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.
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.
# 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 belowDatabase 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
FitEduDbdatabase 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 setConnectionStrings__DefaultConnectionin.envif 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.envis 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.envfirst and falls back to the dev default inappsettings.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 = trueinappsettings.Development.json. Any other environment seeds it only whenSeedData:AllowOutsideDevelopment = trueis set as well (a public demo, see docs/DEPLOYMENT.md); a published app reads the JSON from its ownSeedJson/folder. The bulk student seeder runs in the background after startup. SetSeedData:BulkStudentCountin.envto control the count (default: 10). Each folder below is named by aSeedData:JsonPaths:<Folder>key inappsettings.Development.json(for exampleSeedData:JsonPaths:Exercises=../FitEdu.Shared/JSON/Exercises, resolved fromsrc/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.jsongives 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.csholds 4 fitness articles in English and Greek (progressive overload, protein, sleep, a five-minute warm-up) by "FitEdu", with covers underwwwroot/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:
SubscriptionPlanSeederwrites 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.
SeedContentIntegrityTestschecks 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-neutralreps, Greek imperatives, Greek titles in sentence case) and the resx files (EN, EL and Designer keys match; no untranslated Greek copies; aCourseReslabel 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, theInfrastructure/Seedingseeders such asSubscriptionPlanSeeder.cs, andDatabaseSeedingExtensions.cs) and out of the member-facing text written in C# (the assistant prompt inAssistantService.cs, the certificate text inCertificateService.cs, the email wording inEmailTemplates.cs, the Swagger request examples inOpenApiExtensions.cs).RealSeedJsonSeedingTestsruns the real seeders on these folders and checks every seeded text against its SQL Server column length, so a broken file failsdotnet testinstead of a fresh database.
Never commit
.env— it is already in.gitignore. Use.env.templateto document which keys are needed.
Known build issue (OpenApi pin): package versions are pinned centrally in
Directory.Packages.props, and committedpackages.lock.jsonfiles with CI--locked-modeguard against drift. If arestore/buildever fails with aMicrosoft.OpenApiversion conflict (some restore paths silently rewrite that pin), recover withgit checkout Directory.Packages.props(and any changed.csproj), then restore again.Known build issue (SDK-supplied lock entry):
src/FitEdu.Web/packages.lock.jsoncarries aDirectentry forMicrosoft.AspNetCore.App.Internal.Assetsthat 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-modethen 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-evaluateand commit the refreshed lock file. In CI it is now pinned instead:.github/workflows/tests.ymlinstalls the SDK named byDOTNET_SDK_PIN(10.0.403, which bundles ASP.NET Core 10.0.14, matching the committed lock file) and writes aglobal.jsonwithrollForward: disableso a newer SDK already on the runner cannot take over (that broke CI on 2026-10-30) rather than floating on10.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 when1513d66moved the lock to 10.0.12 while the pin still said 10.0.400. So after any--force-evaluaterefresh, bumpdotnet-versionto 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.
| 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) |
| 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 |
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 |
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.
Run the full suite locally:
dotnet test FitEdu.slnThe 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.
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 testtools/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.
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 reportsDegraded, 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.WebA 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).
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 theChatMessageDto/ChatNotificationDtopayload 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 atext/event-stream(Server-Sent Events) response; the document description explains theAssistantStreamEventframe 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.jsonendpoints are mapped only whenASPNETCORE_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.
| 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 |
| 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 |
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 |
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 |
| Method | Route | Description | Auth |
|---|---|---|---|
GET |
api/lessons/{lessonId}/asset?type=pdf|video |
Download lesson PDF or video (enrolled only) | Yes |
| 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 |
| 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 |
| 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 |
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 |
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 |
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 |
| 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 |
| 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 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) |
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) |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| Method | Route | Description | Auth |
|---|---|---|---|
GET |
api/admin/analytics/summary |
Dashboard summary | Admin |
| 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 |
| Method | Route | Description | Auth |
|---|---|---|---|
POST |
api/admin/assistant/reindex |
Re-index published content immediately instead of waiting for the next scheduled pass | Admin |
| 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 |
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(noTrustServerCertificate=True) AllowedHostsset to the production domainSelfBaseUrlset to the app's real bound address (startup throws otherwise)Email:Smtp:Host(+ credentials) configured soSmtpEmailServicesends real email (startup throws in Production when no SMTP host is set)Payment__WebhookSecretset if Stripe webhooks are used — the webhook endpoint returns 503 without it and never skips signature verification. (Payment:MockEnabledis 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-Forautomatically and trusts a loopback (same-host) proxy. For a proxy on a different host, list its IP(s) inForwardedHeaders: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) andLogging: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 hasdbcreatoror 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).
The email transport is selected at startup from configuration:
Email:Smtp:Hostconfigured →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 underEmail:Smtp:*(host, port, StartTLS, credentials, from address).- No SMTP host, Development →
StubEmailServicelogs all emails to file instead of sending them — find links inLogs/log-YYYY-MM-DD.txt(searchSTUB 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 DEBUGand are not compiled into Release builds — they will 404 in production.
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.jsonadds 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.webpand1.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:
/exercisesis 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.
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.
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 (
NameEnrequired,NameEloptional, falling back to English). - Authoring: pick a category in the course/assessment editor, or create one inline while authoring. A dedicated
/admin/categoriespage 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.
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.
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).
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
CallRingMonitortimes 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 theWebRtcsection ofappsettings.json.
Browser permissions: the site's
Permissions-Policyheader allowscamera,microphone, anddisplay-capturefor same-origin so calls work; JWT for the call hub is passed as theaccess_tokenquery-string parameter, same as the chat hub.
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.
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).
PresenceServiceseeds from the hub's online-user snapshot and updates live onPresenceChangedevents, so status stays current without polling and re-seeds automatically after a reconnect. - Last seen: each account persists a
LastSeenAttimestamp (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.
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).
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.
-
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).
-
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 listin a terminal. If nothing responds, start it: launch Ollama from the Start menu, or runollama serve. -
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) andbge-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.
| 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. |
| 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 |
Daily rotating log files at src/FitEdu.Web/Logs/log-YYYY-MM-DD.txt. The Logs/ directory and all *.log files are gitignored.
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.



