diff --git a/README.md b/README.md index 15c2c0cf..e96faf3c 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Active areas in the repository: - React 19 website (React Router 7 + Vite) - Shared Biergarten theme system + Storybook coverage - Auth flows and account/email integration (local Mailpit in dev compose) +- Photo upload to S3-compatible object storage (local SeaweedFS in dev compose) - Data generation pipeline with C++ and Llama.cpp Archived/reference areas: @@ -98,6 +99,7 @@ Backend access: - API Swagger: http://localhost:8080/swagger - Health Check: http://localhost:8080/health - Mailpit UI (dev SMTP): http://localhost:8025 +- SeaweedFS S3 API (dev object storage): http://localhost:8333 ### Frontend (Node) diff --git a/docs/architecture.md b/docs/architecture.md index be27d82c..ad886d46 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,10 +35,11 @@ For visual representations, see: The backend organizes business capabilities as feature slices instead of technical layers. Each feature (`Features.Auth`, `Features.Breweries`, -`Features.UserManagement`, `Features.Emails`, `Features.Locations`) is a single -project that owns its own MediatR commands/queries/handlers, validators, and -repository end to end. `Features.Emails` and `Features.Locations` have no -`Controllers/` folder — both are invoked internally by other slices via +`Features.UserManagement`, `Features.Emails`, `Features.Locations`, +`Features.PhotoUpload`) is a single project that owns its own MediatR +commands/queries/handlers, validators, and repository end to end. +`Features.Emails`, `Features.Locations`, and `Features.PhotoUpload` have no +`Controllers/` folder — all three are invoked internally by other slices via MediatR/DI, never over HTTP: ``` @@ -48,15 +49,15 @@ MediatR/DI, never over HTTP: │ - Swagger/OpenAPI, JWT auth middleware, global exception filter │ └───────────────────────────────────────────────────────────────────────┘ ↓ discovers controllers via AddApplicationPart -┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┐ -│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │ -│ │Breweries │ UserManagement │ (no controller, │ Locations │ -│ Controller │ Controller │ Controller │ internal only) │ (no controller, │ -│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │ -│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │ -│ Handlers │ Handlers │ Handlers │ │ │ -│ Repository │ Repository │ Repository │ EmailDispatcher │ │ -└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┘ +┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┬──────────────────┐ +│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │ Features. │ +│ │Breweries │ UserManagement │ (no controller, │ Locations │ PhotoUpload │ +│ Controller │ Controller │ Controller │ internal only) │ (no controller, │ (no controller, │ +│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │ internal only) │ +│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │ Commands/ │ +│ Handlers │ Handlers │ Handlers │ │ │ Handlers │ +│ Repository │ Repository │ Repository │ EmailDispatcher │ │ Repository │ +└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┴──────────────────┘ ↓ each slice depends only on shared/domain/infra, never on another slice ┌─────────────────────────┬─────────────────────────┬────────────────────┐ │ Shared.Contracts │ Shared.Application │ Domain.Entities / │ @@ -109,7 +110,7 @@ own - No controllers, no business logic, no feature-specific contracts - Exists purely to host and wire up the feature slices -#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`) +#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`, `Features.PhotoUpload`) **Purpose**: Each slice is the complete vertical for one business capability @@ -132,7 +133,12 @@ no `Controllers/` folder and no MediatR handlers — it exposes `ILocationRepository` directly as a plain service, currently consumed only by `Database.Seed` to look up cities while seeding brewery locations. Other slices (for example, `Features.Breweries`) do not yet depend on it; they run their own -`CityID` existence checks inline. +`CityID` existence checks inline. `Features.PhotoUpload` also has no +`Controllers/` folder — its `UploadPhotoCommand` is sent by other features' +handlers (for example, a future brewery photo upload command) rather than +bound to an HTTP route directly. The one exception today is the `/upload-test` +minimal API endpoint wired directly in `API.Core/Program.cs`, a temporary route +for exercising the upload path end to end. **Dependencies**: @@ -140,7 +146,8 @@ no `Controllers/` folder and no MediatR handlers — it exposes - `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure project the slice needs (`Infrastructure.Jwt`/ `Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/ - `Infrastructure.Email.Templates` for Emails) + `Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for + PhotoUpload) - `Shared.Contracts`, `Shared.Application` - **Never** another `Features.*` project @@ -184,11 +191,16 @@ whichever slices need them - **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit) - **Infrastructure.Email.Templates**: Email template rendering (Razor components) +- **Infrastructure.FileUpload**: `IFileStorageProvider` abstraction over + S3-compatible object storage, implemented by `S3FileStorageProvider` + (targets the SeaweedFS container in dev; any S3-compatible endpoint in + other environments) using the AWS SDK's `AmazonS3Client` with + `ForcePathStyle = true` **Dependencies**: - Domain entities -- External libraries (ADO.NET, JWT, Argon2, MailKit, and so on) +- External libraries (ADO.NET, JWT, Argon2, MailKit, AWSSDK.S3, and so on) **Rules**: @@ -212,6 +224,8 @@ whichever slices need them - `BeerStyle` - A beer style/category (schema table exists; no repository yet) - `BeerPost` - A beer listing tied to a brewery and style (schema table exists; no repository yet) +- `Photo` - A photo uploaded by a user account, written via + `Features.PhotoUpload`'s `IPhotoUploadRepository` **Dependencies**: @@ -266,8 +280,14 @@ only: - `Features.Auth/Repository/IAuthRepository.cs` - `Features.Breweries/Repository/IBreweryRepository.cs` - `Features.UserManagement/Repository/IUserAccountRepository.cs` +- `Features.PhotoUpload/Repository/IPhotoUploadRepository.cs` - `Database.Connection/DefaultSqlConnectionFactory.cs`: the generic connection factory every slice's repository builds on +- `Database.Connection/DapperRepository.cs`: shared abstract base class every + repository extends, providing `CreateConnection()` and a + `RollbackQuietlyAsync()` helper that rolls back a transaction while + swallowing any exception the rollback itself raises, so the exception that + triggered the rollback is what propagates **Benefits**: @@ -524,9 +544,11 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md)) - User accounts (via `Features.Auth`'s repository) - Brewery posts with locations (via `Features.Breweries`' repository) -Tables that exist in the schema but have no repository yet (`Photo`, -`UserAvatar`, `UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, -`BeerPostComment`, `BreweryPostPhoto`) are not seeded. +`Photo` rows are written via `Features.PhotoUpload`'s `IPhotoUploadRepository`, +but `Database.Seed` doesn't call it, so photos aren't part of the seed data. +Tables that exist in the schema but have no repository yet (`UserAvatar`, +`UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`, +`BreweryPostPhoto`) are not seeded either. ## Deployment architecture @@ -539,6 +561,7 @@ Tables that exist in the schema but have no repository yet (`Photo`, - `database.seed` - Data seeder - `api.core` - ASP.NET Core Web API - `mailpit` - Local dev/test SMTP server + web UI (not used in production) +- `seaweedfs` - S3-compatible object storage for uploaded photos (dev only) **Environments**: @@ -573,9 +596,9 @@ healthcheck: │ Tests │ ├──────────────┤ │ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests, - │ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests - │ handler │ (commands/queries/handlers, with repository/service - │ tests) │ dependencies mocked via Moq) + │ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests, + │ handler │ Features.PhotoUpload.Tests (commands/queries/handlers, + │ tests) │ with repository/service dependencies mocked via Moq) └──────────────┘ ``` diff --git a/docs/website/database.md b/docs/website/database.md index 2326d03b..5906bad3 100644 --- a/docs/website/database.md +++ b/docs/website/database.md @@ -33,6 +33,7 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`). | `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` | | `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins | | `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins | +| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` | ### Tables defined in the schema but not yet wired to a feature slice @@ -41,7 +42,6 @@ repository or seed data yet: | Table | Purpose (from schema) | | ------------------ | -------------------------------------------------------------------------------------------------------- | -| `Photo` | A photo uploaded by a user account | | `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar | | `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) | | `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet | @@ -66,8 +66,9 @@ an already-applied script means it won't re-run against existing databases. `Database.Seed` populates a target database from data produced by the C++ pipeline (see [Pipeline README](../pipeline/README.md)): countries, state/provinces, and cities (created on demand while resolving each brewery's -location), user accounts, and brewery posts with locations. It only seeds -tables that have a feature slice to write through (see the table above). +location), user accounts, and brewery posts with locations. It doesn't call +every feature slice that has a write path — `Photo`, for example, has a +repository (`Features.PhotoUpload`) but no seed data. ## Database error handling diff --git a/docs/website/docker.md b/docs/website/docker.md index 3a976ac0..844644f1 100644 --- a/docs/website/docker.md +++ b/docs/website/docker.md @@ -29,6 +29,7 @@ representation. - Swagger UI enabled - Seed data included - Local Mailpit SMTP server for dev email testing +- Local SeaweedFS S3-compatible object storage for photo uploads - `CLEAR_DATABASE=true` (drops and recreates schema) **Services**: @@ -39,6 +40,7 @@ database.migrations # DbUp migrations database.seed # Seed initial data api.core # Web API (ports 8080, 8081) mailpit # Local dev SMTP server + UI (port 8025) +seaweedfs # S3-compatible object storage (ports 8333, 8888, 9333, 23646) ``` **Start Development Environment**: @@ -53,6 +55,10 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d - Health Check: http://localhost:8080/health - Mailpit UI: http://localhost:8025 - SQL Server: localhost:1434 (sa credentials from .env.dev) +- SeaweedFS S3 API: http://localhost:8333 (credentials from + `SEAWEEDFS_ACCESS_KEY_ID`/`SEAWEEDFS_SECRET_ACCESS_KEY` in .env.dev) +- SeaweedFS Filer UI: http://localhost:8888 +- SeaweedFS Master UI: http://localhost:9333 **Stop Environment**: @@ -182,8 +188,12 @@ graph TD A[sqlserver: health check passes] --> B[database.migrations: completes successfully] B --> C[database.seed: completes successfully] C --> D[api.core / tests: start when ready] + E[seaweedfs: container started] --> D ``` +In development, `api.core` also depends on `seaweedfs` (`condition: +service_started`, not a health check) alongside `database.seed`. + **Health Check Example** (SQL Server): ```yaml @@ -216,6 +226,8 @@ api.core: - `sqlserverdata-dev` - Database files persist between restarts - `nuget-cache-dev` - NuGet package cache (speeds up builds) +- `seaweedfsdata-dev` - SeaweedFS object storage data persists between + restarts **Testing**: @@ -262,6 +274,10 @@ environment: ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" + SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" + SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" + SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" + SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" ``` For complete list, see [Environment Variables](environment-variables.md). diff --git a/docs/website/environment-variables.md b/docs/website/environment-variables.md index bf9baed4..aa5aa630 100644 --- a/docs/website/environment-variables.md +++ b/docs/website/environment-variables.md @@ -35,8 +35,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml: `web/generate-env.sh` copies `.env.example` to a target file and replaces the secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SMTP_USERNAME`, -`SMTP_PASSWORD`) with freshly randomized ones via `openssl`. Non-secret values -are left untouched. The output file is written with `600` permissions. +`SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`, `SEAWEEDFS_SECRET_ACCESS_KEY`) +with freshly randomized ones via `openssl`. Non-secret values are left +untouched. The output file is written with `600` permissions. ```bash cd web @@ -221,6 +222,25 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten - **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES, and so on) +### File storage configuration (backend) + +Read by `Infrastructure.FileUpload/S3FileStorageProvider.cs` for uploading and +retrieving photos via an S3-compatible object store. + +```bash +SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default +SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default +SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default +SEAWEEDFS_BUCKET=my-bucket # Required, no default +``` + +- **Implementation**: `S3FileStorageProvider` throws on construction if any of + these four values is missing +- **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333) +- **Production**: point at any S3-compatible endpoint (AWS S3, a hosted + SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which + SeaweedFS requires and AWS S3 also accepts + ## Docker-specific variables ### SQL Server container @@ -288,6 +308,10 @@ reference only and is not run as part of the active stack. | `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` | | `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address | | `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` | +| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL | +| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key | +| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key | +| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name | | `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL | | `SESSION_SECRET` | | ✓ | | Yes | Website session signing | | `NODE_ENV` | | ✓ | | No | Runtime mode | @@ -309,8 +333,10 @@ reference only and is not run as part of the active stack. Variables are validated at startup: - Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, - `CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection - values) cause the application to fail with an `InvalidOperationException` + `CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, + `SEAWEEDFS_SERVICE_URL`, `SEAWEEDFS_ACCESS_KEY_ID`, + `SEAWEEDFS_SECRET_ACCESS_KEY`, `SEAWEEDFS_BUCKET`, DB connection values) + cause the application to fail with an `InvalidOperationException` - No minimum length is enforced on the token secrets in code; the "minimum 32 characters" guidance above is a recommendation, not an enforced check @@ -347,6 +373,12 @@ SMTP_USE_SSL=false SMTP_FROM_EMAIL=noreply@thebiergarten.app SMTP_FROM_NAME=The Biergarten App +# SeaweedFS (S3-compatible storage) +SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 +SEAWEEDFS_ACCESS_KEY_ID= +SEAWEEDFS_SECRET_ACCESS_KEY= +SEAWEEDFS_BUCKET=my-bucket + # Migration CLEAR_DATABASE=true diff --git a/docs/website/getting-started.md b/docs/website/getting-started.md index 2dda2db5..e0492922 100644 --- a/docs/website/getting-started.md +++ b/docs/website/getting-started.md @@ -58,14 +58,16 @@ section for details on `generate-env.sh`. docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d ``` -This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev -SMTP). +This starts SQL Server, migrations, seeding, the API, Mailpit (local dev +SMTP), and SeaweedFS (local dev S3-compatible object storage for photo +uploads). Available endpoints: - API Swagger: http://localhost:8080/swagger - Health Check: http://localhost:8080/health - Mailpit UI: http://localhost:8025 +- SeaweedFS S3 API: http://localhost:8333 ### 4. Start the active frontend @@ -124,6 +126,10 @@ export ACCESS_TOKEN_SECRET="" export REFRESH_TOKEN_SECRET="" export CONFIRMATION_TOKEN_SECRET="" export WEBSITE_BASE_URL="http://localhost:3000" +export SEAWEEDFS_SERVICE_URL="http://localhost:8333" +export SEAWEEDFS_ACCESS_KEY_ID="" +export SEAWEEDFS_SECRET_ACCESS_KEY="" +export SEAWEEDFS_BUCKET="my-bucket" ``` ### 2. Run migrations and seed diff --git a/docs/website/testing.md b/docs/website/testing.md index 8a58f011..6ae8c94f 100644 --- a/docs/website/testing.md +++ b/docs/website/testing.md @@ -11,9 +11,9 @@ The project uses a multi-layered testing approach across backend and frontend: live, seeded database - **Features.\*.Tests** - One unit test project per backend feature slice (`Features.Auth.Tests`, `Features.Breweries.Tests`, - `Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that - slice's command/query handlers, with dependencies mocked via Moq (no real - database required) + `Features.UserManagement.Tests`, `Features.Emails.Tests`, + `Features.PhotoUpload.Tests`), covering that slice's command/query + handlers, with dependencies mocked via Moq (no real database required) - **Storybook Vitest project** - Browser-based interaction tests for shared website stories - **Storybook Playwright suite** - Browser checks against Storybook-rendered @@ -49,6 +49,7 @@ cat test-results/Features.Auth.Tests.trx cat test-results/Features.Breweries.Tests.trx cat test-results/Features.UserManagement.Tests.trx cat test-results/Features.Emails.Tests.trx +cat test-results/Features.PhotoUpload.Tests.trx ``` ### Clean up @@ -86,6 +87,7 @@ dotnet test Features/Features.Auth.Tests/Features.Auth.Tests.csproj dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj dotnet test Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj +dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj # Or run all of them at once via the solution: for proj in Features/*.Tests; do dotnet test "$proj"; done @@ -175,6 +177,14 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`. - Registration and resend-confirmation email dispatch handlers +**Features.PhotoUpload.Tests**: + +- Upload command handler: storage key construction, content type passthrough, + and photo persistence via the mocked storage provider and repository +- Upload validator: min/max file size bounds, and PNG/JPEG/WebP signature + checks (including rejection of files with a mismatched or missing + signature) + **Frontend UI Coverage**: - Shared submit button states @@ -188,7 +198,6 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`. - [ ] Password reset functionality - [ ] Beer post operations - [ ] User follow/unfollow -- [ ] Image upload service - [ ] Frontend route integration coverage beyond Storybook stories ## Testing frameworks and tools @@ -276,9 +285,11 @@ Features.Auth.Tests/ └── TokenServiceValidationTests.cs ``` -Each of the other three slices (`Features.Breweries.Tests`, -`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same -shape: a `Commands/`/`Queries/` folder with one test file per handler. +Each of the other slices (`Features.Breweries.Tests`, +`Features.UserManagement.Tests`, `Features.Emails.Tests`, +`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/` +folder with one test file per handler (`Features.PhotoUpload.Tests` also adds +a validator test file alongside its handler test). ## Writing tests