- src/main.server.ts: process.env['SSR_API_BASE_URL'] instead of
process.env.SSR_API_BASE_URL to satisfy TS4111 index signature rule.
- server.spec.ts: update corresponding test assertions to match new syntax.
Root cause: Dockerfile copied only browser/ to nginx; server/server.mjs
was built but never deployed or executed. Prerender baked DEFAULT_CONFIG
into static HTML with no API at build time, causing stale content on
every initial paint.
Changes:
- Dockerfile: serve stage uses node:22-alpine, installs nginx via apk,
copies both browser/ and server/ bundles from build stage
- docker-entrypoint.sh: starts Angular SSR Node process on port 4200,
waits for readiness, exports SSR_API_BASE_URL, then exec nginx
- nginx.conf.template: adds upstream ssr_backend block (127.0.0.1:4200),
proxies / to SSR backend, static assets served directly with 1y cache
- app.server.routes.ts: all routes switched from RenderMode.Prerender
to RenderMode.Server for zero stale content
- server.ts: removed dead prerender code (PRERENDERED_ROUTES,
isPrerenderedRoute, allowStaticRouteRender)
- main.server.ts: injects SSR-aware apiBaseUrl via APP_CONFIG
(reads SSR_API_BASE_URL env var)
- server.spec.ts: updated 5 test suites + added 5 new suites (31 tests)
covering server.ts cleanup, RenderMode.Server, entrypoint lifecycle,
Dockerfile build, nginx upstream proxy, and API URL injection
- vitest.config.ts: added server.spec.ts to include pattern
Request flow: Browser -> nginx (port 80) -> SSR server (port 4200)
-> Angular SSR -> HttpClient -> localhost:80/api/station-config
-> nginx proxies to api:8000 -> fresh data rendered in HTML
- Add <link rel="preload"> for hero background image (LCP element) in index.html
- Remove blocking APP_INITIALIZER for config.json and station config loading
- Config loads now fire asynchronously in App.ngOnInit() (browser only)
- SSR/prerender renders instantly with default config — no network wait
- Improves LCP, Time to First Byte, and initial paint for SEO
- Add AngularAppEngine.ɵallowStaticRouteRender flag so the engine serves
prerendered static HTML from disk instead of falling back to SSR
- Rename default export to named const (requestHandler) to avoid self-import
loop when the module is the entry point
- Use requestHandler directly in the main-module listener instead of
importing './server.js' which caused TS2307 resolution failure
- ThemeService: remove isPlatformBrowser guard so CSS custom properties
are applied during SSR/prerender — server-rendered HTML now carries the
correct color scheme and gradients (fixes missing colors on test site).
- main.ts: add provideClientHydration() so Angular preserves the server-
rendered DOM on the client instead of destroying and rebuilding it
(fixes jarring full-page reload between static and dynamic versions).
- Tests: update theme.service.spec.ts to match new behaviour, expand
coverage with gradient tests and empty-value handling (9 tests).
Fix structured-data.component.spec.ts import paths. All 54 tests pass.
The SSR packages were pinned to ^21.2.19 and ^21.2.20 which had
strict peer dependency requirements conflicting with @angular/common
at ^21.2.0. This caused npm ci (used in CI) to fail with peer
dependency resolution errors.
Aligned both packages to ^21.2.0 to match the rest of the Angular
core packages, and regenerated package-lock.json.
The PlatformLocation static initializer in @angular/common/router
requires @angular/compiler to be loaded first. Move the compiler
import before any Angular router/common imports so the JIT facade
is available when the module loads.
- Install @angular/ssr and @angular/platform-server (v21.2.x)
- Create server entry point (server.ts) and server bootstrap (main.server.ts)
- Add server routes config (app.server.routes.ts) with 5 prerendered routes:
/, /about, /schedule, /events, /contact
- Update angular.json with server/ssr/prerender build configuration
- Add prerender-routes.txt for build-time static route generation
- Fix SSR compatibility:
- App component: guard DOM access with isPlatformBrowser
- StructuredDataComponent: skip head manipulation on server
- ThemeService: skip CSS variable application on server
- ShowService/EventService: return empty arrays on API failure
(graceful fallback during prerender with no backend)
- Prerendered output verified: 5 static HTML routes in dist/
Add SeoService for dynamic metadata management:
- Per-route document titles (e.g., 'KMountain Flower Radio — About')
- Meta descriptions from station config per route
- Canonical URLs, Open Graph tags, Twitter Card tags
- Input sanitization (HTML stripping, URL validation, attribute escaping)
- noindex robots directives for /login and /admin routes
- Centralized router watcher (zero per-component boilerplate)
Add StructuredDataService + StructuredDataComponent for JSON-LD:
- Organization schema (NCBOrganization) site-wide with contact info
- WebSite schema with publisher info
- WebPage schema per-route with breadcrumbs
- Dynamic Event schema on /events page
- RadioSeason schema on /schedule page
Wire up SeoService.watchRouter() in app-root ngOnInit.
Wire up StructuredDataComponent in app-root template.
Wire up EventsComponent and ScheduleComponent to feed data to
StructuredDataService for dynamic schema injection.
Includes full test suite for SeoService (22 tests).
Addresses reviewer feedback: social media fields (Facebook, Twitter/X, Instagram, YouTube) were added to the backend and frontend interfaces but had no admin UI for station owners to configure them.
Added a 'Social Media' section to the station config form with four URL inputs that persist via the existing station-config API.
- test_sitemap_export.py: 19 tests covering SEOMAP_ROUTES constants,
build_sitemap_xml() output structure, XML validity, trailing slash
handling, and build_robots_txt() rules + sitemap reference
- test_sitemap_api.py: 7 tests covering /sitemap.xml and /robots.txt
endpoint status codes, content types, content validation, and
auth-gated /regenerate endpoint
Addresses reviewer REQUEST_CHANGES: 'This needs a unit test' and
'Unit test for this type too'
The reviewer noted the static sitemap.xml had a hardcoded domain in a
white-label app. Instead of writing files to disk, we now serve
sitemap.xml and robots.txt as dynamic API endpoints that read
KMTN_WEBSITE_URL at runtime.
- Nginx proxies /sitemap.xml and /robots.txt to the backend API so
search crawlers reach them at root paths.
- Removed the startup filesystem-write that would fail in Docker
(backend can't write to Nginx container's static dir).
- Simplified sitemap_export.py to pure generation functions (no I/O).
- Added /api/sitemap/regenerate admin endpoint for consistency.
- CI workflow: replace hardcoded branch list with ['**'] wildcard so
pipeline runs on every branch push, not just 4 named branches.
- Sitemap/robots.txt: moved from static files to dynamic generation.
New sitemap_export.py module (follows theme_export.py pattern) uses
KMTN_WEBSITE_URL to generate tenant-aware sitemap.xml and robots.txt
on app startup. Atomic writes to STATIC_FILES_DIR so Nginx serves them.
- Added /api/sitemap endpoint with /status (public) and /regenerate
(admin-only) for runtime control.
Addresses reviewer comments on PR #4:
1. 'more general way to ensure this runs on any branch on push'
2. 'whitelabel app - this file may need to be dynamically generated'
- Import RouterLinkWithHref in footer and events components (required for queryParams binding on <a>)
- Add social media fields to DEFAULT_CONFIG in station-config.service.ts
- Add public/robots.txt (allow all, disallow /admin and /login, reference sitemap)
- Add public/sitemap.xml (5 public routes with change frequencies and priorities)
- Fix heading hierarchy: h2 -> h1 on about, schedule, events, contact pages
- Remove dead footer href="#" links:
- Volunteer/Corporate Giving/Planned Giving -> routerLink to /contact with subject query param
- Social media links -> conditional on config().social_* fields (hidden when empty)
- Fix logo ARIA: add aria-hidden="true" to decorative logo in about page divider
- Add social_facebook, social_twitter, social_instagram, social_youtube to:
- StationConfig DB model + startup migration
- StationConfigResponse and StationConfigUpdate schemas
- Frontend StationConfig TypeScript interface
- Fix events "Learn More" dead link -> routerLink to /contact
Each public page now has exactly one <h1> element for proper semantic
heading hierarchy:
- About: 'About [Station Name]' (h1), with subsection h2s for History/Team
- Schedule: 'Program Schedule' (h1)
- Events: 'Upcoming Events' (h1)
- Contact: 'Get in Touch' (h1)
The home page hero component already used <h1>, as did login and admin.
The login endpoint already provisions the first admin user on demand
with a proper password_hash. The startup block in main.py created the
user without a password_hash, causing the login endpoint to see
has_admins=True and reject bootstrap credentials with 403.
Fix: remove the startup user-creation block so the login endpoint's
bootstrap path runs first and provisions the user correctly.
- Add users.password_hash to _migrate_add_missing_columns pending list
- Skip tables that don't exist yet (prevents crash on fresh DB)
- Handle nullable columns (no DEFAULT clause) in ALTER TABLE
- Fix SQLite column type (use sql_type instead of hardcoded TEXT)
- Update migrate_roles.py to create schema columns/tables before seeding roles
- Add unit tests for migration: adds column, nullable, idempotent, no-op
The Angular production build was failing with TS2348 errors because
EventEmitter<void>() was used without the 'new' keyword. This is not
valid TypeScript — EventEmitter is a constructor class, not a callable
value.
Fixes:
- src/app/admin/admin-users-tab.component.ts: added 'new' to saved and
closed EventEmitter properties
- Add 'frontend-build' job that runs 'npx ng build --configuration production'
- Wire frontend-build into quality-gate dependencies
- Include feature/multi-admin-roles in CI push branches
- Build failures now fail the pipeline instead of being silently missed
The previous check counted ALL users (func.count(User.id)), which
incorrectly blocked bootstrap login if any non-admin user (e.g.,
Google OAuth sign-in) already existed. The fix queries specifically
for admin users using the legacy is_admin flag OR the admin role
assignment in user_roles.
Also add tests for the non-admin-user-only scenario and fix the
stale test_non_admin_user_cannot_login_as_admin assertion (401
not 403 — bootstrap path is still active when no admin exists).
Backend:
- /api/admin/users: GET list, POST create, PUT update, DELETE
- Admin users can be added with email, display name, and password
- Self-deletion and last-admin deletion are blocked
- Admin role auto-assigned on creation
Frontend:
- AdminUsersTabComponent: list + add/delete admin users
- AdminUserService: HTTP client for admin user CRUD
- AdminUser interface
- 'Admins' tab added to admin dashboard
Tests:
- 10 new tests for admin user management endpoints
- 140 total tests pass (130 original + 10 new)
- Add Role model and user_roles association table (many-to-many)
- Add password_hash column to User model (nullable, for future bcrypt)
- Keep is_admin boolean for backward compatibility
- Add is_admin_effective property: true if legacy is_admin OR 'admin' role
- Update auth dependency (get_current_admin_user) to use is_admin_effective
- Update bootstrap login to auto-create 'admin' role and assign on login
- Update Google OAuth login to use is_admin_effective for token creation
- Add migrate_roles.py: idempotent migration script to backfill roles
- Add unit tests for Role, User, association table, and is_admin_effective
The actions/upload-artifact@v4 step fails on Gitea with:
GHESNotSupportedError: @actions/artifact v2.0.0+ not supported on GHES.
Coverage is already enforced via --cov-fail-under=10 in the test step,
so the artifact upload was redundant anyway.