Skip to content
s070sPublic

About

Mobile-first bilingual (EN/EL) fitness courses app with a public-domain exercise library — .NET 10 Blazor Server

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

FitEdu

A mobile-first, bilingual (English/Greek) fitness-courses app. Members follow training programmes whose workouts are built from a public-domain library of 873 exercises with start and end photos, track their workouts, streak and progress, and earn a certificate when they finish a course. Around that sit learning paths, self-assessments, course reviews, scheduled live classes, real-time chat, video calls, in-app notifications, subscriptions, a blog, testimonials, site search, and a local AI coach. Every page is laid out for a phone first (with a bottom tab bar below 768 px) and widens to a desktop layout.

Screenshots

Guest Home page on a phone: the hero with the Start free and Browse exercises buttons above the bottom tab bar Course detail page on a phone: the Bodyweight Basics programme with its level, goal and schedule chips and a Continue training bar Workout lesson on a phone: The Push-Up, Done Right, with its exercise count, progress bar and coaching text

Student dashboard on a desktop: today's planned workout with a Continue workout button and four progress stat tiles

Phone shots are 375 px wide and the dashboard is 1280 px, all in English with the Development seed data (student Alice). They are the first-screen captures of the responsive sweep, converted to WebP with tools/assets/convert-exercise-images.mjs --single <capture> <output.webp> <width> 0.8.


Quickstart

# 1. Clone
git clone https://github.com/s070s/FitEdu.git
cd FitEdu

# 2. Trust HTTPS dev certificate (once per machine)
dotnet dev-certs https --trust

# 3. Set up secrets — copy the template and fill in your own values
cp .env.template .env          # keep .env at the repo root (next to .env.template)
# Edit .env and set at minimum:
#   AdminUser__Password=YourAdminPassword123!
#   SeedData__StudentPassword=YourStudentPassword123!
#   Jwt__Key=your-dev-jwt-key-at-least-32-chars   ← must be ≥ 32 characters

# 4. Restore packages
dotnet restore

# 5. Build
dotnet build

# 6. Run
dotnet run --project src/FitEdu.Web
# Visual Studio: right-click Solution → Configure Startup Projects → set FitEdu.Web → F5

# 7. (Optional) Install Ollama for the local AI assistant — everything else is automatic
winget install Ollama.Ollama   # see the "AI Assistant" section below

Database is created and migrated automatically on first run. If you drop the database (e.g. from SSMS), just restart the app — it will recreate and reseed it.

Reseeding after a seed file changes: each seeder fills its table only when that table is empty, so an edited JSON file does not reach a database that was already seeded. To reseed everything, stop the app, drop the FitEduDb database and start the app again (LocalDB: sqlcmd -S "(localdb)\MSSQLLocalDB" -Q "ALTER DATABASE [FitEduDb] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [FitEduDb]"). The startup log then reports what each seeder wrote; a fresh Development database gets 873 exercises, 19 courses, 19 assessments, 4 learning paths, 6 testimonials, 6 live classes, 10 named students (students.json) plus the bulk students added in the background (SeedData:BulkStudentCount, default 10). Dropping the database also deletes users, enrolments and chat history, so do it only on a development machine.

Database connection string — do you need to change it on a new PC? The default targets SQL Server LocalDB (Server=(localdb)\MSSQLLocalDB), whose instance name is the same on every machine, so cloning onto a new PC needs no change — just have LocalDB installed (it ships with Visual Studio and SQL Server Express). Only set ConnectionStrings__DefaultConnection in .env if you point the app at a different SQL Server — a named instance, SQL Express, a remote server, or SQL authentication — because that server name is specific to the machine it runs on. Since .env is gitignored it never travels with a clone: on a new PC you recreate it from .env.template (LocalDB default) and adjust the connection string only if you are not using LocalDB. The app reads .env first and falls back to the dev default in appsettings.Development.json.

Admin: admin@fitedu.local / (password you set in .env)

Seed data (blog articles, the public-domain exercise library, courses, assessments, students, learning paths, testimonials, live classes) runs automatically in Development when SeedData:Enabled = true in appsettings.Development.json. Any other environment seeds it only when SeedData:AllowOutsideDevelopment = true is set as well (a public demo, see docs/DEPLOYMENT.md); a published app reads the JSON from its own SeedJson/ folder. The bulk student seeder runs in the background after startup. Set SeedData:BulkStudentCount in .env to control the count (default: 10). Each folder below is named by a SeedData:JsonPaths:<Folder> key in appsettings.Development.json (for example SeedData:JsonPaths:Exercises = ../FitEdu.Shared/JSON/Exercises, resolved from src/FitEdu.Web); when a key is missing the app falls back to the same folder next to the web project. The exercise library is seeded first, because course lessons name their exercises by slug. The seed catalogue, all in English and Greek:

Folder under src/FitEdu.Shared/JSON/ What it seeds
Exercises/ 873 public-domain exercises with photos (source, licence and Greek format). exercises.el.json gives the Greek name and steps of all 873.
Courses/ 19 fitness courses: 51 modules, 131 lessons and 382 exercise prescriptions linked by slug (file format). Seven categories (Strength, Cardio, Mobility, Core, Nutrition, Recovery, Performance), Beginner to Advanced, 9 Free, 5 Plus and 5 Pro.
Assessments/ 19 self-assessment questionnaires (one file per key, e.g. training_readiness_v1.json) grouped as Readiness, Mobility, Nutrition, Recovery, Safety, Cardio, Strength, Mindset and Setup. They share the category list with the courses, so a shared name (e.g. Mobility) has the same Greek name in both.
Students/ The 10 demo students (first.last@fitedu.local).
Paths/ 4 published learning paths of 4 courses each (Beginner Start, Fat Loss, Muscle Building, Better Movement). Each step names its course by English title, so an unknown title stops the seed (file format).
Showcase/ 6 Home-page testimonials and 6 live classes hosted by the admin account, dated on the next six weekdays after the seed date (UTC) and capped at 6 people like the video call (file format).

The blog articles are seeded from code: src/FitEdu.Application/DTOs/Blog/BlogSeedData.cs holds 4 fitness articles in English and Greek (progressive overload, protein, sleep, a five-minute warm-up) by "FitEdu", with covers under wwwroot/images/blog/ and English keyword tags (Strength, Nutrition, Recovery, Mobility, Beginners) that the cards and article pages show in the page language (BlogTagLabels, HomeRes.BlogTag_*).

The three subscription plans are seeded from code in every environment, once, when the table is empty: SubscriptionPlanSeeder writes Free (starter courses and the exercise library, 1 active course), Plus (every Free and Plus course plus assessments, 10 active courses) and Pro (everything, plus certificates and priority support), each with an English and a Greek description. Live classes are open to every signed-in member, so no plan sells them. A database seeded before this change keeps its old plan descriptions until the plans table is emptied.

SeedContentIntegrityTests checks every seed file (counts, Greek text, exercise slugs and photos — every library exercise has both photos and its slugs match docs/assets/exercise-slugs.txt — language-neutral reps, Greek imperatives, Greek titles in sentence case) and the resx files (EN, EL and Designer keys match; no untranslated Greek copies; a CourseRes label for every level, goal and equipment code). Copy-style checks enforce docs/LANGUAGE-GLOSSARY.md, the EN/EL wording rulebook: no gender slashes in Greek, «» quotes in Greek resx, "…" instead of "...", Greek list letters (Α, Β), British "enrolment", sentence case for short English labels, and unique Greek exercise names with lowercase brackets. It also keeps vocabulary left over from the previous, non-fitness product out of the seed JSON, every resx file and the C# seed data (BlogSeedData.cs, the Infrastructure/Seeding seeders such as SubscriptionPlanSeeder.cs, and DatabaseSeedingExtensions.cs) and out of the member-facing text written in C# (the assistant prompt in AssistantService.cs, the certificate text in CertificateService.cs, the email wording in EmailTemplates.cs, the Swagger request examples in OpenApiExtensions.cs). RealSeedJsonSeedingTests runs the real seeders on these folders and checks every seeded text against its SQL Server column length, so a broken file fails dotnet test instead of a fresh database.

