11 Commits

Author SHA1 Message Date
Aaron Po
1cda5779a4 add restart tolerance 2026-10-04 20:29:37 -04:00
Aaron Po
0d74c5f47d Add theme picker stories and cover the try-out card chrome 2026-10-04 20:23:16 -04:00
Aaron Po
3e1ed5643e Refactor FeatureNotAvailable component interfaces and improve code formatting 2026-10-04 20:12:46 -04:00
Aaron Po
bd01f47df5 Enhance theme selection UI with mood descriptors and improved styling 2026-10-04 20:02:55 -04:00
Aaron Po
cc52ad3d5c Remove beer emoji from the closing call-to-action heading 2026-10-04 19:56:01 -04:00
Aaron Po
632a3df3dc update docs 2026-10-04 19:51:14 -04:00
Aaron Po
2291fd12a9 docker updates 2026-10-04 19:50:50 -04:00
Aaron Po
c187c475ab update fonts, add favicon 2026-10-04 19:37:50 -04:00
Aaron Po
2b8a03148f Standardise page wrapper width and top padding and refresh catalog and theme styling 2026-10-04 04:49:50 -04:00
Aaron Po
9581435578 Update packages to resolve security vulnerabilities 2026-10-04 03:57:29 -04:00
Aaron Po
4105914633 Make the production stack deployable and disclose the AI-generated catalogue 2026-10-03 22:30:08 -04:00
46 changed files with 177 additions and 969 deletions

View File

@@ -1,101 +0,0 @@
## Summary
<!--
State WHAT is changing and WHY, in two to five sentences.
A reader should understand the change without opening the diff.
Describe the problem first, then the outcome.
-->
## Type of change
<!-- Tick the one option that best describes this PR. -->
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor (no change in behaviour)
- [ ] Performance improvement
- [ ] Documentation
- [ ] Tests only
- [ ] Build, CI or dependency update
- [ ] Other (please specify):
## Related issues
<!--
Link the issue(s) this PR relates to. Use one keyword per issue so that each is closed on merge:
Fixes #123
Fixes #124, fixes #125
Fixes owner/other-repo#456
Closing keywords only take effect when the PR targets the repository's default branch.
Use "Related to #123" if the PR should not close the issue.
If there is no issue, write "N/A" and explain the motivation in the Summary.
-->
## Approach and context
<!--
Explain how the change works and why this approach was chosen.
Include, where relevant:
- The decisions you made that are not obvious from the code.
- Alternatives you considered and why you rejected them.
- Known limitations or shortcomings, and any planned follow-up work.
- Background such as benchmark results or design documents.
Summarise the key points of any external link. Links may be inaccessible to future readers.
-->
## How this was tested
<!--
Describe what you ran and what you observed. Be specific, and state only what you actually did.
For example: the automated tests added or changed, manual steps and their outcome,
environments or platforms covered, and anything you were unable to test.
For a bug fix, note the test that fails without the fix.
-->
## Compatibility and risk
- [ ] This change is backwards compatible.
- [ ] This change is **breaking** (complete the details below).
- [ ] This change deprecates existing behaviour (complete the details below).
<!--
If breaking or deprecating, describe: what changes, who is affected,
and the migration path or recommended alternative.
Also note any deployment, configuration, data migration or rollback considerations.
-->
## User-facing change
<!--
Does this PR change behaviour that users, operators or API consumers will notice?
If not, write "None".
If so, write one or two sentences suitable for release notes, in plain language and from the
user's point of view. If users must take action when upgrading, state clearly that action is required.
-->
```release-note
```
## Notes for reviewers
<!--
Help reviewers focus. For example:
- Where to start, or a suggested order in which to read the files.
- Areas where you would particularly value scrutiny, or where you are uncertain.
- How any mechanical, generated or bulk changes were produced (for example a search-and-replace
or a code generator), so that reviewers can reproduce the steps.
- Screenshots or recordings for visual changes (include before and after where useful).
-->
## Checklist
- [ ] The PR has a single, focused purpose and contains no unrelated changes.
- [ ] The title is a clear, imperative summary of the change (for example, "Add retry to payment client").
- [ ] I have added or updated tests that cover this change.
- [ ] The build succeeds and the tests pass locally.
- [ ] The code follows the existing patterns and style of this repository.
- [ ] I have updated documentation, changelogs or API references where required.
- [ ] I have reviewed my own diff, and this description reflects the final state of the change.

1
.gitignore vendored
View File

@@ -507,3 +507,4 @@ web/test-results/frontend/playwright-storybook.xml
# mkdocs build output # mkdocs build output
/site /site

View File

@@ -76,14 +76,14 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique | | `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs | | `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email | | `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository`, read via `IUserListRepository`'s left joins in both `GetByIdAsync` and `GetAllAsync` | | `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository`, read via `IUserListRepository.GetByIdAsync`'s left join |
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync`, read via `IUserListRepository`'s `ValidTo IS NULL` join for the active avatar | | `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing | | `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly | | `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; `CityName` and `StateProvinceID` together are unique; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` | | `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; `CityName` and `StateProvinceID` together are unique; 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 | | `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 | | `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; `Hyperlink` holds the object key within the bucket, which readers sign into a time-limited URL via `IFileStorageProvider.GetPresignedUrl`; the active avatar's row is also read via `IUserListRepository`'s `Media.Photo` join; not yet seeded by `Database.Seed` | | `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 ### Tables defined in the schema but not yet wired to a feature slice

View File

@@ -41,7 +41,7 @@ no data flowing over them.
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). | | **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. | | **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. |
| **Prod network** (`prodnet`, bridge, named `biergarten-prodnet`) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod`, both external, so `down -v` keeps them. Startup order: SeaweedFS (health check) → API (health check) → frontend, with `frontend` the only service publishing a host port, bound to `127.0.0.1`. Migrating and seeding run on demand as their own run-once jobs joining `biergarten-prodnet`, so an `up` or a reboot replays neither. | | **Prod network** (`prodnet`, bridge, named `biergarten-prodnet`) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod` (both external, so `down -v` keeps them), plus `nuget-cache-prod`. Startup order: SQL Server (health check) → migrations → API → frontend, with `frontend` the only service publishing a host port. Seeding runs on demand from `docker-compose.prod.seed.yaml`, as its own Compose project joining `biergarten-prodnet`. |
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. | | **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. | | **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. | | `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |

View File

@@ -1,13 +1,12 @@
sequenceDiagram sequenceDiagram
box Production Environment (docker-compose.prod.yaml) box Production Environment (docker-compose.prod.yaml)
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
participant ProdS3 as SeaweedFS<br/>S3 gateway :8333<br/>no host port published participant ProdMig as Migrations (run-once)
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
participant ProdWeb as Frontend Container<br/>Express + React Router<br/>127.0.0.1:5656
end end
ProdAPI-->>ProdS3: depends_on: seaweedfs healthy ProdMig-->>ProdDB: depends_on: sqlserver healthy
ProdWeb-->>ProdAPI: depends_on: api.core healthy ProdMig->>ProdDB: Run migrations
ProdAPI-->>ProdMig: depends_on: migrations completed
ProdAPI->>ProdDB: Connect and serve ProdAPI->>ProdDB: Connect and serve
ProdAPI->>ProdS3: Store and read photos Note over ProdDB: Seeding is a separate run-once job<br/>(docker-compose.prod.seed.yaml)
Note over ProdDB: Migrating and seeding are separate run-once jobs,<br/>run at deploy time rather than by the stack

View File

@@ -149,17 +149,13 @@ containers, which would end the run prematurely. See
**Services**: **Services**:
```yaml ```yaml
sqlserver # Production SQL Server (internal only) sqlserver # Production SQL Server (internal only)
database.migrations # Schema updates only
api.core # Production API (internal only, no published ports) api.core # Production API (internal only, no published ports)
seaweedfs # S3-compatible object storage (internal only) seaweedfs # S3-compatible object storage (internal only)
frontend # Production website (127.0.0.1:5656, the only host binding) frontend # Production website (127.0.0.1:5656, the only host binding)
``` ```
Migrating and seeding are run-once jobs kept outside this file, so an `up` or a
restart touches neither the schema nor the catalogue. Run them explicitly at
deploy time: see [Migrating production](#migrating-production) and
[Seeding production](#seeding-production-docker-composeprodseedyaml).
**Deploy Production**: **Deploy Production**:
```bash ```bash
@@ -175,32 +171,14 @@ Docker seeds each volume with the ownership the image expects and labels it for
SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on
enforcing distributions such as RHEL, AlmaLinux and Fedora. enforcing distributions such as RHEL, AlmaLinux and Fedora.
#### Migrating production
`database.migrations` is a run-once job rather than a service in the prod stack,
so the schema is migrated when an operator asks for it instead of on every boot.
Build the image and run it against the network the prod stack creates:
```bash
docker build -t database.migrations \
-f web/backend/Database/Database.Migrations/Dockerfile \
--build-arg BUILD_CONFIGURATION=Release --build-arg APP_UID=1000 \
web/backend
docker run --rm --env-file web/.env.prod --network biergarten-prodnet \
-e DOTNET_RUNNING_IN_CONTAINER=true database.migrations
```
DbUp records every script it has applied, so a re-run applies only what is new.
Run this with `sqlserver` already up and healthy, and before the seed job.
#### Restart tolerance #### Restart tolerance
`sqlserver`, `api.core`, `seaweedfs` and `frontend` run under `sqlserver`, `api.core`, `seaweedfs` and `frontend` run under
`restart: unless-stopped`, so the stack returns on its own after a container `restart: unless-stopped`, so the stack returns on its own after a container
crash, a Docker daemon restart or a host reboot, provided Docker itself starts at crash, a Docker daemon restart or a host reboot, provided Docker itself starts at
boot (`systemctl enable docker`). The migration and seed jobs stay out of the boot (`systemctl enable docker`). `database.migrations` keeps `restart: "no"`: it
stack, so a reboot replays neither. is a one-shot job that runs once per deploy and stays exited across a reboot, so
the schema is migrated on deploy rather than on every boot.
The daemon replays restart policies without replaying the `depends_on` graph, so The daemon replays restart policies without replaying the `depends_on` graph, so
after a reboot the containers come back in arbitrary order and each one tolerates after a reboot the containers come back in arbitrary order and each one tolerates
@@ -231,7 +209,7 @@ docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml ps
#### Seeding production (`docker-compose.prod.seed.yaml`) #### Seeding production (`docker-compose.prod.seed.yaml`)
`database.seed` lives in its own compose file, run on demand once the prod stack `database.seed` lives in its own compose file, run on demand once the prod stack
is up, `sqlserver` is healthy, and the schema is migrated: is up and `sqlserver` is healthy:
```bash ```bash
docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \ docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \
@@ -242,8 +220,8 @@ The job runs as the `biergarten-prod-seed` Compose project and attaches to the
external `biergarten-prodnet` network, which the main prod stack creates, so it external `biergarten-prodnet` network, which the main prod stack creates, so it
reaches `sqlserver` and `seaweedfs` under the same hostnames the API uses. It reaches `sqlserver` and `seaweedfs` under the same hostnames the API uses. It
exits when seeding finishes; `--rm` removes the container. Bring the prod stack exits when seeding finishes; `--rm` removes the container. Bring the prod stack
up first, because the seed project has no visibility into the other project's up first — the seed project has no visibility into the other project's health
health checks, so a run started before SQL Server is accepting connections fails. checks, so a run started before SQL Server is accepting connections fails.
#### Production data #### Production data
@@ -254,7 +232,7 @@ anyone interacting with an application seeded from it has to be told upfront tha
the content is AI-generated. the content is AI-generated.
The website meets that obligation with `AiDisclosureDialog` The website meets that obligation with `AiDisclosureDialog`
(`web/frontend/app/features/home/components/AiDisclosureDialog.tsx`), which (`web/frontend/app/features/disclosure/components/AiDisclosureDialog.tsx`), which
the root route renders on every full page load, whichever route the visitor lands the root route renders on every full page load, whichever route the visitor lands
on. It is part of the server-rendered HTML rather than mounted after hydration, so on. It is part of the server-rendered HTML rather than mounted after hydration, so
the disclosure is on screen with the first paint. Acknowledging it hides the the disclosure is on screen with the first paint. Acknowledging it hides the
@@ -324,11 +302,11 @@ graph TD
In development, `api.core` also depends on `seaweedfs` In development, `api.core` also depends on `seaweedfs`
(`condition: service_started`, not a health check) alongside `database.seed`. (`condition: service_started`, not a health check) alongside `database.seed`.
Production has a shorter chain, because the migration and seed jobs run outside In production the chain is gated on health instead: `api.core` waits for
the stack: `api.core` waits for `seaweedfs` to pass its health check, and `seaweedfs` to pass its health check and for `database.migrations` to exit
`frontend` waits for `api.core` to report healthy. This ordering applies to successfully, and `frontend` waits for `api.core` to report healthy. This ordering
`docker compose up`; see [Restart tolerance](#restart-tolerance) for what happens applies to `docker compose up`; see
on a host reboot. [Restart tolerance](#restart-tolerance) for what happens on a host reboot.
**Health Check Example** (SQL Server): **Health Check Example** (SQL Server):
@@ -372,6 +350,7 @@ api.core:
- `biergartendata-prod` - Database files, external; survives `down -v` - `biergartendata-prod` - Database files, external; survives `down -v`
- `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v` - `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v`
- `nuget-cache-prod` - Production NuGet cache
See [Production data volumes](#production-data-volumes). See [Production data volumes](#production-data-volumes).
@@ -394,11 +373,10 @@ stack down and leaves the database and the uploaded photos in place. Deleting
them takes an explicit `docker volume rm`. them takes an explicit `docker volume rm`.
The trade-off is that Compose creates neither one, and `up` fails with a clear The trade-off is that Compose creates neither one, and `up` fails with a clear
error until both exist, hence the `docker volume create` step in error until both exist — hence the `docker volume create` step in
[Deploy Production](#3-production-docker-composeprodyaml). External volumes also [Deploy Production](#3-production-docker-composeprodyaml). External volumes also
carry their literal name rather than a `<project>_` prefix, so `docker volume ls` carry their literal name rather than a `<project>_` prefix, which is why
shows `biergartendata-prod` and `seaweedfsdata-prod` unprefixed, unlike the `docker volume ls` shows `biergartendata-prod` next to `web_nuget-cache-prod`.
`web_`-prefixed volumes of the dev and test stacks.
Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point
the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at

View File

@@ -260,25 +260,17 @@ and retrieving photos via an S3-compatible object store.
```bash ```bash
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default
SEAWEEDFS_PUBLIC_URL=http://localhost:8333 # Optional, defaults to SEAWEEDFS_SERVICE_URL
SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default
SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default
SEAWEEDFS_BUCKET=my-bucket # Required, no default SEAWEEDFS_BUCKET=my-bucket # Required, no default
``` ```
- **Implementation**: `S3FileStorageProvider` throws on construction if any of - **Implementation**: `S3FileStorageProvider` throws on construction if any of
the four required values is missing these four values is missing
- **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333) - **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333)
- **Production**: point at any S3-compatible endpoint (AWS S3, a hosted - **Production**: point at any S3-compatible endpoint (AWS S3, a hosted
SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which
SeaweedFS requires and AWS S3 also accepts SeaweedFS requires and AWS S3 also accepts
- **`SEAWEEDFS_PUBLIC_URL`**: the endpoint download URLs are signed against,
used by `GetPresignedUrl` and reaching the API response as
`PublicUserProfileDto.AvatarUrl`. A presigned URL is signed over its host, so
this has to be the address the browser uses (`http://localhost:8333` with the
dev compose file, which publishes the S3 API port), separate from the
in-network `SEAWEEDFS_SERVICE_URL` the API uploads through. The two can be the
same value wherever one address serves both
## Docker-specific variables ## Docker-specific variables
@@ -349,7 +341,6 @@ from the template in place; edit them manually if you go that route.
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address | | `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` | | `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL | | `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL |
| `SEAWEEDFS_PUBLIC_URL` | ✓ | | ✓ | No | Endpoint download URLs are signed against; defaults to `SEAWEEDFS_SERVICE_URL` |
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key | | `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key | | `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name | | `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
@@ -424,7 +415,6 @@ SMTP_FROM_NAME=The Biergarten App
# SeaweedFS (S3-compatible storage) # SeaweedFS (S3-compatible storage)
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl> SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl>
SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl> SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl>
SEAWEEDFS_BUCKET=my-bucket SEAWEEDFS_BUCKET=my-bucket

View File

@@ -257,8 +257,7 @@ API.Specs/
│ └── ApiGeneralSteps.cs # Shared/general step definitions │ └── ApiGeneralSteps.cs # Shared/general step definitions
├── Mocks/ ├── Mocks/
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher │ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
│ ├── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider │ └── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider
│ └── MockFileStorageProvider.cs # Substitutes Features.ImageUploads' IFileStorageProvider
└── TestApiFactory.cs # Test server setup (swaps in the mocks above) └── TestApiFactory.cs # Test server setup (swaps in the mocks above)
``` ```

View File

@@ -68,12 +68,8 @@ WEBSITE_BASE_URL=http://localhost:3000
# ====================== # ======================
# SeaweedFS (S3) Configuration # SeaweedFS (S3) Configuration
# ====================== # ======================
# Credentials and default bucket for the local SeaweedFS S3-compatible storage. # Credentials and default bucket for the local SeaweedFS S3-compatible storage
# SEAWEEDFS_SERVICE_URL is the in-network endpoint the API uploads through;
# SEAWEEDFS_PUBLIC_URL is the endpoint download URLs are signed against, so it has to be
# reachable from the browser. It defaults to SEAWEEDFS_SERVICE_URL when left blank.
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
SEAWEEDFS_ACCESS_KEY_ID=admin SEAWEEDFS_ACCESS_KEY_ID=admin
SEAWEEDFS_SECRET_ACCESS_KEY=secret SEAWEEDFS_SECRET_ACCESS_KEY=secret
SEAWEEDFS_BUCKET=my-bucket SEAWEEDFS_BUCKET=my-bucket

View File

@@ -13,61 +13,9 @@ Scenario: Public profile exposes only allowlisted fields
Then the response has HTTP status 200 Then the response has HTTP status 200
And the public profile response should match the registered account And the public profile response should match the registered account
And the response JSON should have "biography" equal "" And the response JSON should have "biography" equal ""
And the response JSON should have "avatarUrl" equal ""
And the response JSON should not contain "email" And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth" And the response JSON should not contain "dateOfBirth"
Scenario: Public profile exposes the uploaded avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: Public profile exposes the current avatar after it is replaced
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
And I have uploaded a new avatar, replacing the previous one
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
And the public profile response should not carry my previous avatar
Scenario: Fetching the avatar without authentication succeeds
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve the public profile by ID without authentication
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: A different user can read someone else's avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
And I am logged in as a different user
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: Listing user accounts exposes each account's avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I list user accounts
Then the response has HTTP status 200
And my listed account should carry my current avatar
Scenario: Listing user accounts exposes each account's biography
Given I have registered a new account
And I have a valid access token for my account
And I have set my biography to "Drinks lagers."
When I list user accounts
Then the response has HTTP status 200
And my listed account should carry my biography
Scenario: Public profile for a non-existent account returns not found Scenario: Public profile for a non-existent account returns not found
Given I have registered a new account Given I have registered a new account
And I have a valid access token for my account And I have a valid access token for my account
@@ -78,7 +26,6 @@ Scenario: Fetching the public profile without authentication succeeds
Given I have registered a new account Given I have registered a new account
When I retrieve the public profile by ID without authentication When I retrieve the public profile by ID without authentication
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should have "avatarUrl" equal ""
And the response JSON should not contain "email" And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth" And the response JSON should not contain "dateOfBirth"
@@ -103,7 +50,6 @@ Scenario: Listing user accounts as an authenticated user excludes private fields
And I have a valid access token for my account And I have a valid access token for my account
When I list user accounts When I list user accounts
Then the response has HTTP status 200 Then the response has HTTP status 200
And the response JSON should contain "avatarUrl"
And the response JSON should not contain "email" And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth" And the response JSON should not contain "dateOfBirth"
@@ -114,14 +60,6 @@ Scenario: The caller can fetch their own account
Then the response has HTTP status 200 Then the response has HTTP status 200
And the public profile response should match the registered account And the public profile response should match the registered account
Scenario: The caller's own account omits the avatar graph
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve my own account
Then the response has HTTP status 200
And the response JSON should not contain "userAvatar"
Scenario: The account endpoint serves the caller named by the access token Scenario: The account endpoint serves the caller named by the access token
Given I have registered a new account Given I have registered a new account
And I am logged in as a different user And I am logged in as a different user

View File

@@ -1,55 +0,0 @@
using Features.ImageUploads.Services;
namespace API.Specs.Mocks;
/// <summary>
/// Test double for <see cref="IFileStorageProvider" /> that records uploads in memory, so specs can
/// exercise photo-backed features without object storage.
/// </summary>
public class MockFileStorageProvider : IFileStorageProvider
{
private readonly List<string> _keys = [];
private readonly Dictionary<string, byte[]> _objects = new();
public IReadOnlyCollection<string> UploadedKeys => _keys.ToList();
/// <summary>The key of the most recent upload.</summary>
/// <exception cref="InvalidOperationException">Thrown when nothing has been uploaded yet.</exception>
public string LastUploadedKey =>
_keys.Count > 0
? _keys[^1]
: throw new InvalidOperationException("no file has been uploaded");
public Task UploadAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default
)
{
using MemoryStream buffer = new();
content.CopyTo(buffer);
if (!_objects.ContainsKey(key))
_keys.Add(key);
_objects[key] = buffer.ToArray();
return Task.CompletedTask;
}
public Task DeleteAsync(string key, CancellationToken cancellationToken = default)
{
_objects.Remove(key);
_keys.Remove(key);
return Task.CompletedTask;
}
/// <summary>
/// Stands in for a signed download URL: an absolute URL on a host the object store would serve,
/// carrying the key in its path, so specs can assert the shape callers actually receive.
/// </summary>
public string GetPresignedUrl(string key, TimeSpan expiresIn)
{
return $"https://storage.test/{key}?X-Amz-Expires={(int)expiresIn.TotalSeconds}";
}
}

View File

@@ -183,25 +183,6 @@ public class ApiGeneralSteps(ScenarioContext scenario)
AssertFieldAbsent(payloadElem, field); AssertFieldAbsent(payloadElem, field);
} }
[Then("the response JSON should contain {string}")]
public void ThenTheResponseJsonShouldContainString(string field)
{
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
!root.TryGetProperty(field, out _) &&
root.TryGetProperty("payload", out JsonElement payloadElem))
{
AssertFieldPresent(payloadElem, field);
return;
}
AssertFieldPresent(root, field);
}
/// <summary> /// <summary>
/// Recurses into arrays so a listing endpoint's response can be asserted the same way as a /// Recurses into arrays so a listing endpoint's response can be asserted the same way as a
/// single-resource response. /// single-resource response.
@@ -222,31 +203,4 @@ public class ApiGeneralSteps(ScenarioContext scenario)
break; break;
} }
} }
/// <summary>
/// Recurses into arrays so every item of a listing endpoint's response must carry the field.
/// </summary>
private static void AssertFieldPresent(JsonElement element, string field)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
element
.TryGetProperty(field, out _)
.Should()
.BeTrue("Expected field '{0}' to be present in the response", field);
break;
case JsonValueKind.Array:
element
.EnumerateArray()
.Should()
.NotBeEmpty(
"Expected at least one item to assert field '{0}' against",
field
);
foreach (JsonElement item in element.EnumerateArray())
AssertFieldPresent(item, field);
break;
}
}
} }

View File

@@ -1,12 +1,5 @@
using System.Text.Json; using System.Text.Json;
using API.Specs.Mocks;
using Features.ImageUploads.Services;
using Features.Users.Commands.Profile.UploadAvatar;
using Features.Users.Repository;
using FluentAssertions; using FluentAssertions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Reqnroll; using Reqnroll;
namespace API.Specs.Steps; namespace API.Specs.Steps;
@@ -17,11 +10,6 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
private const string AccessTokenKey = "accessToken"; private const string AccessTokenKey = "accessToken";
private const string RegisteredUserIdKey = "registeredUserId"; private const string RegisteredUserIdKey = "registeredUserId";
private const string RegisteredUsernameKey = "registeredUsername"; private const string RegisteredUsernameKey = "registeredUsername";
private const string CurrentAvatarKeyKey = "currentAvatarKey";
private const string PreviousAvatarKeyKey = "previousAvatarKey";
private const string BiographyKey = "biography";
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private async Task SendAsync(HttpRequestMessage requestMessage) private async Task SendAsync(HttpRequestMessage requestMessage)
{ {
@@ -48,193 +36,22 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
: throw new InvalidOperationException("registered user ID not found in scenario"); : throw new InvalidOperationException("registered user ID not found in scenario");
} }
/// <summary>
/// Builds the smallest upload the photo validator accepts: a PNG signature padded out to the
/// 1 KiB minimum.
/// </summary>
private static IFormFile CreateAvatarFile()
{
byte[] content = new byte[2048];
PngSignature.CopyTo(content, 0);
MemoryStream stream = new(content);
return new FormFile(stream, 0, content.Length, "file", "avatar.png")
{
Headers = new HeaderDictionary { ["Content-Type"] = "image/png" },
};
}
/// <summary>
/// Uploads an avatar and records the object key the storage provider actually received, rather
/// than re-deriving the key template the handler uses.
/// </summary>
private async Task UploadAvatarAsync()
{
Guid userId = GetRegisteredUserId();
using IServiceScope scope = GetFactory().Services.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(new UploadAvatarCommand(userId, CreateAvatarFile()));
if (Scenario.TryGetValue<string>(CurrentAvatarKeyKey, out string? existing))
Scenario[PreviousAvatarKeyKey] = existing;
Scenario[CurrentAvatarKeyKey] = GetStorageProvider().LastUploadedKey;
}
private MockFileStorageProvider GetStorageProvider()
{
return (MockFileStorageProvider)
GetFactory().Services.GetRequiredService<IFileStorageProvider>();
}
private string GetCurrentAvatarKey()
{
return Scenario.TryGetValue(CurrentAvatarKeyKey, out string? key) && key is not null
? key
: throw new InvalidOperationException("no avatar has been uploaded in this scenario");
}
private string GetPreviousAvatarKey()
{
return Scenario.TryGetValue(PreviousAvatarKeyKey, out string? key) && key is not null
? key
: throw new InvalidOperationException(
"only one avatar has been uploaded in this scenario"
);
}
/// <summary>
/// Asserts that <paramref name="avatarUrl" /> is something a browser can fetch -- an absolute
/// HTTP(S) URL -- pointing at the object stored under <paramref name="expectedKey" />. A bare
/// storage key would resolve against the page's own origin, so the shape is the contract here,
/// not just the value.
/// </summary>
private static Uri AssertFetchableUrl(string? avatarUrl, string expectedKey)
{
avatarUrl.Should().NotBeNullOrWhiteSpace();
Uri.TryCreate(avatarUrl, UriKind.Absolute, out Uri? uri)
.Should()
.BeTrue("avatarUrl must be an absolute URL, but was '{0}'", avatarUrl);
uri!.Scheme.Should().BeOneOf("http", "https");
uri.AbsolutePath.Should().EndWith(expectedKey, "the URL must address the uploaded object");
return uri;
}
private string GetAvatarUrlFromResponse()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
return doc.RootElement.GetProperty("avatarUrl").GetString()!;
}
[Given("I have uploaded an avatar")]
public async Task GivenIHaveUploadedAnAvatar()
{
await UploadAvatarAsync();
}
[Given("I have uploaded a new avatar, replacing the previous one")]
public async Task GivenIHaveUploadedANewAvatarReplacingThePreviousOne()
{
await UploadAvatarAsync();
}
/// <summary>
/// Writes the biography straight through the repository: registration creates the profile row
/// with an empty biography, and no endpoint edits it yet.
/// </summary>
[Given("I have set my biography to {string}")]
public async Task GivenIHaveSetMyBiographyTo(string biography)
{
using IServiceScope scope = GetFactory().Services.CreateScope();
IUserProfileRepository profiles =
scope.ServiceProvider.GetRequiredService<IUserProfileRepository>();
await profiles.UpdateBiographyAsync(
GetRegisteredUserId(),
biography,
CancellationToken.None
);
Scenario[BiographyKey] = biography;
}
[Then("the public profile response should carry my current avatar")]
public void ThenThePublicProfileResponseShouldCarryMyCurrentAvatar()
{
AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
}
[Then("the public profile response should not carry my previous avatar")]
public void ThenThePublicProfileResponseShouldNotCarryMyPreviousAvatar()
{
Uri uri = AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
uri.AbsolutePath.Should().NotContain(GetPreviousAvatarKey());
}
[Then("my listed account should carry my current avatar")]
public void ThenMyListedAccountShouldCarryMyCurrentAvatar()
{
AssertFetchableUrl(
GetMyListedAccount().GetProperty("avatarUrl").GetString(),
GetCurrentAvatarKey()
);
}
[Then("my listed account should carry my biography")]
public void ThenMyListedAccountShouldCarryMyBiography()
{
string expected =
Scenario.TryGetValue(BiographyKey, out string? biography) && biography is not null
? biography
: throw new InvalidOperationException("no biography was set in this scenario");
GetMyListedAccount().GetProperty("biography").GetString().Should().Be(expected);
}
/// <summary>Finds the registered account in a listing response.</summary>
private JsonElement GetMyListedAccount()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array);
return doc
.RootElement.EnumerateArray()
.Should()
.ContainSingle(item =>
item.GetProperty("userAccountId").GetGuid() == GetRegisteredUserId()
)
.Subject.Clone();
}
[When("I retrieve the public profile by ID")] [When("I retrieve the public profile by ID")]
public async Task WhenIRetrieveThePublicProfileById() public async Task WhenIRetrieveThePublicProfileById()
{ {
await SendAsync( await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile"));
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
);
} }
[When("I retrieve the public profile by ID without authentication")] [When("I retrieve the public profile by ID without authentication")]
public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication() public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication()
{ {
await SendAsync( await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile"));
new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
);
} }
[When("I retrieve the public profile by a non-existent ID")] [When("I retrieve the public profile by a non-existent ID")]
public async Task WhenIRetrieveThePublicProfileByANonExistentId() public async Task WhenIRetrieveThePublicProfileByANonExistentId()
{ {
await SendAsync( await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile"));
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile")
);
} }
[When("I retrieve my own account without authentication")] [When("I retrieve my own account without authentication")]

View File

@@ -1,7 +1,6 @@
using API.Core; using API.Core;
using API.Specs.Mocks; using API.Specs.Mocks;
using Features.Emails.Services; using Features.Emails.Services;
using Features.ImageUploads.Services;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -33,15 +32,6 @@ internal class TestApiFactory : WebApplicationFactory<Program>
services.Remove(emailDispatcherDescriptor); services.Remove(emailDispatcherDescriptor);
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>(); services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
ServiceDescriptor? fileStorageDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IFileStorageProvider)
);
if (fileStorageDescriptor != null)
services.Remove(fileStorageDescriptor);
services.AddSingleton<IFileStorageProvider, MockFileStorageProvider>();
}); });
} }
} }

View File

@@ -23,14 +23,6 @@ public static class ConfigurationKeys
public const string SmtpFromName = "SMTP_FROM_NAME"; public const string SmtpFromName = "SMTP_FROM_NAME";
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL"; public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
/// <summary>
/// The browser-reachable base URL of the object store, used to sign download URLs. Falls back to
/// <see cref="SeaweedFsServiceUrl" /> when unset: SigV4 covers the host, so a URL signed against
/// the container-internal endpoint is only usable by callers that can resolve that host.
/// </summary>
public const string SeaweedFsPublicUrl = "SEAWEEDFS_PUBLIC_URL";
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID"; public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY"; public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET"; public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";

View File

@@ -1,5 +1,3 @@
using System.Text.Json.Serialization;
namespace Domain.Entities; namespace Domain.Entities;
public sealed class UserAccount public sealed class UserAccount
@@ -22,10 +20,4 @@ public sealed class UserAccount
// Navigation properties // Navigation properties
public UserCredential? UserCredential { get; set; } public UserCredential? UserCredential { get; set; }
public UserProfile? UserProfile { get; set; } public UserProfile? UserProfile { get; set; }
// GET /api/user/authenticated serializes this entity directly, and the avatar graph carries the
// storage key and uploader of the underlying Photo. Clients read the avatar from
// PublicUserProfileDto.AvatarUrl, which is a signed URL, so keep this one out of the response.
[JsonIgnore]
public UserAvatar? UserAvatar { get; set; }
} }

View File

@@ -13,14 +13,11 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
{ {
private readonly string _bucket; private readonly string _bucket;
private readonly IAmazonS3 _client; private readonly IAmazonS3 _client;
private readonly IAmazonS3 _presignClient;
private readonly Protocol _presignProtocol;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage /// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage
/// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>, /// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>,
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>, and the optional /// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>) from <paramref name="configuration" />.
/// <c>SEAWEEDFS_PUBLIC_URL</c>) from <paramref name="configuration" />.
/// </summary> /// </summary>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// Thrown when any of the required configuration values is not set. /// Thrown when any of the required configuration values is not set.
@@ -48,31 +45,6 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true }; AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true };
_client = new AmazonS3Client(accessKeyId, secretAccessKey, config); _client = new AmazonS3Client(accessKeyId, secretAccessKey, config);
// A presigned URL is signed over its host, so one signed against the container-internal
// endpoint is only usable from inside the network. Sign downloads against the public
// endpoint instead, so a browser can fetch the object.
string? configuredPublicUrl = configuration[ConfigurationKeys.SeaweedFsPublicUrl];
string publicUrl = string.IsNullOrWhiteSpace(configuredPublicUrl)
? serviceUrl
: configuredPublicUrl;
_presignClient =
publicUrl == serviceUrl
? _client
: new AmazonS3Client(
accessKeyId,
secretAccessKey,
new AmazonS3Config { ServiceURL = publicUrl, ForcePathStyle = true }
);
// GetPreSignedUrlRequest.Protocol defaults to HTTPS and ignores the ServiceURL scheme, so
// take the scheme from the endpoint the URL is signed against.
_presignProtocol =
Uri.TryCreate(publicUrl, UriKind.Absolute, out Uri? publicUri)
&& publicUri.Scheme == Uri.UriSchemeHttp
? Protocol.HTTP
: Protocol.HTTPS;
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -110,18 +82,14 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
BucketName = _bucket, BucketName = _bucket,
Key = key, Key = key,
Expires = DateTime.UtcNow.Add(expiresIn), Expires = DateTime.UtcNow.Add(expiresIn),
Protocol = _presignProtocol,
}; };
return _presignClient.GetPreSignedURL(request); return _client.GetPreSignedURL(request);
} }
/// <inheritdoc/> /// <inheritdoc/>
public void Dispose() public void Dispose()
{ {
if (!ReferenceEquals(_presignClient, _client))
_presignClient.Dispose();
_client.Dispose(); _client.Dispose();
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }

View File

@@ -1,5 +1,4 @@
using Domain.Entities; using Domain.Entities;
using Features.ImageUploads.Services;
using Features.Users.Dtos; using Features.Users.Dtos;
using Features.Users.Queries.GetAllUsers; using Features.Users.Queries.GetAllUsers;
using Features.Users.Repository; using Features.Users.Repository;
@@ -10,36 +9,27 @@ namespace Features.Users.Tests.Queries;
public class GetAllUsersHandlerTests public class GetAllUsersHandlerTests
{ {
private readonly Mock<IUserListRepository> _repoMock = new();
private readonly Mock<IFileStorageProvider> _storageMock = new();
private readonly GetAllUsersHandler _handler;
public GetAllUsersHandlerTests()
{
_storageMock
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
_handler = new GetAllUsersHandler(_repoMock.Object, _storageMock.Object);
}
[Fact] [Fact]
public async Task Handle_PassesLimitAndOffset_ToRepository() public async Task Handle_PassesLimitAndOffset_ToRepository()
{ {
_repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>()); Mock<IUserListRepository> repoMock = new();
GetAllUsersHandler handler = new(repoMock.Object);
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
IEnumerable<PublicUserProfileDto> result = await _handler.Handle( IEnumerable<PublicUserProfileDto> result = await handler.Handle(
new GetAllUsersQuery(10, 5), new GetAllUsersQuery(10, 5),
CancellationToken.None CancellationToken.None
); );
result.Should().BeEmpty(); result.Should().BeEmpty();
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once); repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
} }
[Fact] [Fact]
public async Task Handle_MapsUsersToPublicProfileDtos_AndExcludesPrivateFields() public async Task Handle_MapsUsersToPublicProfileDtos_AndExcludesPrivateFields()
{ {
Mock<IUserListRepository> repoMock = new();
GetAllUsersHandler handler = new(repoMock.Object);
UserAccount user = new() UserAccount user = new()
{ {
UserAccountId = Guid.NewGuid(), UserAccountId = Guid.NewGuid(),
@@ -48,60 +38,13 @@ public class GetAllUsersHandlerTests
LastName = "One", LastName = "One",
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
}; };
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]); repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]);
IEnumerable<PublicUserProfileDto> result = await _handler.Handle( IEnumerable<PublicUserProfileDto> result = await handler.Handle(
new GetAllUsersQuery(null, null), new GetAllUsersQuery(null, null),
CancellationToken.None CancellationToken.None
); );
result result.Should().ContainSingle().Which.Should().BeEquivalentTo(user.ToPublicProfileDto());
.Should()
.ContainSingle()
.Which.Should()
.BeEquivalentTo(user.ToPublicProfileDto(_storageMock.Object));
}
[Fact]
public async Task Handle_MapsEachUsersOwnAvatarUrl()
{
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
UserAccount withAvatar = new()
{
UserAccountId = Guid.NewGuid(),
Username = "has-avatar",
FirstName = "Has",
LastName = "Avatar",
CreatedAt = DateTime.UtcNow,
UserProfile = new UserProfile { Biography = "Drinks lagers." },
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
};
UserAccount withoutAvatar = new()
{
UserAccountId = Guid.NewGuid(),
Username = "no-avatar",
FirstName = "No",
LastName = "Avatar",
CreatedAt = DateTime.UtcNow,
};
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([withAvatar, withoutAvatar]);
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
new GetAllUsersQuery(null, null),
CancellationToken.None
);
result
.Select(dto => dto.AvatarUrl)
.Should()
.Equal($"https://storage.test/{avatarKey}?signed", string.Empty);
_storageMock.Verify(
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
Times.Once
);
_storageMock.Verify(
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
Times.Once
);
} }
} }

View File

@@ -1,6 +1,5 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.ImageUploads.Services;
using Features.Users.Dtos; using Features.Users.Dtos;
using Features.Users.Queries.GetPublicUserProfileById; using Features.Users.Queries.GetPublicUserProfileById;
using Features.Users.Repository; using Features.Users.Repository;
@@ -13,21 +12,15 @@ public class GetPublicUserProfileByIdHandlerTests
{ {
private readonly GetPublicUserProfileByIdHandler _handler; private readonly GetPublicUserProfileByIdHandler _handler;
private readonly Mock<IUserListRepository> _repoMock = new(); private readonly Mock<IUserListRepository> _repoMock = new();
private readonly Mock<IFileStorageProvider> _storageMock = new();
public GetPublicUserProfileByIdHandlerTests() public GetPublicUserProfileByIdHandlerTests()
{ {
_storageMock _handler = new GetPublicUserProfileByIdHandler(_repoMock.Object);
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object, _storageMock.Object);
} }
[Fact] [Fact]
public async Task Handle_ReturnsPublicProfile_WhenFound() public async Task Handle_ReturnsPublicProfile_WhenFound()
{ {
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
UserAccount user = new() UserAccount user = new()
{ {
UserAccountId = Guid.NewGuid(), UserAccountId = Guid.NewGuid(),
@@ -38,7 +31,6 @@ public class GetPublicUserProfileByIdHandlerTests
DateOfBirth = new DateTime(1990, 1, 1), DateOfBirth = new DateTime(1990, 1, 1),
CreatedAt = new DateTime(2024, 1, 1), CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." }, UserProfile = new UserProfile { Biography = "Drinks lagers." },
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
}; };
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user); _repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
@@ -56,48 +48,13 @@ public class GetPublicUserProfileByIdHandlerTests
user.FirstName, user.FirstName,
user.LastName, user.LastName,
"Drinks lagers.", "Drinks lagers.",
$"https://storage.test/{avatarKey}?signed",
user.CreatedAt user.CreatedAt
) )
); );
} }
/// <summary>
/// <c>Photo.Hyperlink</c> holds an object key, so the DTO has to carry a signed URL rather than
/// the stored value: a bare key resolves against the calling page's own origin.
/// </summary>
[Fact] [Fact]
public async Task Handle_SignsTheAvatarKey_RatherThanReturningIt() public async Task Handle_ReturnsEmptyBiography_WhenProfileMissing()
{
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
CreatedAt = new DateTime(2024, 1, 1),
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.AvatarUrl.Should().NotBe(avatarKey);
Uri.TryCreate(result.AvatarUrl, UriKind.Absolute, out Uri? uri).Should().BeTrue();
uri!.Scheme.Should().BeOneOf("http", "https");
uri.AbsolutePath.Should().EndWith(avatarKey);
_storageMock.Verify(
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
Times.Once
);
}
[Fact]
public async Task Handle_ReturnsEmptyBiographyAndAvatarUrl_WhenProfileMissing()
{ {
UserAccount user = new() UserAccount user = new()
{ {
@@ -115,57 +72,6 @@ public class GetPublicUserProfileByIdHandlerTests
); );
result.Biography.Should().BeEmpty(); result.Biography.Should().BeEmpty();
result.AvatarUrl.Should().BeEmpty();
}
[Fact]
public async Task Handle_ReturnsEmptyAvatarUrl_WhenProfileHasNoAvatar()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." },
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.Biography.Should().Be("Drinks lagers.");
result.AvatarUrl.Should().BeEmpty();
}
[Fact]
public async Task Handle_ReturnsEmptyAvatarUrl_WhenAvatarHasNoPhoto()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." },
UserAvatar = new UserAvatar(),
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.AvatarUrl.Should().BeEmpty();
_storageMock.Verify(
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
Times.Never
);
} }
[Fact] [Fact]

View File

@@ -1,39 +1,19 @@
using Domain.Entities; using Domain.Entities;
using Features.ImageUploads.Services;
namespace Features.Users.Dtos; namespace Features.Users.Dtos;
/// <summary>Converts user domain entities into API response models.</summary> /// <summary>Converts user domain entities into API response models.</summary>
public static class UserDtoMapper public static class UserDtoMapper
{ {
/// <summary>
/// How long a presigned avatar URL stays valid. Long enough to outlive the page that embeds it,
/// short enough that a leaked URL stops working.
/// </summary>
public static readonly TimeSpan AvatarUrlLifetime = TimeSpan.FromHours(1);
/// <summary>Converts a user account into its public-profile representation.</summary> /// <summary>Converts a user account into its public-profile representation.</summary>
/// <param name="user">The account to convert.</param> public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user)
/// <param name="fileStorageProvider">
/// Signs the download URL for the account's avatar. <c>Photo.Hyperlink</c> holds an object key
/// rather than a URL, so it has to be signed before a client can fetch it.
/// </param>
public static PublicUserProfileDto ToPublicProfileDto(
this UserAccount user,
IFileStorageProvider fileStorageProvider
)
{ {
string? avatarKey = user.UserAvatar?.Photo?.Hyperlink;
return new PublicUserProfileDto( return new PublicUserProfileDto(
UserAccountId : user.UserAccountId, UserAccountId : user.UserAccountId,
Username : user.Username, Username : user.Username,
FirstName : user.FirstName, FirstName : user.FirstName,
LastName : user.LastName, LastName : user.LastName,
Biography : user.UserProfile?.Biography ?? string.Empty, Biography : user.UserProfile?.Biography ?? string.Empty,
AvatarUrl : string.IsNullOrWhiteSpace(avatarKey)
? string.Empty
: fileStorageProvider.GetPresignedUrl(avatarKey, AvatarUrlLifetime),
CreatedAt : user.CreatedAt CreatedAt : user.CreatedAt
); );
} }

View File

@@ -7,6 +7,5 @@ public record PublicUserProfileDto(
string FirstName, string FirstName,
string LastName, string LastName,
string Biography, string Biography,
string AvatarUrl,
DateTime CreatedAt DateTime CreatedAt
); );

View File

@@ -1,15 +1,12 @@
using Domain.Entities; using Domain.Entities;
using Features.ImageUploads.Services;
using Features.Users.Dtos; using Features.Users.Dtos;
using Features.Users.Repository; using Features.Users.Repository;
using MediatR; using MediatR;
namespace Features.Users.Queries.GetAllUsers; namespace Features.Users.Queries.GetAllUsers;
public class GetAllUsersHandler( public class GetAllUsersHandler(IUserListRepository repository)
IUserListRepository repository, : IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
IFileStorageProvider fileStorageProvider
) : IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
{ {
public async Task<IEnumerable<PublicUserProfileDto>> Handle( public async Task<IEnumerable<PublicUserProfileDto>> Handle(
GetAllUsersQuery request, GetAllUsersQuery request,
@@ -17,6 +14,6 @@ public class GetAllUsersHandler(
) )
{ {
IEnumerable<UserAccount> users = await repository.GetAllAsync(request.Limit, request.Offset); IEnumerable<UserAccount> users = await repository.GetAllAsync(request.Limit, request.Offset);
return users.Select(user => user.ToPublicProfileDto(fileStorageProvider)); return users.Select(user => user.ToPublicProfileDto());
} }
} }

View File

@@ -1,16 +1,13 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.ImageUploads.Services;
using Features.Users.Dtos; using Features.Users.Dtos;
using Features.Users.Repository; using Features.Users.Repository;
using MediatR; using MediatR;
namespace Features.Users.Queries.GetPublicUserProfileById; namespace Features.Users.Queries.GetPublicUserProfileById;
public class GetPublicUserProfileByIdHandler( public class GetPublicUserProfileByIdHandler(IUserListRepository repository)
IUserListRepository repository, : IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
IFileStorageProvider fileStorageProvider
) : IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
{ {
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception> /// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
public async Task<PublicUserProfileDto> Handle( public async Task<PublicUserProfileDto> Handle(
@@ -21,6 +18,6 @@ public class GetPublicUserProfileByIdHandler(
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId); UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
if (user is null) if (user is null)
throw new NotFoundException($"User with ID {request.UserAccountId} not found"); throw new NotFoundException($"User with ID {request.UserAccountId} not found");
return user.ToPublicProfileDto(fileStorageProvider); return user.ToPublicProfileDto();
} }
} }

View File

@@ -15,10 +15,7 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
/// <inheritdoc /> /// <inheritdoc />
/// <remarks> /// <remarks>
/// Left-joins <c>Social.UserProfile</c>, so <see cref="UserAccount.UserProfile" /> is /// Left-joins <c>Social.UserProfile</c>, so <see cref="UserAccount.UserProfile" /> is
/// <c>null</c> for an account whose profile has yet to be created, and left-joins the /// <c>null</c> for an account whose profile has yet to be created.
/// account's active <c>Social.UserAvatar</c> (<c>ValidTo IS NULL</c>) with its
/// <c>Media.Photo</c>, so <see cref="UserAccount.UserAvatar" /> is <c>null</c> for an account
/// with no avatar.
/// </remarks> /// </remarks>
public async Task<UserAccount?> GetByIdAsync(Guid id) public async Task<UserAccount?> GetByIdAsync(Guid id)
{ {
@@ -26,89 +23,51 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
IEnumerable<UserAccount> results = await connection.QueryAsync< IEnumerable<UserAccount> results = await connection.QueryAsync<
UserAccount, UserAccount,
UserProfile, UserProfile,
Photo,
UserAvatar,
UserAccount UserAccount
>( >(
""" """
SELECT SELECT
ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName, ua.UserAccountID,
ua.Email, ua.CreatedAt, ua.UpdatedAt, ua.DateOfBirth, ua.Username,
ua.RowVersion, up.UserProfileID, up.UserAccountID, ua.FirstName, ua.LastName, ua.Email, ua.CreatedAt, ua.UpdatedAt,
up.Biography, up.RowVersion, p.PhotoID, p.UploadedByID, ua.DateOfBirth, ua.RowVersion,
p.Hyperlink, p.UploadedAt, p.RowVersion, av.UserAvatarID, up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion
av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
av.RowVersion
FROM Auth.UserAccount ua FROM Auth.UserAccount ua
LEFT JOIN Social.UserProfile up LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
ON ua.UserAccountID = up.UserAccountID
LEFT JOIN Social.UserAvatar av
ON up.UserProfileID = av.UserProfileID
AND av.ValidTo IS NULL
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
WHERE ua.UserAccountID = @UserAccountId WHERE ua.UserAccountID = @UserAccountId
""", """,
MapUserRow, MapUserRow,
new { UserAccountId = id }, new { UserAccountId = id },
splitOn: AvatarSplitOn splitOn: "UserProfileID"
); );
return results.SingleOrDefault(); return results.SingleOrDefault();
} }
private static UserAccount MapUserRow(UserAccount user, UserProfile? profile)
{
user.UserProfile = profile;
return user;
}
/// <inheritdoc /> /// <inheritdoc />
/// <remarks> /// <remarks>
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending. /// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset". /// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
/// Joins the same profile and active-avatar rows as <see cref="GetByIdAsync" />, both of which are /// Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is consumed only as public
/// part of a public profile. Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is /// profiles, so those fields are left at their default rather than read from the database.
/// consumed only as public profiles, so those fields are left at their default rather than read
/// from the database.
/// </remarks> /// </remarks>
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset) public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync< return await connection.QueryAsync<UserAccount>(
UserAccount,
UserProfile,
Photo,
UserAvatar,
UserAccount
>(
""" """
SELECT SELECT UserAccountID, Username, FirstName, LastName, CreatedAt, UpdatedAt, RowVersion
ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName, FROM Auth.UserAccount
ua.CreatedAt, ua.UpdatedAt, ua.RowVersion, ORDER BY CreatedAt DESC
up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion,
p.PhotoID, p.UploadedByID, p.Hyperlink, p.UploadedAt, p.RowVersion,
av.UserAvatarID, av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
av.RowVersion
FROM Auth.UserAccount ua
LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
LEFT JOIN Social.UserAvatar av
ON up.UserProfileID = av.UserProfileID AND av.ValidTo IS NULL
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
ORDER BY ua.CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
""", """,
MapUserRow, new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue },
splitOn: AvatarSplitOn
); );
} }
private const string AvatarSplitOn = "UserProfileID,PhotoID,UserAvatarID";
private static UserAccount MapUserRow(
UserAccount user,
UserProfile? profile,
Photo? photo,
UserAvatar? avatar
)
{
user.UserProfile = profile;
avatar?.Photo = photo;
user.UserAvatar = avatar;
return user;
}
} }

View File

@@ -70,7 +70,6 @@ services:
DB_USER: "${DB_USER}" DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}" DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"

View File

@@ -72,7 +72,6 @@ services:
DB_USER: "${DB_USER}" DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}" DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
@@ -111,7 +110,6 @@ services:
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"

View File

@@ -84,7 +84,6 @@ services:
DB_USER: "${DB_USER}" DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}" DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"

View File

@@ -1,4 +1,12 @@
# One-shot seed job for the production stack, kept out of
# docker-compose.prod.yaml so that bringing production up, or restarting it,
# never reloads the catalogue. Run it deliberately, once the prod stack is
# healthy:
#
# docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml run --rm --build database.seed # docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml run --rm --build database.seed
#
# Its own project name keeps it from treating the prod stack's containers as
# orphans; it joins the prod network to reach sqlserver and seaweedfs.
name: biergarten-prod-seed name: biergarten-prod-seed
services: services:
@@ -19,7 +27,6 @@ services:
DB_USER: "${DB_USER}" DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}" DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"

View File

@@ -25,11 +25,36 @@ services:
networks: networks:
- prodnet - prodnet
database.migrations:
env_file: ".env.prod"
image: database.migrations
container_name: prod-env-database-migrations
depends_on:
sqlserver:
condition: service_healthy
build:
context: ./backend
dockerfile: Database/Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
restart: "no"
networks:
- prodnet
api.core: api.core:
env_file: ".env.prod" env_file: ".env.prod"
image: api.core image: api.core
container_name: prod-env-api-core container_name: prod-env-api-core
depends_on: depends_on:
database.migrations:
condition: service_completed_successfully
seaweedfs: seaweedfs:
condition: service_healthy condition: service_healthy
build: build:
@@ -38,6 +63,8 @@ services:
args: args:
BUILD_CONFIGURATION: Release BUILD_CONFIGURATION: Release
APP_UID: 1000 APP_UID: 1000
# No published ports: only the frontend service is reachable from outside
# the Docker network in production. api.core stays internal to prodnet.
environment: environment:
ASPNETCORE_ENVIRONMENT: "Production" ASPNETCORE_ENVIRONMENT: "Production"
ASPNETCORE_URLS: "http://0.0.0.0:8080" ASPNETCORE_URLS: "http://0.0.0.0:8080"
@@ -51,11 +78,12 @@ services:
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
healthcheck: healthcheck:
# The .NET runtime image carries bash but no HTTP client, so the probe speaks
# HTTP to /health over bash's /dev/tcp rather than adding curl to the image.
test: test:
[ [
"CMD", "CMD",
@@ -70,11 +98,15 @@ services:
restart: unless-stopped restart: unless-stopped
networks: networks:
- prodnet - prodnet
volumes:
- nuget-cache-prod:/root/.nuget/packages
seaweedfs: seaweedfs:
env_file: ".env.prod" env_file: ".env.prod"
image: chrislusf/seaweedfs image: chrislusf/seaweedfs
container_name: prod-env-seaweedfs container_name: prod-env-seaweedfs
# No published ports: the S3 API, filer, master and admin UIs are reachable
# only from other containers on prodnet.
environment: environment:
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
@@ -82,6 +114,8 @@ services:
volumes: volumes:
- seaweedfsdata-prod:/data - seaweedfsdata-prod:/data
healthcheck: healthcheck:
# Probes the S3 gateway, the one component api.core talks to; it comes up
# last, after the master, volume and filer servers are serving.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8333/status"] test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8333/status"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
@@ -109,6 +143,8 @@ services:
API_BASE_URL: "http://api.core:8080" API_BASE_URL: "http://api.core:8080"
SESSION_SECRET: "${SESSION_SECRET}" SESSION_SECRET: "${SESSION_SECRET}"
healthcheck: healthcheck:
# /healthz answers from the Express server alone, so the frontend stays
# healthy while api.core is still starting and serves its own error states.
test: ["CMD", "wget", "--quiet", "--spider", "--tries=1", "http://127.0.0.1:5656/healthz"] test: ["CMD", "wget", "--quiet", "--spider", "--tries=1", "http://127.0.0.1:5656/healthz"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
@@ -119,12 +155,19 @@ services:
- prodnet - prodnet
volumes: volumes:
# Declared external so `docker compose down -v` leaves the data alone: Compose
# removes only the volumes it owns. Create them once with `docker volume
# create` before the first `up`.
biergartendata-prod: biergartendata-prod:
external: true external: true
seaweedfsdata-prod: seaweedfsdata-prod:
external: true external: true
nuget-cache-prod:
driver: local
networks: networks:
prodnet: prodnet:
# Fixed name so docker-compose.prod.seed.yaml, which runs as its own Compose
# project, can attach to this network as an external one.
name: biergarten-prodnet name: biergarten-prodnet
driver: bridge driver: bridge

View File

@@ -70,7 +70,6 @@ services:
DB_USER: "${DB_USER}" DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}" DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"

View File

@@ -212,9 +212,7 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Settings className="size-7" aria-hidden="true" /> <Settings className="size-7" aria-hidden="true" />
<div> <div>
<h1 className="font-serif text-5xl font-bold leading-tight"> <h1 className="font-serif text-5xl font-bold leading-tight">Account Settings</h1>
Account Settings
</h1>
<p className="text-base-content/70"> <p className="text-base-content/70">
Manage the account details for{' '} Manage the account details for{' '}
<span className="font-mono">{username}</span> <span className="font-mono">{username}</span>

View File

@@ -33,9 +33,7 @@ const BreweryHeaderCard = ({
Est. {foundedYear} Est. {foundedYear}
</span> </span>
</div> </div>
<h1 className="font-serif text-5xl font-bold leading-tight m-0"> <h1 className="font-serif text-5xl font-bold leading-tight m-0">{brewery.breweryName}</h1>
{brewery.breweryName}
</h1>
{brewery.location && ( {brewery.location && (
<p className="text-sm text-base-content/70 flex items-center gap-1 m-0"> <p className="text-sm text-base-content/70 flex items-center gap-1 m-0">
<MapPin className="size-4" aria-hidden="true" /> <MapPin className="size-4" aria-hidden="true" />

View File

@@ -79,9 +79,7 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
> >
&larr; Back to Breweries &larr; Back to Breweries
</Link> </Link>
<h1 className="font-serif text-5xl font-bold leading-tight mb-4"> <h1 className="font-serif text-5xl font-bold leading-tight mb-4">Full brewery directory</h1>
Full brewery directory
</h1>
<p className="text-base-content/70 mb-6"> <p className="text-base-content/70 mb-6">
All of our partner breweries, newest first. All of our partner breweries, newest first.
</p> </p>

View File

@@ -6,21 +6,21 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
## Status legend ## Status legend
- ✅ **Available**: real data, wired up. - ✅ **Available**: real data, wired up.
- ⚠️ **Partial**: reads real data, but a related gap remains (see the Gap column). - ⚠️ **Partial**: schema/repository exists but no public read endpoint.
- ❌ **Missing**: no backend support; page shows filler or local-only state. - ❌ **Missing**: no backend support; page shows filler or local-only state.
## Header & sidebar ## Header & sidebar
| Field | Status | Source | Gap | | Field | Status | Source | Gap |
| ---------------------------------------- | ------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------- | ------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | | | Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | |
| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | | | `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | |
| Bio | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads `Social.UserProfile.Biography` via the left join in `UserListRepository.GetByIdAsync`; an account with no profile row yet returns an empty string. | | Bio | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads `Social.UserProfile.Biography` via the left join in `UserListRepository.GetByIdAsync`; an account with no profile row yet returns an empty string. |
| Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. | | Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. |
| Avatar photo | ⚠️ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads the current `Social.UserAvatar` photo via the left joins in `UserListRepository.GetByIdAsync` and signs it into `avatarUrl`; an account with no avatar returns an empty string and `ProfileAvatar` shows initials. `UploadAvatarCommand` has no HTTP route yet, so only accounts created by `Database.Seed` have one — see (4) below. | | Avatar photo | ⚠️ | Initials placeholder (`avatar-placeholder`) | `Social.UserAvatar` + `UploadAvatarHandler` exist but there's no GET endpoint to read a user's stored avatar back. |
| Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. | | Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. |
| Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). | | Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). |
| Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. | | Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. |
## Follow action ## Follow action
@@ -36,7 +36,7 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
| Commenter name links (brewery detail comments) | ⚠️ | `CommentItem` links only when `comment.userAccountId` is set | `FILLER_COMMENTS` has no backing accounts, so those rows render as plain text; a comment added during the session by a signed-in user does carry a real GUID and links correctly. | | Commenter name links (brewery detail comments) | ⚠️ | `CommentItem` links only when `comment.userAccountId` is set | `FILLER_COMMENTS` has no backing accounts, so those rows render as plain text; a comment added during the session by a signed-in user does carry a real GUID and links correctly. |
| Following-list person links (profile page) | ⚠️ | `FollowingTab` links only when `f.userAccountId` is set | Same gap as above — `FILLER_FOLLOWING`'s two person rows have no backing accounts, so they render as plain text until real follow data exists. | | Following-list person links (profile page) | ⚠️ | `FollowingTab` links only when `f.userAccountId` is set | Same gap as above — `FILLER_FOLLOWING`'s two person rows have no backing accounts, so they render as plain text until real follow data exists. |
Both components already support linking (see `CommentItem.stories.tsx`'s `WithLinkedAccount` story); what's missing is real account IDs on the filler rows, which can't be faked without recreating the 404 this was fixed for. This resolves itself once (3) below closes and comments/follows are loader-backed. Both components already support linking (see `CommentItem.stories.tsx`'s `WithLinkedAccount` story); what's missing is real account IDs on the filler rows, which can't be faked without recreating the 404 this was fixed for. This resolves itself once (4) below closes and comments/follows are loader-backed.
## Activity / Following / Liked tabs ## Activity / Following / Liked tabs
@@ -49,8 +49,8 @@ Both components already support linking (see `CommentItem.stories.tsx`'s `WithLi
## Summary of backend work ## Summary of backend work
1. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept. 1. A public query + controller route to read a user's stored `Social.UserAvatar`.
2. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts. 2. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept (mirrors the avatar gap).
3. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first. 3. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts.
4. An avatar-upload endpoint: `UploadAvatarCommand`/`UploadAvatarHandler` exist and `Database.Seed` sends them, but no controller action does, so a signed-in user has no way to set their own avatar. Needs a `multipart/form-data` route on the authenticated caller's own account (`IFormFile`, same validation the seeder's uploads pass through). 4. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first.
5. Once (2) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`). Swapping in real (possibly empty) arrays is also what makes the Activity/Following `EmptyState`s reachable from the actual route — today they're only exercised by the `Empty` Storybook stories, since the filler arrays are never empty. 5. Once (3) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`). Swapping in real (possibly empty) arrays is also what makes the Activity/Following `EmptyState`s reachable from the actual route — today they're only exercised by the `Empty` Storybook stories, since the filler arrays are never empty.

View File

@@ -1,51 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import ProfileAvatar from './ProfileAvatar';
const profileAvatarDescription = `Avatar shown at the top of the public profile sidebar. It renders the photo from the public profile payload's \`avatarUrl\`, and shows the first two letters of the display name when that field is empty or the image fails to load.`;
const meta = {
title: 'Users/ProfileAvatar',
component: ProfileAvatar,
tags: ['autodocs'],
args: {
displayName: 'Maren Iverson',
avatarUrl: 'https://picsum.photos/id/1027/240/240',
},
parameters: {
layout: 'centered',
docs: {
description: {
component: profileAvatarDescription,
},
},
},
} satisfies Meta<typeof ProfileAvatar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const WithPhoto: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const image = canvas.getByRole('img', { name: "Maren Iverson's avatar" });
await expect(image).toHaveAttribute('src', 'https://picsum.photos/id/1027/240/240');
},
};
export const WithoutPhoto: Story = {
args: { avatarUrl: '' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('MA')).toBeInTheDocument();
await expect(canvas.queryByRole('img')).not.toBeInTheDocument();
},
};
export const BrokenPhotoUrl: Story = {
args: { avatarUrl: 'https://example.invalid/missing-avatar.jpg' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText('MA')).toBeInTheDocument();
},
};

View File

@@ -1,42 +0,0 @@
import { useState } from 'react';
interface ProfileAvatarProps {
avatarUrl: string;
displayName: string;
}
/**
* Renders the user's current avatar photo, falling back to their initials when the public
* profile payload carries an empty `avatarUrl` or the hosted image fails to load.
*/
const ProfileAvatar = ({ avatarUrl, displayName }: ProfileAvatarProps) => {
const [didImageFail, setDidImageFail] = useState(false);
const showPhoto = avatarUrl.length > 0 && !didImageFail;
return (
<div
className={`avatar w-[120px] h-[120px] rounded-full border-4 border-base-200 shadow-md ${
showPhoto ? '' : 'avatar-placeholder'
}`}
>
{showPhoto ? (
<div className="w-full h-full rounded-full overflow-hidden">
<img
src={avatarUrl}
alt={`${displayName}'s avatar`}
className="w-full h-full object-cover"
onError={() => setDidImageFail(true)}
/>
</div>
) : (
<div className="bg-secondary text-secondary-content w-full h-full rounded-full flex items-center justify-center">
<span className="text-2xl font-bold">
{displayName.slice(0, 2).toUpperCase()}
</span>
</div>
)}
</div>
);
};
export default ProfileAvatar;

View File

@@ -3,7 +3,6 @@ import { data } from 'react-router';
import RouteErrorState from '../../../components/ui/error/RouteErrorState'; import RouteErrorState from '../../../components/ui/error/RouteErrorState';
import { getOptionalAuth } from '../../auth/auth.server'; import { getOptionalAuth } from '../../auth/auth.server';
import { getPublicUserProfile, type PublicUserProfile } from '../users.server'; import { getPublicUserProfile, type PublicUserProfile } from '../users.server';
import ProfileAvatar from '../components/profile/ProfileAvatar';
import ProfileSidebarCard from '../components/profile/ProfileSidebarCard'; import ProfileSidebarCard from '../components/profile/ProfileSidebarCard';
import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs'; import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs';
import ActivityTab from '../components/profile/tabs/ActivityTab'; import ActivityTab from '../components/profile/tabs/ActivityTab';
@@ -81,7 +80,15 @@ const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12"> <div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">
<div className="lg:sticky lg:top-8 flex flex-col gap-5 px-2"> <div className="lg:sticky lg:top-8 flex flex-col gap-5 px-2">
<ProfileAvatar avatarUrl={profile.avatarUrl} displayName={displayName} /> <div className="w-[120px] h-[120px] rounded-full border-4 border-base-200 shadow-md overflow-hidden">
<div className="avatar avatar-placeholder w-full h-full">
<div className="bg-secondary text-secondary-content w-full h-full flex items-center justify-center">
<span className="text-2xl font-bold">
{displayName.slice(0, 2).toUpperCase()}
</span>
</div>
</div>
</div>
<ProfileSidebarCard <ProfileSidebarCard
displayName={displayName} displayName={displayName}

View File

@@ -8,7 +8,6 @@ export interface PublicUserProfile {
firstName: string; firstName: string;
lastName: string; lastName: string;
biography: string; biography: string;
avatarUrl: string;
createdAt: string; createdAt: string;
} }
@@ -50,7 +49,6 @@ export const getPublicUserProfile = async (
firstName: string; firstName: string;
lastName: string; lastName: string;
biography: string; biography: string;
avatarUrl: string;
createdAt: string; createdAt: string;
} = await res.json(); } = await res.json();
@@ -60,7 +58,6 @@ export const getPublicUserProfile = async (
firstName: account.firstName, firstName: account.firstName,
lastName: account.lastName, lastName: account.lastName,
biography: account.biography, biography: account.biography,
avatarUrl: account.avatarUrl,
createdAt: account.createdAt, createdAt: account.createdAt,
}; };
}; };

View File

@@ -1,5 +1,5 @@
// see USER_PROFILE_HANDOFF.md — filler data pending backend support for // see USER_PROFILE_HANDOFF.md — filler data pending backend support for
// location, cover photos, follower/following stats, and activity feeds. // bio, location, avatar/cover photos, follower/following stats, and activity feeds.
export interface FillerProfileMeta { export interface FillerProfileMeta {
bio: string; bio: string;

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,19 @@
{
"name": "The Biergarten App",
"short_name": "Biergarten",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}