16 KiB
Media streamlining debug fix plan
Goal: make media rendering (avatars, team logos, league logos) deterministic, debuggable, and boring. Remove misleading stubs from runtime, converge on one URL shape (/media/...) end-to-end, and add observability so broken images can be diagnosed in minutes.
Non-goals:
- No CDN rollout (we still design for it).
- No “AI generation” pipeline. Keep existing deterministic SVG generation in
MediaGenerationService.
1) Current state (facts from code)
Backend (API)
-
The canonical HTTP routes exist in
MediaController:- Team logo:
GET /media/teams/:teamId/logo(SVG)getTeamLogo() - League logo:
GET /media/leagues/:leagueId/logo(SVG)getLeagueLogo() - Driver avatar:
GET /media/avatar/:driverId(SVG)getDriverAvatar() - Default:
GET /media/default/:variant(PNG placeholder)getDefaultMedia()
- Team logo:
-
Seeding sets
logoReffor teams/leagues to “generated” references: -
Presenters resolve
MediaReference→ URL string via aMediaResolverPort:- Teams list:
AllTeamsPresenter.present()resolves viathis.mediaResolver.resolve()
- Teams list:
Frontend (Website)
-
The landing page cards render with Next
Image:- Team card:
TeamCarduses<Image src={logoUrl}>
- Team card:
-
Some UI code uses an internal URL builder that does not match the API’s route shapes:
getMediaUrl()builds/media/generated/team-logo/:idetc.- Example usage:
TeamLadderRowusesgetMediaUrl('team-logo', teamId)
-
Next.js image config currently allows localhost and allows SVG:
next.config.mjsincludesremotePatternsforlocalhost:3001anddangerouslyAllowSVG: true.
2) Suspected root causes (ranked)
A. URL shape mismatch in Website fallback builder
The Website builder getMediaUrl() generates paths like:
/media/generated/team-logo/:id
But the API serves:
/media/teams/:id/logoor/media/generated/team/:id(generic endpoint)
Result: 404s for any page that uses getMediaUrl() instead of logoUrl returned by the API.
B. Runtime accidentally uses the in-memory resolver (misleading)
In API Team DI, the runtime media resolver is currently the stub InMemoryMediaResolverAdapter via TeamProviders.
That adapter is explicitly described as “fake URLs” and has URL shapes that don’t match the API controller, e.g. system-default returns ${base}/default/${ref.variant} in InMemoryMediaResolverAdapter.resolve().
Even if team logos are “generated” and map to /media/teams/:id/logo, this is an architectural footgun:
- It makes it easy for other entity presenters (drivers/leagues/etc.) to emit non-existent URLs.
- It undermines confidence when debugging.
C. Next.js Image error symptoms
You reported: Next.js Image errors about remote host not configured and or SVG blocked.
Given next.config.mjs appears to allow localhost:3001 and enables SVG, this suggests at least one of:
- The actual
srchost differs (e.g.127.0.0.1,api:3000, or another hostname). - The
srcis not a valid URL string at runtime (empty string, malformed). - A stale container is running with older config.
The plan below makes src always same-origin to the Website (relative /media/...), eliminating this entire class of errors.
3) Target architecture (strict, minimal, easy-to-reason)
3.1 Invariants (rules)
- Canonical media URLs are always paths starting with
/media/. - API DTO fields like
team.logoUrlare either:null, or- a path
/media/...(never absolute URLs, never empty string).
- The Website renders media using only:
- DTO-provided
/media/...URLs, or - a single shared Website builder that produces
/media/...URLs matching the API routes.
- DTO-provided
- The Website never needs to know
http://localhost:3001. - All runtime resolution uses exactly one resolver implementation (no stubs).
3.2 One canonical path schema
Canonical HTTP paths (served by API, fetched by browser via Website proxy rewrite):
- Team logo SVG:
/media/teams/{teamId}/logo - League logo SVG:
/media/leagues/{leagueId}/logo - Driver avatar SVG:
/media/avatar/{driverId} - Defaults (PNG):
/media/default/{variant} - Uploaded:
/media/uploaded/{mediaId}
/media/generated/:type/:id can remain, but should become an internal alias only (not returned by resolvers/presenters).
3.3 Single resolver for the whole API
- Runtime resolver:
MediaResolverAdapterusing the concrete sub-resolvers:
Resolver output must be path-only:
- For any
MediaReference,resolve()returns/media/...ornull. - No
baseUrlparameter is needed for DTOs.
Rationale: once URLs are path-only, the Website can proxy them and Next Image becomes deterministic.
3.4 Proper storage abstraction (core port) + adapter implementation
This is required to align with Clean Architecture rules in DATA_FLOW.md and avoid runtime stubs.
3.4.1 Core (ports + use-cases)
We already have a core port MediaStoragePort used by the media use-cases (upload/delete). The plan is to make it real and remove mock usage in runtime.
Target responsibilities:
-
Core Application port (interface):
MediaStoragePortuploadMedia(file, metadata) -> { success, url?, filename?, storageKey?, contentType? }deleteMedia(storageKey) -> void- (optional but recommended)
getReadStream(storageKey) -> streamorgetBytes(storageKey) -> Buffer
-
Core Domain entity (or value object):
Mediashould reference a storage identifier (e.g.storageKey) andcontentType.- The domain does not store absolute URLs.
- The resolver + controller decide how a
storageKeybecomes/media/uploaded/{id}.
3.4.2 Adapters (file storage)
Add a concrete adapter: FileSystemMediaStorageAdapter under adapters/.
Implementation rules:
- Store files under a single base directory (configured via env):
GRIDPILOT_MEDIA_STORAGE_DIR=/data/media(container path)
- Use deterministic, collision-resistant keys:
uploaded/{mediaId}/{originalFilename}oruploaded/{mediaId}(single-file per mediaId)
- Enforce content-type allowlist for images (at minimum
image/png,image/jpeg,image/svg+xml). - Never return public absolute URLs from the adapter. Return
storageKeyonly.
Docker alignment:
- Add a named volume mounted into
apicontainer for persisted dev media.
3.4.3 API serving route for uploaded media
The API endpoint GET /media/uploaded/:mediaId is currently a stub.
Target:
- Look up
MediabymediaIdinIMediaRepository. - Read bytes/stream from
MediaStoragePortusingstorageKey. - Set headers:
Content-Type: <stored contentType>Cache-Control: public, max-age=31536000, immutable(if content-addressed) ORmax-age=3600(if mutable)
- Return 404 if missing.
This makes “uploaded” a first-class, debuggable path in the same /media/... scheme.
4) End-to-end trace (pseudocode)
This is the required mental model for debugging.
4.1 Seed → DB
teamId = seedId(team-1)
team.logoRef = MediaReference.generated(team, teamId)
persist team.logoRef as JSON
4.2 API Use Case → Presenter → DTO
usecase GetAllTeamsUseCase
loads Team entities
returns { teams: [{ id, name, logoRef, logoUrl: null, ... }] }
presenter AllTeamsPresenter
for each team:
ref = MediaReference.fromJSON(team.logoRef)
dto.logoUrl = MediaResolver.resolve(ref)
=> /media/teams/{teamId}/logo
response JSON contains logoUrl string or null
4.3 Website → React component → img src
LandingService.getHomeDiscovery
calls GET {apiBaseUrl}/teams/all
creates TeamCardViewModel with dto.logoUrl
TeamCard
Image src = team.logoUrl
(src is relative /media/...)
4.4 Browser fetch → Website rewrite → API bytes
browser GET http://localhost:3000/media/teams/{id}/logo
Next rewrite proxies to http://api:3000/media/teams/{id}/logo
API returns image/svg+xml bytes
browser renders
5) Debuggability improvements (must-have)
5.1 Add a debug resolve endpoint in API
Add GET /media/debug/resolve in MediaController.
Input options:
- Query param
refas base64url JSON ofMediaReferenceProps. - Or explicit query params:
type,variant,avatarVariant,generationRequestId,mediaId.
Output JSON:
ref: the parsed ref (as JSON)refHash: same asMediaReference.hash()resolvedPath:/media/...or nullresolver: which branch handled it (default or generated or uploaded or none)notes: validation warnings (e.g. generationRequestId format)
This endpoint exists to debug resolvers without hitting entity APIs.
5.2 Structured logs
Add structured logs on each media request:
-
In
MediaController.getTeamLogo()and similar endpoints:- log: route, entityId, cache-control chosen
- log: svg length, deterministic seed used
-
In resolver:
- log:
refHash, resolved path, branch
- log:
5.3 Curl recipes (copy/paste)
Teams API returning logoUrl:
curl -sS http://localhost:3001/teams/all | jq '.teams[0] | {id, name, logoUrl}'
Team logo bytes:
TEAM_ID=$(curl -sS http://localhost:3001/teams/all | jq -r '.teams[0].id')
curl -i http://localhost:3001/media/teams/$TEAM_ID/logo | sed -n '1,20p'
Expected:
HTTP/1.1 200 OKcontent-type: image/svg+xml
Website proxy path (after rewrite is added):
curl -i http://localhost:3000/media/teams/$TEAM_ID/logo | sed -n '1,20p'
6) Concrete fixes (file-by-file)
6.1 Remove misleading runtime stubs
-
Stop using
InMemoryMediaResolverAdapterin API runtime providers.- Replace in
TeamProviders(and similar providers in drivers/leagues if present) with the realMediaResolverAdapter.
- Replace in
-
Ensure any “in-memory” resolver remains test-only:
- Keep it referenced only in unit tests, not in app modules/providers.
6.2 Make resolver output path-only
Update MediaResolverAdapter.resolve() and sub-resolvers to return /media/... paths:
DefaultMediaResolverAdapter.resolve():/media/default/...GeneratedMediaResolverAdapter.resolve():- team →
/media/teams/{id}/logo - league →
/media/leagues/{id}/logo - driver →
/media/avatar/{id}
- team →
UploadedMediaResolverAdapter.resolve():/media/uploaded/{mediaId}
Remove all “baseUrl” joining logic from resolvers.
6.3 Website must stop inventing wrong media URLs
-
Replace or delete
getMediaUrl().- Either remove it entirely, or redefine it to output canonical
/media/...paths.
- Either remove it entirely, or redefine it to output canonical
-
Update all call sites found via:
TeamLadderRowLeagueHeaderFriendPillapps/website/app/teams/[id]/page.tsxapps/website/app/profile/page.tsx
to use either:
- DTO-provided URLs, or
- a single canonical builder aligned with API routes.
6.4 Add Website rewrite for /media/*
Extend next.config.mjs rewrites() to also proxy /media/:path* to http://api:3000/media/:path* in dev.
This yields same-origin image URLs for the browser:
src=/media/...always.
6.5 Tests
- Unit tests for resolver mapping:
- Add tests around
GeneratedMediaResolverAdapter.resolve()to ensureteam-<id>→/media/teams/<id>/logo.
- API presenter contract test:
- Verify
logoUrlisnullor starts with/media/inAllTeamsPresenter.
- E2E Playwright image smoke:
- Add a test that loads the landing page, finds at least one team logo
<img>, and asserts the image request returns 200. - Use existing Playwright config files like
playwright.website.config.ts.
- Media upload + serve integration test:
- Upload an image via
POST /media/upload. - Verify response includes a
mediaIdand DTO uses/media/uploaded/{mediaId}(path-only rule). - Fetch
/media/uploaded/{mediaId}and assert status 200 + correctContent-Type.
7) Mermaid flow (new architecture)
flowchart TD
A[Bootstrap seed sets MediaReference] --> B[DB stores logoRef JSON]
B --> C[API use case returns logoRef]
C --> D[Presenter resolves ref to media path]
D --> E[DTO logoUrl is slash media path]
E --> F[Website renders Image src slash media path]
F --> G[Next rewrite proxies to API media route]
G --> H[MediaController returns SVG or PNG bytes]
8) TDD execution order (implementation guidance)
- Add unit tests for canonical resolver mapping (generated/system-default/uploaded).
- Change resolver implementations to return path-only and make tests pass.
- Update API providers to use real resolver everywhere (remove runtime usage of in-memory resolver).
- Add
/media/:path*rewrite in Website. - Replace Website
getMediaUrland all call sites. - Add API debug endpoint and structured logs.
- Replace mock
MediaStoragePortwith real filesystem adapter, wire env + volume. - Implement uploaded media serving endpoint (remove stub), add integration test.
- Add Playwright test verifying image loads.
9) Acceptance criteria
-
GET http://localhost:3001/teams/allreturnslogoUrlvalues that are eithernullor begin with/media/. -
GET http://localhost:3000/media/teams/{id}/logoreturns 200 withimage/svg+xml. -
No Next
Imageremote-host/SVG errors in dev for logos. -
Playwright test passes: at least one image request returns 200 on a real page.
-
Upload flow works end-to-end:
POST /media/uploadstores a file via filesystem adapter.GET /media/uploaded/{mediaId}returns the stored bytes with correct headers.