Never commit .env — it is already in .gitignore. Use .env.template to document which keys are needed.

Known build issue (OpenApi pin): package versions are pinned centrally in Directory.Packages.props, and committed packages.lock.json files with CI --locked-mode guard against drift. If a restore/build ever fails with a Microsoft.OpenApi version conflict (some restore paths silently rewrite that pin), recover with git checkout Directory.Packages.props (and any changed .csproj), then restore again.

Known build issue (SDK-supplied lock entry): src/FitEdu.Web/packages.lock.json carries a Direct entry for Microsoft.AspNetCore.App.Internal.Assets that no file in this repo declares — its version comes from the installed .NET SDK's bundled-versions props. So an SDK servicing update changes what restore resolves, and --locked-mode then fails on a lock file nobody edited. This has already required four manual bumps (9d8764c → 10.0.10, 2df6bac → 10.0.11, 1513d66 → 10.0.12, 10.0.13 with SDK 10.0.402 on 2026-10-18, and 10.0.14 with SDK 10.0.403 on 2026-10-30). Recovery when it happens locally is the same each time: dotnet restore FitEdu.sln --force-evaluate and commit the refreshed lock file. In CI it is now pinned instead: .github/workflows/tests.yml installs the SDK named by DOTNET_SDK_PIN (10.0.403, which bundles ASP.NET Core 10.0.14, matching the committed lock file) and writes a global.json with rollForward: disable so a newer SDK already on the runner cannot take over (that broke CI on 2026-10-30) rather than floating on 10.0.x, which had broken the build on 2026-09-09 as soon as SDK 10.0.401 shipped with 10.0.12. The pin and the lock entry are one unit and must be changed together — editing the lock file alone breaks CI just as surely as letting the SDK float did, which is exactly what happened on 2026-09-12 when 1513d66 moved the lock to 10.0.12 while the pin still said 10.0.400. So after any --force-evaluate refresh, bump dotnet-version to the SDK you refreshed with, in the same commit. The upstream problem — a lock file containing an SDK-supplied package cannot be shared across SDK versions — is tracked at dotnet/aspnetcore#64897.


Tech Stack

Layer Technology
Runtime .NET 10
Frontend / Backend Blazor SSR + ASP.NET Core Web API
ORM Entity Framework Core 10 (SQL Server)
Auth ASP.NET Core Identity · Cookie (SSR) · JWT Bearer · Refresh tokens
Real-time SignalR — chat (/hubs/chat), video-call signaling (/hubs/call), and notifications (/hubs/notifications)
Video calls Self-hosted WebRTC (P2P mesh, up to 6 participants) with SignalR signaling
API docs OpenAPI (Microsoft.AspNetCore.OpenApi) + Swagger UI — /swagger (Development only)
PDF QuestPDF 2026.8.0 (certificates: one landscape page in the brand palette with the dumbbell mark)
Localization English + Greek (.resx); the Greek culture uses a 24-hour clock (21:00), and the loopback API calls and the chat/call hub connections carry the page language as Accept-Language, so server and hub errors come back in that language
Testing xUnit + Shouldly + NSubstitute · EF Core InMemory/SQLite · WebApplicationFactory · Playwright e2e (local)
CI GitHub Actions (.github/workflows/tests.yml)
Logging Custom daily file logger
Email MailKit SmtpEmailService whenever Email:Smtp:Host is configured; StubEmailService (logs to file) as the Development fallback; startup throws in Production if neither. Both take their subjects and bodies from EmailTemplates, in English or Greek: the request language when the member asks for the email, the member's stored language when an admin triggers it
Security HSTS · X-Content-Type-Options · X-Frame-Options · Referrer-Policy · Permissions-Policy
AI Assistant Local Ollama sidecar via Microsoft.Extensions.AI — qwen3:8b chat (tool-calling agent) + bge-m3 embeddings, auto-pulled at startup, no cloud API

Solution Structure

FitEdu.sln
├── src/
│   ├── FitEdu.Domain/          Entities, enums, value objects — no framework dependencies
│   ├── FitEdu.Application/     Service interfaces, DTOs, application services, entity→DTO mapping helpers (Mappings/)
│   ├── FitEdu.Infrastructure/  EF Core DbContext, migrations, service implementations
│   ├── FitEdu.Web/             Blazor SSR + API controllers — the only deployable project
│   └── FitEdu.Shared/          Localization resources (.resx EN/EL + Designer classes), JSON seed data
├── docs/                                Runbook, deployment notes, asset manifest and licences, open audit backlog, EN/EL language glossary
├── tests/                               Unit, integration, e2e (Playwright), and shared test-support projects
├── tools/azure-demo/                    PowerShell script that puts a free public demo on Azure (see its README)
├── tools/assets/                        Node scripts that convert and render the shipped images (see its README)
├── tools/responsive-check/              Node sweep that checks every page at phone and desktop size (see its README)
└── tools/shared/                        Headless Chrome launcher both tool folders use (needs PLAYWRIGHT_CORE)

Adding a field to an entity touches several layers (entity → EF config → migration → DTO → mapping helper → controller → consumer → page → resx). Follow the checklist in docs/ADDING-A-FIELD.md so no step is missed — a skipped step surfaces as a silently blank value, not an error.


Quality & Tests

Run the full suite locally:

dotnet test FitEdu.sln

The test projects mirror the application layers under tests/: Domain.Tests, Application.Tests, Infrastructure.Tests, and Web.Tests, plus FitEdu.TestSupport for shared fixtures. On 2026-10-13 the suite ran 1,694 tests, all passing: Domain 190, Application 482, Infrastructure 250 and Web 772 (the Web project includes the bUnit component tests, the asset-licence guard and the integration tests; Infrastructure includes the seed-content guards). The suite uses free/permissive test dependencies only: xUnit, Shouldly, NSubstitute, EF Core InMemory/SQLite, and Microsoft.AspNetCore.Mvc.Testing.

Web integration tests boot the real ASP.NET Core pipeline through WebApplicationFactory<Program>. The test host supplies dummy JWT/admin settings, runs in Development, swaps SQL Server for InMemory, and uses SQLite only for provider behaviors InMemory cannot execute, such as EF.Functions.Like or ExecuteUpdateAsync.

GitHub Actions runs on every push and pull request, and can also be started by hand from the Actions tab (Run workflow): a locked-mode restore, a Release build that fails on any compiler or analyzer warning (-warnaserror; the solution builds warning-free), a code-style gate (dotnet format style --verify-no-changes — a file that drifts from .editorconfig fails the build), and dotnet test, then uploads TRX results from TestResults/*.trx. A separate job applies the full migration chain against a real SQL Server container to catch migrations that pass on InMemory/SQLite but break on SQL Server. There is no coverage gate or coverage artifact yet; the definition of done is a green dotnet test locally and in CI. Style and the recommended analyzers also run in every local build (EnforceCodeStyleInBuild), surfacing as warnings rather than blocking day-to-day work.

End-to-end tests (local)

A Playwright smoke suite under tests/e2e/ covers the browser-only behaviors the .NET suite can't reach: login through the real auth redirect chain, a consumer-backed page actually rendering data (the silent-blank-render failure mode), the Greek culture switch, the key public pages fitting phone and desktop screens with working images, and a two-user video call connecting with fake media (22 tests in four spec files: 16 of them are the eight public pages on a phone and a desktop screen). It runs locally on demand (needs Node.js and the seeded Development database — it is not part of CI):

cd tests/e2e
npm install
npx playwright install chromium   # first time only
npx playwright test

Responsive sweep (local)

tools/responsive-check/ opens every page of a running app as a guest, a student and an admin, at 375×812 and 1280×800, in English and Greek, and reports sideways scrolling, broken images, console errors and failed requests, visible error messages, the phone tab bar, small tap targets, raw resource keys and English-looking text on Greek pages. When the student is enrolled nowhere it first enrols them in a free programme, so the lesson page itself is checked. It needs no npm install (it borrows an existing playwright-core, named by the required PLAYWRIGHT_CORE variable, and the installed Chrome). With the app running, pass the seeded passwords in FITEDU_STUDENT_PASSWORD and FITEDU_ADMIN_PASSWORD and run node tools/responsive-check/check-pages.mjs http://127.0.0.1:5090 <output-folder> (keep the folder outside the repo); it writes report.md, report.json and two screenshots per visit, and exits non-zero on any defect. The 2026-10-13 sweep visited 65 role and page pairs at both sizes in both languages (260 visits) with no defects.

Monitoring & diagnostics

The app exposes two health endpoints for uptime probes and orchestrators:

  • GET /health/live — liveness (process is up; runs no checks).
  • GET /health/ready — readiness (database reachable; the AI assistant reports Degraded, not down, when Ollama is unreachable).

For live runtime metrics (GC, thread pool, request rate, exceptions) without any extra setup, attach the built-in .NET counters to a running instance:

dotnet counters monitor -n FitEdu.Web

A full metrics/tracing stack (OpenTelemetry + an OTLP backend) is intentionally not wired in — this single-instance project has no metrics backend to ship to, and dotnet-counters plus the daily log error-digest cover live diagnostics. Adopting OpenTelemetry is the documented next step if the app is ever run unattended (see docs/plans/38-audit-observability.md).


API Documentation (Swagger / OpenAPI)

Interactive API docs are generated from the code (built-in Microsoft.AspNetCore.OpenApi + Swagger UI) and served in Development only:

Resource URL
Swagger UI https://localhost:7090/swagger
OpenAPI document (JSON) https://localhost:7090/openapi/v1.json

Authorize / test secured endpoints: click Authorize in Swagger UI, then paste a JWT obtained from POST /api/auth/login — paste the token value only (the Bearer prefix is added for you). The lock icon on each operation reflects whether it requires authentication.

What's covered:

  • Every API controller, grouped by tag (e.g. Authentication, Courses, Admin · Users & Roles), with summaries, parameter descriptions, response codes (200 / 201 / 204 / 400 / 401 / 403 / 404 / 409 / 500), and request-body examples pre-filled for "Try it out".
  • The four browser-navigation endpoints (/culture/set, /auth/complete, /auth/signout, /sitemap.xml) under the Infrastructure tag.
  • The SignalR chat hub (/hubs/chat) — documented in the document description (invoke methods, server events, query-string JWT auth) with the ChatMessageDto / ChatNotificationDto payload shapes under Schemas. SignalR is not a REST protocol, so it appears as reference rather than callable operations.
  • The AI Assistant chat endpoint (POST /api/assistant/chat) — a normal HTTP operation but with a text/event-stream (Server-Sent Events) response; the document description explains the AssistantStreamEvent frame shape (see AI Assistant).
  • The SignalR call hub (/hubs/call) is the WebRTC signaling channel for video calls; like the chat hub it is a bidirectional WebSocket protocol, so it is not represented as callable REST operations (see Video Calls).

Production: the Swagger UI and /openapi/v1.json endpoints are mapped only when ASPNETCORE_ENVIRONMENT=Development; they are not exposed in Production.

Implementation: src/FitEdu.Web/OpenApi/OpenApiExtensions.cs (info metadata, JWT bearer security scheme, per-operation security/lock, parameter & response descriptions, request examples, hub documentation). Action/DTO summaries flow into the doc via <GenerateDocumentationFile> on the FitEdu.Web and FitEdu.Application projects.

The tables below are a quick static reference; Swagger UI is the authoritative, always-current source.


Endpoints

Auth — api/auth

Method Route Description Auth
POST api/auth/register Register new user (Student role) No
GET api/auth/confirm-email Confirm email via token link No
POST api/auth/login Log in — returns JWT + refresh token No
POST api/auth/forgot-password Request password-reset email No
POST api/auth/reset-password Reset password with token No
GET api/auth/me Current user info from JWT Yes
POST api/auth/refresh Rotate refresh token — returns new JWT + refresh token pair No
POST api/auth/dev/confirm-email Dev-only: confirm email without link (compiled out in Release builds) Dev
POST api/auth/dev/reset-password Dev-only: reset password without email (compiled out in Release builds) Dev

Profile — api/profile

Method Route Description Auth
GET api/profile Get current user's profile Yes
PUT api/profile Update profile Yes
DELETE api/profile Delete own account (GDPR erasure); returns 204 No Content Yes
GET api/profile/export Download own data (GDPR access/portability) Yes
POST api/profile/avatar Upload avatar Yes
GET api/profile/avatar Get avatar Yes
POST api/profile/change-password Change password Yes
GET api/profile/training-stats Home dashboard stats: workouts completed, minutes trained, daily streak (days of the members' time zone, Site:TimeZone), the programme to continue, goal and level Yes

Courses — api/courses

The catalogue reads are public so guests can browse programmes: a guest gets no enrolment or progress, and the detail shows lesson titles, kinds, minutes and exercise counts but never lesson content. Joining, lessons, completion and writing a review need a signed-in member. The catalogue keeps its filters and page number in the address (/courses?category={id}&search=core&goal=FatLoss&level=Beginner&equipment=none&page=2), so other pages can link to a filtered list and Back returns to it. The pages /courses and /courses/{id} are open to guests too (the guest header and menu link to Programmes; the phone tab bar keeps its four guest tabs): a guest sees no plan banner, paid programmes link to /pricing, and a programme page offers Sign up free to start (/register?returnUrl=/courses/{id}) and Log in (/login?returnUrl=/courses/{id}) in place of Join. Both bring them back to the programme: the sign-up page passes the return address on to its log-in links, and the log-in page to its sign-up link; an address that points to another site is dropped.

Method Route Description Auth
GET api/courses List published courses (optional ?categoryId= and ?search= filters — see Content Categories — plus ?goal= and ?level= by enum name and ?equipment= by one equipment code). Items carry level, goal, equipment, weeks, sessions per week, a cover image URL and, for a member, whether they joined (isEnrolled) and finished (isCompleted) it No
GET api/courses/{courseId} Course detail with modules, lessons and the same fitness fields No
GET api/courses/enrolment-count How many programmes the member has joined, counted the same way as the plan limit (the catalogue's plan banner shows it) Yes
POST api/courses/{courseId}/enroll Enroll in a course Yes
GET api/courses/lessons/{lessonId} Lesson detail, including the prescribed exercises (sets, reps, rest, notes, photos) in lesson order Yes
POST api/courses/lessons/{lessonId}/complete Mark lesson complete Yes
GET api/courses/{courseId}/reviews Approved reviews and aggregate rating of a published course (none for an unpublished one), and the caller's own review (none for a guest) — see Course Reviews No
POST api/courses/{courseId}/reviews Create/update own review (enrolled only; edits reset it to Pending for moderation) Yes

Exercises — api/exercises

The public exercise library (published exercises only). Photos are served from /images/exercises/{slug}/0.webp (start position) and 1.webp (end position). The public pages /exercises (library grid with muscle, level, type and equipment filters, search and pages, all kept in the address such as /exercises?muscle=chest&page=2, so a link or Back shows the same list) and /exercises/{slug} (photos, muscles, steps and the workouts that use it) read these endpoints.

Method Route Description Auth
GET api/exercises Paged list ordered by name (?page=1&pageSize=24&lang=en; optional search, muscle, equipment, level, category filters) No
GET api/exercises/facets Every muscle, equipment, level and category value with its exercise count (drives the filter chips; cached for 60 s) No
GET api/exercises/{slug} Exercise detail with muscles, force, mechanic and step-by-step instructions (?lang=en) No
GET api/exercises/{slug}/lessons Lessons of published courses that prescribe the exercise (?lang=en) No

Lesson Assets — api/lessons

Method Route Description Auth
GET api/lessons/{lessonId}/asset?type=pdf|video Download lesson PDF or video (enrolled only) Yes

Assessments — api/assessments

Method Route Description Auth
GET api/assessments List published assessments (paged; optional ?categoryId= and ?search= filters — see Content Categories) Yes
GET api/assessments/{id} Assessment detail Yes
POST api/assessments/{id}/submit Submit answers (AnswersJson max 50 000 chars; a multi-select answer is a JSON array of the ticked options, since options may contain commas) Yes
GET api/assessments/mine Current user's submissions Yes

Categories — api/categories

Method Route Description Auth
GET api/categories?scope=courses|assessments&lang=en Categories with ≥1 published item in the scope, with counts (drives the browse/filter chips). Public for both scopes because the guest course catalogue shows the chips; for assessments a guest learns only category names and counts, while the assessments stay members-only No

Certificates — api/certificates

Method Route Description Auth
GET api/certificates/my List current user's certificates Yes
POST api/certificates/issue/{courseId} Issue certificate for completed course (certificate-enabled plan required) Yes
GET api/certificates/{certificateId}/download Download certificate PDF in the member's language (English or Greek); each language is rendered on its own first download and kept Yes
GET api/certificates/verify/{verificationId} Public certificate verification No

Learning Paths — api/paths

Curated, ordered sequences of courses (see Learning Paths). Public catalog; reads the caller's identity when present to project per-user step progress.

Method Route Description Auth
GET api/paths Published learning paths for the catalog (?lang=en) No
GET api/paths/{id} Path detail with ordered steps and, for signed-in users, per-step progress (locked / next / done) No

Landing — api/landing

Numbers and showcase for the guest home page in one call. The page's numbers strip shows four tiles: programmes, exercises, lessons, and the member count once it reaches 100 (before that, the published self-assessments take the fourth place).

Method Route Description Auth
GET api/landing Published course and lesson counts, exercise, member and published self-assessment counts, the six most-enrolled courses, and whether a custom hero photo is set (?lang=en; cached for 60 s per language) No

Scheduled Sessions — api/sessions

Upcoming live sessions an admin schedules and users register for (see Scheduled Sessions).

Method Route Description Auth
GET api/sessions Upcoming (Scheduled/Live) sessions, soonest first (?lang=en) Yes
POST api/sessions/{id}/register Register the current user for a session Yes
POST api/sessions/{id}/unregister Unregister from a session Yes
POST api/sessions/{id}/link-call Record the video-call session a participant started for this scheduled session Yes

Subscriptions — api/subscriptions

Method Route Description Auth
GET api/subscriptions/plans List plans, with the description in the chosen language (?lang=en) No
GET api/subscriptions/status Current user's subscription status Yes
POST api/subscriptions/checkout Start checkout Yes
POST api/subscriptions/webhook Payment webhook No
POST api/subscriptions/cancel Cancel subscription Yes
GET api/subscriptions/billing Billing history Yes

Chat — api/chat + SignalR /hubs/chat

Method Route Description Auth
GET api/chat/conversations List conversations Yes
GET api/chat/conversations/{conversationId}/messages Load messages Yes
POST api/chat/conversations/start Start conversation Yes
DELETE api/chat/conversations/{conversationId} Delete conversation Yes
GET api/chat/users Users available to chat Yes
GET api/chat/unread-count Unread message count Yes
— /hubs/chat (SignalR) Real-time hub Yes (JWT via query string)

Video Calls — SignalR /hubs/call

Video calls have no REST endpoints — everything runs over the SignalR hub (WebRTC signaling) plus browser-to-browser media. Like chat, they are available to every authenticated user. See Video Calls for the full flow.

Method Route Description Auth
— /hubs/call (SignalR) Call signaling hub — StartCall / AcceptCall / DeclineCall / CancelCall / LeaveCall / InviteToCall / RejoinCall / UpdateMediaState / GetCallableUsers, plus SendOffer / SendAnswer / SendIceCandidate for WebRTC negotiation Yes (JWT via query string)

Notifications — api/notifications + SignalR /hubs/notifications

An in-app notification inbox with a bell badge and live push (see Notifications). Available to every authenticated user.

Method Route Description Auth
GET api/notifications Paged, sortable notification history (?page=&pageSize=&sortBy=&sortDir=) Yes
GET api/notifications/unread-count Unread count for the bell badge Yes
POST api/notifications/{id}/read Mark one notification read Yes
POST api/notifications/read-all Mark all of the caller's notifications read Yes
— /hubs/notifications (SignalR) Live push of new notifications Yes (JWT via query string)

Blog — api/blog

Method Route Description Auth
GET api/blog/summaries Latest published summaries (?count=6&lang=en) No
GET api/blog/{slug} Single article by slug (?lang=en) No

Testimonials — api/testimonials

Method Route Description Auth
GET api/testimonials All active testimonials ordered by DisplayOrder, role and quote in the requested language (?lang=en; Greek falls back to English when a testimonial has no Greek text) No

Search — api/search

Method Route Description Auth
GET api/search Site-wide search over published courses, assessments, blog articles and exercises (?q=&limit=8&lang=en; empty q returns no results; exercises are matched by name, in the title search, which gives every source a hit before any source gets a second, and beside the AI index's meaning-based hits, which hold no exercises; those hits show a snippet of the matching text in the search language) No

Site Settings — api/site

Method Route Description Auth
GET api/site/background-image Landing page background image No
POST api/admin/site/background-image Upload landing page background image (lives under api/admin/* like every other admin-only surface) Admin

Media — api/media

Method Route Description Auth
GET api/media/{*filePath} Serve public media files (allowed folders: blog/covers, testimonials/avatars; allowed extensions: .jpg .jpeg .png .gif .webp .avif .svg) No

Admin — Users — api/admin

Method Route Description Auth
GET api/admin/users List users (paged, searchable) Admin
GET api/admin/users/{userId} User detail Admin
GET api/admin/users/search Search users Admin
POST api/admin/users/{userId}/roles/{roleName} Add role Admin
DELETE api/admin/users/{userId}/roles/{roleName} Remove role Admin
GET api/admin/roles List all roles Admin
POST api/admin/roles/{roleName} Create role Admin
POST api/admin/users/{userId}/disable Disable user Admin
POST api/admin/users/{userId}/enable Enable user Admin
DELETE api/admin/users/{userId} Delete user; returns 204 No Content Admin
POST api/admin/users/{userId}/force-password-reset Force password reset — emails reset link to user; returns 204 No Content Admin
POST api/admin/users/{userId}/set-password Directly set password Admin
POST api/admin/users/{userId}/impersonate Generate temporary JWT as that user Admin

Admin — Courses — api/admin/courses

Method Route Description Auth
GET api/admin/courses List all courses Admin
GET api/admin/courses/{id} Course detail with modules and enrollments Admin
POST api/admin/courses Create course (incl. level, goal, equipment codes, weeks, sessions per week, cover exercise slug; a slug that is not a published library exercise, or an equipment code outside Course.EquipmentCodes, returns 400) Admin
PUT api/admin/courses/{id} Update course (same fields and checks) Admin
DELETE api/admin/courses/{id} Delete course Admin
POST api/admin/courses/{id}/publish Publish Admin
POST api/admin/courses/{id}/unpublish Unpublish Admin

Admin — Modules — api/admin/modules

Method Route Description Auth
GET api/admin/modules/course/{courseId} List modules for a course Admin
GET api/admin/modules/{id} Module detail Admin
POST api/admin/modules Create module Admin
PUT api/admin/modules/{id} Update module Admin
DELETE api/admin/modules/{id} Delete module Admin

Admin — Lessons — api/admin/lessons

Method Route Description Auth
GET api/admin/lessons/module/{moduleId} List lessons for a module Admin
GET api/admin/lessons/{id} Lesson detail, including its exercise rows in order Admin
POST api/admin/lessons Create lesson, optionally with its exercises (slug, sets, reps, rest, notes EN/EL) Admin
PUT api/admin/lessons/{id} Update lesson; an exercises list replaces the lesson's exercises wholesale in list order (omit it to keep them; an unknown or unpublished slug returns 400) Admin
DELETE api/admin/lessons/{id} Delete lesson Admin
POST api/admin/lessons/{id}/upload/pdf Upload PDF Admin
POST api/admin/lessons/{id}/upload/video Upload video Admin

Admin — Assessments — api/admin/assessments

Method Route Description Auth
GET api/admin/assessments List assessments (paged) Admin
GET api/admin/assessments/{id} Assessment detail Admin
POST api/admin/assessments Create assessment Admin
PUT api/admin/assessments/{id} Update assessment Admin
DELETE api/admin/assessments/{id} Delete assessment Admin
POST api/admin/assessments/{id}/publish Publish Admin
POST api/admin/assessments/{id}/unpublish Unpublish Admin
GET api/admin/assessments/{id}/submissions List submissions Admin

Admin — Categories — api/admin/categories

Method Route Description Auth
GET api/admin/categories List categories with usage counts (paged) Admin
GET api/admin/categories/all All categories (unpaged) — for course/assessment editor dropdowns Admin
POST api/admin/categories Create category Admin
PUT api/admin/categories/{id} Rename category Admin
DELETE api/admin/categories/{id} Delete category — referencing courses/assessments become uncategorized, not hidden Admin

Admin — Blog — api/admin/blog

Method Route Description Auth
GET api/admin/blog List articles (paged, searchable) Admin
GET api/admin/blog/{id} Article detail Admin
POST api/admin/blog Create article Admin
PUT api/admin/blog/{id} Update article Admin
POST api/admin/blog/{id}/publish Publish Admin
POST api/admin/blog/{id}/unpublish Unpublish Admin
DELETE api/admin/blog/{id} Delete article Admin
POST api/admin/blog/{id}/upload/cover Upload cover image Admin

Admin — Testimonials — api/admin/testimonials

Method Route Description Auth
GET api/admin/testimonials List testimonials (paged) Admin
GET api/admin/testimonials/{id} Testimonial by id Admin
POST api/admin/testimonials Create Admin
PUT api/admin/testimonials/{id} Update Admin
POST api/admin/testimonials/{id}/toggle-active Toggle active Admin
POST api/admin/testimonials/{id}/move-up Move up Admin
POST api/admin/testimonials/{id}/move-down Move down Admin
POST api/admin/testimonials/{id}/upload/avatar Upload avatar Admin
DELETE api/admin/testimonials/{id}/avatar Remove avatar Admin
DELETE api/admin/testimonials/{id} Delete Admin

Admin — Learning Paths — api/admin/paths

Method Route Description Auth
GET api/admin/paths List all learning paths (paged) Admin
GET api/admin/paths/{id} Path detail with steps Admin
POST api/admin/paths Create path (unpublished, no steps) Admin
PUT api/admin/paths/{id} Update path details Admin
POST api/admin/paths/{id}/publish Publish to the public catalog Admin
POST api/admin/paths/{id}/unpublish Unpublish Admin
DELETE api/admin/paths/{id} Delete path and its steps Admin
POST api/admin/paths/{id}/steps Append a course as the next step Admin
DELETE api/admin/paths/{id}/steps/{stepId} Remove a step (remaining steps re-sequenced) Admin
POST api/admin/paths/{id}/steps/{stepId}/move-up Move a step one position earlier Admin
POST api/admin/paths/{id}/steps/{stepId}/move-down Move a step one position later Admin

Admin — Scheduled Sessions — api/admin/sessions

Method Route Description Auth
GET api/admin/sessions List all scheduled sessions (paged) Admin
POST api/admin/sessions Create a session (the creating admin becomes host) Admin
PUT api/admin/sessions/{id} Update session details Admin
POST api/admin/sessions/{id}/cancel Cancel a session Admin

Admin — Course Reviews — api/admin/course-reviews

Method Route Description Auth
GET api/admin/course-reviews Moderation queue (paged; ?status=Pending|Approved|Rejected) Admin
POST api/admin/course-reviews/{id}/approve Approve — makes the review visible on the course page Admin
POST api/admin/course-reviews/{id}/reject Reject a pending review Admin

Admin — Analytics — api/admin/analytics

Method Route Description Auth
GET api/admin/analytics/summary Dashboard summary Admin

AI Assistant — api/assistant

Method Route Description Auth
POST api/assistant/chat?lang=en|el Grounded, streaming answer — response is text/event-stream, not JSON (see AI Assistant) Yes
GET api/assistant/status Whether the local model backend is reachable Yes

Admin — AI Assistant — api/admin/assistant

Method Route Description Auth
POST api/admin/assistant/reindex Re-index published content immediately instead of waiting for the next scheduled pass Admin

Roles

Role Access
Admin Full access — content authoring, user/role management, site settings
Student Enroll in courses, follow learning paths, view lessons, take assessments, review courses, register for scheduled sessions, chat and call, manage profile/subscription, download certificates

Configuration

All secrets are loaded from .env at startup (see .env.template). The .env file is gitignored — never commit it.

Key Where Notes
ConnectionStrings__DefaultConnection .env Full SQL Server connection string. appsettings.json intentionally blank. Dev default (with TrustServerCertificate=True) is in appsettings.Development.json.
Jwt__Key .env ≥ 32 bytes required — startup throws if shorter.
Jwt:AccessTokenExpirationMinutes appsettings.json Default 15.
Jwt:RefreshTokenExpirationDays appsettings.json Default 7.
AdminUser__Password .env Admin seed account password.
SeedData__StudentPassword .env Seed student password. Required whenever sample content is seeded (SeedData:Enabled = true, plus SeedData:AllowOutsideDevelopment = true outside Development).
Payment:MockEnabled appsettings.Development.json true in dev — skips real Stripe; uses mock checkout.
Payment__WebhookSecret .env Stripe HMAC signing secret. Leave unset in dev.
AllowedHosts .env or env var Default localhost;127.0.0.1. Set to your production domain (e.g. fitedu.com;www.fitedu.com) before deploying.
SelfBaseUrl .env The app's own real bound base address (used for its self-calling loopback API consumers). Defaults to https://localhost:7090 in Development only — startup throws outside Development if unset or still pointing at localhost.
Email__Smtp__Host (+ Port/Username/Password/FromAddress) .env SMTP relay for real email via SmtpEmailService (MailKit). Set it to send real mail; leave unset in Development to use the logging stub. Required outside Development — startup throws without it.
Assistant__Enabled / Assistant__BaseUrl .env or appsettings.json Toggle the local AI assistant (default on) and point it at a non-default Ollama host.
Site:TimeZone appsettings.json The members' time zone as an IANA id (default Europe/Athens). The dashboard date, the training-streak day boundary and every date and time the pages show use it (SiteClock), never UTC or the server's own zone; admins type class start times in it too. Startup throws on an unknown id.
Sitemap:BaseUrl appsettings.json Public site address (default https://fitedu.com). /sitemap.xml lists the public pages (home, programmes, exercises, paths, blog, pricing, privacy, terms), every published blog article, programme, learning path and exercise under it (a missing exercise, article or programme page answers 404 with its friendly not-found view, so crawlers drop dead addresses; when the API could not be asked it answers 503 instead, so an outage never makes a real page look gone), and SeoHead builds each page's canonical, Open Graph and Twitter URLs from it. Required — startup throws without it.

appsettings.Development.json: SeedData:Enabled, SeedData:BulkStudentCount, SeedData:JsonPaths:*, Payment:MockEnabled, dev connection string.

Production checklist: (see docs/DEPLOYMENT.md for the full detail behind the SelfBaseUrl/loopback-TLS, DataProtection key-ring, and email-transport items)

  • ASPNETCORE_ENVIRONMENT=Production
  • Real Jwt__Key (≥ 32 bytes), ConnectionStrings__DefaultConnection (no TrustServerCertificate=True)
  • AllowedHosts set to the production domain
  • SelfBaseUrl set to the app's real bound address (startup throws otherwise)
  • Email:Smtp:Host (+ credentials) configured so SmtpEmailService sends real email (startup throws in Production when no SMTP host is set)
  • Payment__WebhookSecret set if Stripe webhooks are used — the webhook endpoint returns 503 without it and never skips signature verification. (Payment:MockEnabled is ignored outside Development, so mock checkout can't grant free upgrades in production.)
  • Behind a reverse proxy (nginx/Caddy/IIS ARR/cloud LB terminating TLS): the app processes X-Forwarded-Proto/X-Forwarded-For automatically and trusts a loopback (same-host) proxy. For a proxy on a different host, list its IP(s) in ForwardedHeaders:KnownProxies — otherwise HTTPS redirects loop and the Secure auth cookie is refused.
  • Persistent state outside the deploy folder so a redeploy isn't destructive: point Storage:UploadsPath (uploaded avatars/PDFs/videos) and Logging:File:Directory (log files) at durable paths; the database and DataProtection keys already live in SQL Server, not on disk.
  • Migrations run automatically at startup (MigrateAsync, with bounded retry-with-backoff if the database isn't reachable yet); ensure the DB user has dbcreator or schema-alter rights on first deploy
  • /health/live (process up, no dependency checks) and /health/ready (database + AI assistant status) are available for a load balancer/orchestrator to poll

Operating it: docs/runbook.md covers backup & restore, the update/migration procedure, and first-response steps for the likely incident scenarios (app won't start, empty pages, login/email/assistant failures, admin lockout).


Email

The email transport is selected at startup from configuration:

  • Email:Smtp:Host configured → SmtpEmailService (MailKit) sends real email — point it at a local catcher (Papercut/Mailhog) in Development or any SMTP relay (SES, SendGrid SMTP, etc.) in production. Settings live under Email:Smtp:* (host, port, StartTLS, credentials, from address).
  • No SMTP host, Development → StubEmailService logs all emails to file instead of sending them — find links in Logs/log-YYYY-MM-DD.txt (search STUB EMAIL).
  • No SMTP host, Production → the application throws at startup, so emails are never silently swallowed.

The confirmation and password-reset emails come in English or Greek (EmailTemplates). Each member's language is stored on the account (ApplicationUser.PreferredLanguage, en or el, default en): it is set at sign-up from the page language, saved again at every sign-in (the page language the member signs in from, so a switch made while signed out or a Greek browser is picked up), and can be changed on the Profile page (which switches the site to it only when the stored language changes). The anonymous forgot-password request does not save it, since anyone who knows the email could send it. The language switcher (/culture/set) changes only the site language cookie, never the stored language: it is a plain GET link that any site could send a member to. Emails the member asks for themselves use the request language (the pages' loopback API calls send it as Accept-Language); emails someone else triggers, such as an admin's forced password reset, use the member's stored language, so a Greek member gets the Greek email even when the admin reads the site in English.

Dev shortcuts for bypassing email confirmation:

Endpoint Purpose
POST api/auth/dev/confirm-email Confirm email without a link
POST api/auth/dev/reset-password Reset password without an email

⚠️ Both endpoints are wrapped in #if DEBUG and are not compiled into Release builds — they will 404 in production.


Exercise Library (public domain)

The app ships 873 exercises from Free Exercise DB, which is released under the Unlicense (a public-domain dedication). Its names, steps and photos may be reused and edited freely; we credit the project on the exercise pages anyway.

  • Data: each exercise has a slug, name, category, level, force, mechanic, equipment, primary and secondary muscles and numbered steps, in English and Greek. The English text is the upstream exercises.json, trimmed, with the instructions edited (typos fixed, British spelling, metric units), as described in its README; exercises.el.json adds the Greek name and steps. The full slug list is docs/assets/exercise-slugs.txt.
  • Photos: a start and an end photo per exercise, converted to 480 px WebP by tools/assets/ and served from /images/exercises/{slug}/0.webp and 1.webp. The UI loops between the two to show the movement; when the visitor asks for reduced motion it shows both photos side by side instead.
  • Pages: /exercises is the public library (search, muscle, level, type and equipment filters, pages); /exercises/{slug} shows the photos, muscles, steps and the workouts that use the exercise.
  • In workouts: a lesson lists its exercises in order with sets, reps, rest and a coaching note (EN/EL). Course JSON names them by slug, and admins pick them in the lesson editor; an unknown slug stops the course seed, is refused by the admin API (400, which also refuses an unpublished exercise, since members never see one) and fails SeedContentIntegrityTests.

Assets & Licences

Every image the app ships is public domain (Free Exercise DB, the Unlicense), CC0 (the Home hero photo from Wikimedia Commons) or our own work (logo, icons, social preview). docs/assets/ASSET-MANIFEST.md records the source, licence, where the licence is stated and the download date for each file family, quotes the licence text as it was on the download date, and lists the front-end code libraries (Bootstrap, Bootstrap Icons, Quill) and the seed data. AssetManifestTests fails when a shipped image has no licence row, when a row matches no file, or when a row names any other licence. Videos are deliberately not used: no CC0 exercise videos could be verified.

Content Categories

Admins classify courses and assessments with a shared pool of categories, and students browse/filter the public Courses and Assessments pages by them.

  • One category per item (nullable) drawn from a single shared pool used by both content types. Each category has bilingual names (NameEn required, NameEl optional, falling back to English).
  • Authoring: pick a category in the course/assessment editor, or create one inline while authoring. A dedicated /admin/categories page manages the pool (create, rename, delete) with usage counts.
  • Browsing: the public Courses and Assessments pages show a category chip on each card plus a filter bar of chips (with per-category counts) and a debounced search box. Both filters are applied server-side via the ?categoryId= and ?search= query params before pagination, so page totals stay correct.
  • Deleting a category soft-deletes it and nulls the reference on any course/assessment that used it — that content becomes "uncategorized" and stays visible, never hidden.

Learning Paths

Curated, ordered sequences of courses that guide a student from one topic to the next.

  • Authoring: an admin builds a path by appending published courses as ordered steps (reorderable and removable), then publishes it to the public catalog (api/admin/paths).
  • Per-user progress: for a signed-in user each step is projected as locked, next, or done from their course completions, so the path shows exactly where to resume; anonymous visitors see the steps without progress.
  • Bilingual: path titles and descriptions carry EN/EL like the rest of the content. The public catalog is api/paths.

Course Reviews

Enrolled students can leave a one-per-course rating and review, moderated before it appears publicly.

  • Write: an enrolled user posts or edits their review (POST api/courses/{courseId}/reviews); any edit resets it to Pending for re-moderation. Admins cannot review courses.
  • Read: the course page shows approved reviews, the aggregate star rating, and the caller's own review at whatever moderation status it's in (GET api/courses/{courseId}/reviews).
  • Moderate: admins work an approve/reject queue (api/admin/course-reviews); approving a review notifies its author (see Notifications).

Video Calls

One-to-one and group video calls, started from the existing chat. Self-hosted WebRTC (peer-to-peer mesh, up to 6 participants) with SignalR (/hubs/call) handling only signaling — media flows browser-to-browser and never touches the server.

  • Access: identical to chat — every authenticated user can make and receive calls, regardless of role or subscription tier.
  • Features: mic mute, camera toggle, screen share, and call events (started / missed / ended + duration) persisted into chat history. If no camera is available (none present, or held by another app/browser on the same machine), the call joins audio-only instead of failing.
  • "Ring anywhere": an incoming-call overlay pops up on any page via a single call-host component mounted in the layout. 1:1 calls start from the conversation header; group calls from a multi-select picker, and participants can be added mid-call.
  • Reliability: a background CallRingMonitor times out unanswered rings (45 s default), reaps disconnected participants after a short grace period, and sweeps dangling sessions left by a server restart. Ring timeout, max participants, and ICE/STUN servers are configurable under the WebRtc section of appsettings.json.

Browser permissions: the site's Permissions-Policy header allows camera, microphone, and display-capture for same-origin so calls work; JWT for the call hub is passed as the access_token query-string parameter, same as the chat hub.


Scheduled Sessions

Admin-scheduled live classes (e.g. a group mobility class or a coach Q&A) that users register for and join over the existing video-call stack.

  • Schedule: an admin creates a session with a start time and becomes its host (api/admin/sessions); it can be updated or cancelled.
  • Register: users see upcoming sessions and register or unregister (api/sessions). A background monitor sends registrants a reminder as the start time approaches (see Notifications).
  • Join: when a participant starts the call, the client links the live call session back to the schedule (POST api/sessions/{id}/link-call) so everyone joins the same room.

Presence & online status

Signed-in users show a live online/offline dot and a "last seen" time across the app — in the chat conversation sidebar, the chat and call user pickers, and the admin users list.

  • How it's tracked: presence rides on the SignalR call hub that every user's browser already holds open (established globally by the call-host component). PresenceService seeds from the hub's online-user snapshot and updates live on PresenceChanged events, so status stays current without polling and re-seeds automatically after a reconnect.
  • Last seen: each account persists a LastSeenAt timestamp (ApplicationUser.LastSeenAt); the UI renders it as a relative time ("last seen 5 min ago") and a live event overrides the stored value the moment a user goes offline.

Notifications

An in-app notification inbox with a bell badge and live push over SignalR (/hubs/notifications), available to every authenticated user.

  • What triggers one: a new chat message, a subscription activating or nearing expiry, an approaching scheduled-session reminder, a certificate being issued, and a course review being approved.
  • Inbox: a paged, sortable history (api/notifications) with an unread count for the badge, plus mark-one-read and mark-all-read. New notifications arrive live without a refresh, falling back to the stored history on reconnect.
  • Bilingual: titles and bodies are resource-keyed (EN/EL) and formatted with per-notification arguments, and each carries a deep link to the relevant page. Certificate, review and session-reminder notifications store both the English and the Greek title, so each language shows its own (older rows hold only the English title, which both languages then show).

AI Assistant

A grounded, bilingual (EN/EL) AI helper available to every authenticated user, regardless of subscription tier. It answers site questions from a background index of published courses (with every lesson in them, as students see them), assessments, and blog articles, and — as a tool-calling agent — can look up the signed-in user's own enrollments, lesson progress, assessment results and subscription, plus search and recommend courses and search the exercise library. Everything runs locally via Ollama — no cloud API, no per-token cost, no data leaves the machine.

Setup: the assistant needs Ollama running locally; everything after that is automatic (the app pulls the models itself). If the widget shows "The coach is unavailable right now. Please try again later." (the app can't reach Ollama), work through these three steps.

  1. Install Ollama — download from https://ollama.com/download, or on Windows:

    winget install Ollama.Ollama

    The installer starts Ollama and keeps it running in the background (look for its icon in the system tray).

  2. Confirm it's actually running — this is exactly what the "coach is unavailable" message checks. Open http://localhost:11434 in a browser (it should say "Ollama is running"), or run ollama list in a terminal. If nothing responds, start it: launch Ollama from the Start menu, or run ollama serve.

  3. Run the app:

    dotnet run --project src/FitEdu.Web

    On first start the app auto-pulls the two models it needs — qwen3:8b (chat, ≈ 5.2 GB) and bge-m3 (embeddings, ≈ 1.2 GB); ~6.4 GB total, one-time. The assistant widget shows live download progress. The app then loads the chat model into memory (the first load after an install or reboot can take about a minute) and goes live once it is loaded. After each answer Ollama keeps the model in memory for an hour (instead of its default 5 minutes), so later answers start quickly; this holds about 5.6 GB of GPU memory while the coach is in use.

You can install or start Ollama after the app is already running — it keeps probing and recovers on its own, no restart needed. To pull the models yourself instead (e.g. on a metered connection): ollama pull qwen3:8b && ollama pull bge-m3. If Ollama runs on a different machine or port, point the app at it with Assistant__BaseUrl (see the table below).

How it works: the assistant introduces itself as FitEdu Coach: it helps with courses, exercises and weekly planning, and sends anything medical to a doctor or physiotherapist. A background service chunks and embeds published content into an AssistantContentChunks table (incrementally — only re-embedding what changed); a lesson's text also lists its exercises and their primary muscles, so a question about a muscle finds the workouts that train it. Each question embeds the query, retrieves the top matching chunks by cosine similarity, and injects them plus the user's tier and enrolled course titles into a scoped system prompt (RAG grounding). For questions about the user's own data, the model calls server-side tools (get_my_enrollments, get_my_progress, get_my_assessment_results, get_subscription_status, search_courses, search_exercises (exercise library by name, primary muscle and equipment), recommend_courses); the authenticated identity is captured server-side — no tool accepts a user id, so prompt injection can never read another user's data, and raw assessment answers never reach the model. Replies stream to a floating chat widget over Server-Sent Events. Answers are kept short; one that still hits Assistant__MaxOutputTokens is cut back to its last full sentence. The widget sends only the current question and the two exchanges before it, and a "New chat" button clears the conversation. The widget appears bottom-right on every page for signed-in users; when Ollama is unreachable or a model is downloading, it shows a live state instead, and the rest of the app is unaffected.

Key Where Default Notes
Assistant__Enabled .env / appsettings.json true Master switch. When false, the API and widget gracefully report the assistant as unavailable. Test hosts pin it off.
Assistant__BaseUrl appsettings.json http://localhost:11434 Ollama's default local address.
Assistant__ChatModel appsettings.json qwen3:8b ~5.2 GB. Multilingual (incl. Greek) with native tool calling; needs a GPU with 8 GB of memory to answer quickly (slow on CPU only). On weaker hardware use qwen3:4b or qwen3:1.7b (fast, but its Greek is poor).
Assistant__EmbeddingModel appsettings.json bge-m3 ~1.2 GB. Multilingual (EN/EL) retrieval embeddings.
Assistant__AutoPullModels appsettings.json true Auto-download missing models at startup. Set false in restricted environments and pull manually.
Assistant__MaxContextChunks appsettings.json 3 Retrieved chunks injected per question (each shortened to 400 characters). More made the chat model lose track of the question.
Assistant__MaxOutputTokens appsettings.json 800 Caps answer length.
Assistant__Temperature appsettings.json 0.1 Lower = more deterministic/grounded answers. Kept just above 0: qwen3 can repeat itself at 0.
Assistant__PresencePenalty appsettings.json 1.5 Discourages repeated words; stops qwen3 looping on one sentence at a low temperature. Range 0–2.
Assistant__RequestsPerMinute appsettings.json 10 Per-user rate limit on POST api/assistant/chat.
Assistant__MaxToolRounds appsettings.json 3 Caps tool-invocation rounds per request (loop guardrail).

Hardware guidance: runs CPU-only on any ~2020+ 4-core machine with 8 GB RAM — no GPU required. Expect a few seconds to the first token (streaming masks this); the 1.7B default model is comfortably faster than the previous 4B one.

Live smoke test (optional, needs Ollama running): FITEDU_OLLAMA_LIVE=1 dotnet test --filter AssistantLiveSmoke unloads the chat model, boots the app against local Ollama, waits for Ready (so the cold-start path is covered), and asserts streamed answers to English and Greek questions; it prints the answers for a read-through. Without the variable the test self-passes, so CI never needs Ollama.


Troubleshooting

Problem Fix
DB connection fails sqllocaldb info MSSQLLocalDB. Verify connection string. If the instance is stopped: sqllocaldb start MSSQLLocalDB.
Database missing / dropped Just restart the app — MigrateAsync at startup recreates and reseeds it automatically.
Adding a new migration dotnet ef migrations add <Name> --project src/FitEdu.Infrastructure --startup-project src/FitEdu.Web — then restart; migrations apply on next run.
Seed data missing or out of date SeedData:Enabled = true in appsettings.Development.json. Seeders only fill empty tables, so to load changed seed files drop FitEduDb and restart (see docs/runbook.md).
Email link not found Search STUB EMAIL in Logs/log-<today>.txt or use dev endpoints.
Role-based page inaccessible Check AspNetUserRoles table. Admin pages require Admin role.
Chat not connecting JWT via access_token query string. Check token expiry (default 15 min) — use api/auth/refresh to rotate.
Video call has no camera/mic Grant the browser camera/microphone permission for the site, and use HTTPS (WebRTC requires a secure context). When testing with two browsers on one PC, only one can hold the physical webcam — the other joins audio-only (expected).
401 after login Match Jwt:Key/Issuer/Audience. Disabled accounts return X-User-Disabled: true.
HTTPS not trusted dotnet dev-certs https --trust
Assistant shows "The coach is unavailable right now. Please try again later." Ollama isn't reachable at http://localhost:11434. Install it (winget install Ollama.Ollama) and make sure it's running — open http://localhost:11434 (should say "Ollama is running") or run ollama list; if not, start it via the Ollama app or ollama serve. The app re-probes automatically and recovers without a restart. See the AI Assistant setup steps.
Assistant stuck "downloading its model" Normal on first run (~6.4 GB total). Progress shows in the widget and in Logs/. On a metered/blocked network set Assistant__AutoPullModels=false and pull manually: ollama pull qwen3:8b && ollama pull bge-m3.
Assistant's first answer is slow Expected — Ollama cold-loads the model into memory on first request after startup/idle. Subsequent answers are faster.

Security

Feature Details
Auth cookies HttpOnly, SameSite=Strict, Secure (non-dev), 24 h sliding window, 7-day MaxAge hard cap
JWT tokens HS256, 15-min expiry, security-stamp validated on every request, key ≥ 32 bytes enforced at startup
Refresh tokens SHA-256-hashed, single-use rotation; revoked token chain tracked
XSS prevention All rich-text inputs sanitised with Ganss.Xss (IHtmlSanitizer)
Rate limiting "auth" policy (global) on register / login / confirm-email / forgot-password / reset-password; "assistant" and "sensitive" policies (per-user) on the AI chat endpoint and on state-changing account actions (password change, avatar upload, checkout, assessment submission, account delete/export); "public" policy (per caller IP, 120 requests a minute) on the anonymous reads (exercises, landing, the course list/detail/reviews, categories, paths, blog, testimonials, search). The app's own loopback calls are exempt from "public", because every guest's Blazor page reaches the API through them; behind a reverse proxy set ForwardedHeaders:KnownProxies so each visitor counts as their own address
Anonymous reads Open without signing in: GET api/exercises/*, api/landing, api/courses, api/courses/{id}, api/courses/{id}/reviews, api/categories, api/paths/*, api/blog/*, api/testimonials, api/search, api/subscriptions/plans and api/certificates/verify/{id}. A guest gets no enrolment, progress or own review, and never lesson content; an unpublished programme shows no reviews or rating. The two that every guest page asks for, api/landing (one entry per language) and api/exercises/facets, are served from a 60-second in-memory cache, so a burst of visitors costs one set of queries a minute
Login return address /login?returnUrl= only follows a path on this site (starts with /, not // or /\); anything else goes to the home page, so the link cannot redirect to another site
HSTS Enabled in Production; skipped in Development
Security headers X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy (camera/microphone/display-capture allowed same-origin for video calls; geolocation denied)
File uploads Content-type allowlist enforced per upload type (image / PDF / video); extension allowlist on media serve
Sitemap Slugs XML-escaped via SecurityElement.Escape()
Account enumeration Login, forgot-password, reset-password all return generic messages; duplicate-email registration mapped to generic error
Sensitive data at rest Special-category assessment answers (AnswersJson/SummaryJson) encrypted at rest via ASP.NET Core Data Protection (transparent EF value converter); the Data Protection key ring is persisted to the database
GDPR posture Bilingual Privacy Policy (/privacy) and Terms (/terms) linked from the registration consent and the landing footer; registration requires explicit consent with captured timestamp; self-service account erasure and "download my data" export from the Profile page (the export includes the stored preferred language, which the Privacy Policy lists among the account data); expired refresh tokens purged on a background sweep

Logging

Daily rotating log files at src/FitEdu.Web/Logs/log-YYYY-MM-DD.txt. The Logs/ directory and all *.log files are gitignored.


License

Released under the MIT License — © 2026 s070s. Third-party dependencies keep their own licenses; note that QuestPDF is used under its Community tier, whose eligibility is conditional on the vendor's annual-revenue threshold (see CertificateService.cs). Shipped images are public domain (Free Exercise DB, the Unlicense), CC0 (the Home hero photo) or our own work; each is recorded in docs/assets/ASSET-MANIFEST.md, and AssetManifestTests fails on an image without a licence row. The same file lists the front-end code libraries (Bootstrap, Bootstrap Icons, Quill) and their licences.

About

Mobile-first bilingual (EN/EL) fitness courses app with a public-domain exercise library — .NET 10 Blazor Server

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages