diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5fbca23a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/node_modules/ +**/dist +.git +npm-debug.log +.coverage +.coverage.* +.env \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..f81c6724 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = tab +tab_width = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 120 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index da5872eb..05b96755 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,10 +1,9 @@ --- name: Bug rapport about: Maak een bug rapport om fouten te signaleren -title: "`error message` of beschrijving" +title: '`error message` of beschrijving' labels: bug assignees: '' - --- **Beschrijf de bug** @@ -12,6 +11,7 @@ Een duidelijke, beknopte beschrijving van de bug. **Reproductie** Stappen om het gedrag te reproduceren: + 1. Ga naar '...' 2. Klik op '....' 3. Scroll naar beneden tot '....' @@ -24,6 +24,14 @@ Een duidelijke, beknopte beschrijving van wat je verwacht dat er gebeurt. Indien van toepassing, voeg een screenshot toe die het probleem duidelijk maakt. **Extra context** -Voeg extra context over het probleem toe. Was je ergens bijzonder mee bezig of naar op zoek? +Was je ergens bijzonder mee bezig of naar op zoek? Welke documentatie of links heb je (al) geraadpleegd? -- [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ... + diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index d06f41bc..dd1ed988 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -1,13 +1,12 @@ --- name: Feature aanvragen about: Stel een feature voor -title: "Korte beschrijving of naam" +title: 'Korte beschrijving of naam' labels: enhancement assignees: '' - --- -**Is your feature request related to a problem? Please describe.** +**Is jouw feature request gerelateerd tot een probleem? Beschrijf.** Een duidelijke, beknopte beschrijving van het probleem. Wat mist er? Wat kan beter? **Beschrijf de oplossing die je zou willen** @@ -16,4 +15,11 @@ Een duidelijke, beknopte beschrijving van wat je zou willen dat er gebeurt. **Extra context** Extra context of screenshots bij de feature. -- [ ] Ik heb aan deze issue het juiste label toegekend, afhankelijk van frontend, backend, ... + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..e9fb04c6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ + + +## Context + + + +## Screenshots + + + +## Aanvullende opmerkingen + + + + +- Fixes # + + diff --git a/.github/workflows/lint-action.yml b/.github/workflows/lint-action.yml new file mode 100644 index 00000000..e0f24ba9 --- /dev/null +++ b/.github/workflows/lint-action.yml @@ -0,0 +1,45 @@ +name: Lint + +on: + # Trigger the workflow on push or pull request, + # but only for the main branch + push: + branches: + - dev + # Replace pull_request with pull_request_target if you + # plan to use this action with forks, see the Limitations section + pull_request: + branches: + - dev + +# Down scope as necessary via https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token +permissions: + checks: write + contents: write + +jobs: + run-linters: + name: Run linters + runs-on: [self-hosted, Linux, X64] + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + # ESLint and Prettier must be in `package.json` + - name: Install Node.js dependencies + run: npm ci + + - name: Run linters + uses: rkuykendall/lint-action@master + with: + auto_fix: true + eslint: true + eslint_args: '--config eslint.config.ts' + prettier: true + commit_message: 'style: fix linting issues met ${linter}' diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d28e7d73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,740 @@ +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# package-lock.json +backend/package-lock.json + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# ---> Vue +# gitignore template for Vue.js projects +# +# Recommended template: Node.gitignore + +# TODO: where does this rule come from? +docs/_book + +# TODO: where does this rule come from? +test/ + +# ---> JetBrains +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# ---> VisualStudio +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +# ---> VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# ---> macOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# ---> Vim +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +# ---> Emacs +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile + +# directory configuration +.dir-locals.el + +# network security +/network-security.data + + diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..72394bfe --- /dev/null +++ b/.prettierignore @@ -0,0 +1,742 @@ +#Ignore .github files +**/.github/** + +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# package-lock.json +backend/package-lock.json + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# ---> Vue +# gitignore template for Vue.js projects +# +# Recommended template: Node.gitignore + +# TODO: where does this rule come from? +docs/_book + +# TODO: where does this rule come from? +test/ + +# ---> JetBrains +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# ---> VisualStudio +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +# ---> VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +# ---> macOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# ---> Vim +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +# ---> Emacs +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile + +# directory configuration +.dir-locals.el + +# network security +/network-security.data + diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..bf5fb037 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "Vue.volar", + "vitest.explorer", + "ms-playwright.playwright", + "dbaeumer.vscode-eslint", + "EditorConfig.EditorConfig", + "esbenp.prettier-vscode" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..21550adc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + "tsconfig.json": "tsconfig.*.json, env.d.ts", + "vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*", + "package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .prettier*, prettier*, .editorconfig" + }, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "editor.formatOnSave": false, + "editor.defaultFormatter": "esbenp.prettier-vscode" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100755 index 00000000..4890521b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,105 @@ +# Hoe bijdragen aan Dwengo-1? + +Bedankt dat je wil bijdragen aan Dwengo-1! +Hieronder vind je enkele richtlijnen om je op weg te helpen. + +Over het algemeen bestaat de workflow uit de volgende stappen: + +1. [Een issue aanmaken](#issues) +2. [Een branch maken](#workflow) +3. [Code schrijven](#coding-conventions) +4. [Werk committen](#commits) +5. [Een pull request maken](#pull-request) + +## Issues + +Als je een issue aanmaakt is het belangrijk om zo veel mogelijk (relevante) informatie te geven. +Om je op weg te helpen zijn er [templates](.github/ISSUE_TEMPLATE) voorzien. +Gebruik deze om alle nodige informatie te verzamelen. + +Gebruik de juiste [labels](https://github.com/SELab-2/Dwengo-1/labels) om te helpen een onderscheid te maken tussen verschillende categorieën issues. + +Ken jezelf toe aan een issue als je eraan werkt, zodat er beter een overzicht bewaard kan worden. +Op die manier vermijd je onnodig werk. + +## Workflow + +Dit project maakt gebruik van (een minder strenge versie van) [Gitflow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow). +Dat betekent dat verschillende branches een verschillende rol hebben. +Nieuwe branches worden aangemaakt vanuit `dev` en worden gemerged naar `dev`. + +Een overzicht: + +- `main`: Hier worden enkel de releases gemerged. Elke merge naar `main` moet een release zijn, aangeduid met een tag (`v1.2.3`). +- `dev`: Jouw branch hoort hiervan af te takken. + - `feat/my-feat`: Voor features die uit geen of meer dan 1 issue bestaan + - `feat/this-#x`: Voor features die aan een issue gelinkt kunnen worden + - `fix/something-#x`: Voor (minder dringende) bug fixes. Bug fixes worden aan een issue gelinkt. +- `release/x.y.z`: Voorbereidingen voor een release. Hier worden enkel bug fixes en hotfixes gemerged. + +Lees [hier](https://github.com/SELab-2/Dwengo-1/wiki/Developmentstrategie-keuzes#gitflow) meer over de beslissing om Gitflow te gebruiken. + +We hebben ervoor gekozen om `main` en `dev` te beschermen. +Zie ook [pull request](#pull-request). + +## Coding conventions + +Om de code consistent te houden, maken dit project gebruik van enkele tools: + +- Formatting: [Prettier](https://prettier.io/), zorgt ervoor dat de code consistent geformatteerd is. +- Linting: [ESLint](https://typescript-eslint.io/), zorgt er o.a. voor dat de code geen "slechte" constructies bevat. + +Je kan ze handmatig uitvoeren met `npm run lint` en `npm run format`. + +Deze tools worden niet standaard automatisch uitgevoerd bij een commit. +Automatisch uitvoeren bij een commit kan met [git hooks](https://git-scm.com/docs/githooks). + +## Commits + +**Conventionele commits** + +Dit project maakt gebruik van [conventional commits](https://www.conventionalcommits.org/). + +Dit betekent dat elke commit een duidelijke boodschap moet hebben, die volgens een bepaald formaat is opgesteld. +In het kort ziet dat er zo uit: + +``` +(): + +type options: + feat, fix, refactor, test, docs, build, ci, chore, ... +``` + +Lees [hier](https://github.com/SELab-2/Dwengo-1/wiki/Developmentstrategie-keuzes#conventionele-commits) meer over de beslissing om conventionele commits te gebruiken. + +**Andere tips** + +Als je een commit 'fixt', gebruik dan [`git commit --fixup`](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---fixupamendrewordltcommitgt) + +Als je een commit niet alleen hebt geschreven, maak dan een [commit met meerdere auteurs](https://docs.github.com/en/pull-requests/committing-changes-to-your-project/creating-and-editing-commits/creating-a-commit-with-multiple-authors). + +## Pull request + +Eens je code hebt geschreven en gecommit, is het tijd om een pull request te maken. +Het is fijn als je meteen ([draft](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests)) pull requests maakt, zodat anderen kunnen meekijken en feedback kunnen geven. + +Om je op weg te helpen is er een [template](.github/PULL_REQUEST_TEMPLATE.md) voorzien. +Door deze in te vullen, zorg je ervoor dat de reviewer een duidelijk beeld heeft van wat je hebt gedaan. + +Als je aan visuele features werkt, voeg dan een screenshot van de omgeving van de feature toe, voor en na dat de feature geïmplementeerd werd. + +**Branch protection** + +Je zult merken dat sommige branches [beschermd](https://docs.github.com/en/github/administering-a-repository/about-protected-branches) zijn. +Dit betekent dat je niet zomaar kan mergen naar deze branches: + +- `main`: kan enkel vanuit `release/x.y.z` +- `dev`: wordt nagekeken alvorens te mergen + +Elders kan je vrij mergen. + +Het zou kunnen dat je code bepaalde checks moet doorstaan alvorens te mergen. +Dit kan gaan van een simpele lint check tot een volledige test suite die moet slagen. +Tag gerust een maintainer als je denkt dat je code klaar is om gemerged te worden. + +## Dankjewel! diff --git a/README.md b/README.md index 34664b7e..dc09bbfc 100644 --- a/README.md +++ b/README.md @@ -1 +1,87 @@ -# Dwengo-1 \ No newline at end of file +

Dwengo-1

+ +

+ +OneDrive + +Figma + +Projectopgave +

+ +Dit is de monorepo voor [Dwengo-1](https://sel2-1.ugent.be), een interactief leerplatform waar leerkrachten opdrachten +en lessen kunnen samenstellen hun leerlingen en hun vooruitgang kunnen opvolgen. + +## Installatie + +Om de applicatie in te stellen voor een productieomgeving, volg +de [installatiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving). + +Alternatief kan je één van de volgende methodes gebruiken om de applicatie lokaal te draaien. + +### Quick start + +1. Installeer Docker en Docker Compose op je systeem (zie [Docker](https://docs.docker.com/get-docker/) + en [Docker Compose](https://docs.docker.com/compose/)). +2. Clone deze repository. +3. In de backend, kopieer `.env.example` (of `.env.development.example`) naar `.env` en pas de variabelen aan waar + nodig. +4. Voer `docker compose up` uit in de root van de repository. +5. Optioneel: Configureer de applicatie aan de hand van + de [configuratiehandleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#dwengo-1-configuratie). + +```bash +docker compose version +git clone https://github.com/SELab-2/Dwengo-1.git +cd Dwengo-1/backend +cp .env.example .env +# Pas .env aan +nano .env +cd .. +docker compose up +# Configureer de applicatie +``` + +### Handmatige installatie + +Zie de submappen voor de installatie-instructies van de [frontend](./frontend/README.md) +en [backend](./backend/README.md). + +## Architectuur + +![Architectuur](./docs/architecture/schema.png) + +De tech-stack bestaat uit: + +- **Frontend**: TypeScript + Vue.js + Vuetify +- **Backend**: TypeScript + Node.js + Express.js + TypeORM + PostgreSQL +- **Identity provider**: Keycloak + +Voor meer informatie over de keuze van deze tech-stack, +zie [designkeuzes](https://github.com/SELab-2/Dwengo-1/wiki/Developer:-Design-keuzes). + +## Testen + +Voer volgende commando's uit om de te testen: + +``` +npm run test:unit +``` + +## Bijdragen aan Dwengo-1 + +Zie [CONTRIBUTING.md](./CONTRIBUTING.md) voor meer informatie over hoe je kan bijdragen aan Dwengo-1. + +Deze rocksterren hebben bijgedragen aan Dwengo-1: + +| Naam | Functie | +| ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| [
Adriaan Jacquet](https://github.com/WhisperinCheetah) | Backend Lead | +| [
Francisco Gabriel Van Langenhove](https://github.com/Gabriellvl) | Team Lead | +| [
Gerald Schmittinger](https://github.com/geraldschmittinger) | Database Administrator | +| [
Joyelle Ndagijimana](https://github.com/joyelle436) | Frontend Lead | +| [
Laure Jablonski](https://github.com/laurejablonski) | Documentatie- en Test Lead | +| [
Tibo De Peuter](https://github.com/tdpeuter) | Technische Lead | +| [
Timo De Meyst](https://github.com/kloep1) | System Administrator | + +En in de toekomst misschien jij ook? diff --git a/assets/img/dwengo-groen-zwart.png b/assets/img/dwengo-groen-zwart.png new file mode 100644 index 00000000..9d83c92f Binary files /dev/null and b/assets/img/dwengo-groen-zwart.png differ diff --git a/assets/img/dwengo-groen-zwart.svg b/assets/img/dwengo-groen-zwart.svg new file mode 100644 index 00000000..0da7a37d --- /dev/null +++ b/assets/img/dwengo-groen-zwart.svg @@ -0,0 +1,61 @@ + +image/svg+xml diff --git a/assets/img/keycloak.png b/assets/img/keycloak.png new file mode 100644 index 00000000..6a79a7a2 Binary files /dev/null and b/assets/img/keycloak.png differ diff --git a/backend/.env.development.example b/backend/.env.development.example new file mode 100644 index 00000000..466e1b7b --- /dev/null +++ b/backend/.env.development.example @@ -0,0 +1,28 @@ +# +# Basic configuration +# + +DWENGO_PORT=3000 # The port the backend will listen on +DWENGO_DB_HOST=localhost +DWENGO_DB_PORT=5431 +DWENGO_DB_USERNAME=postgres +DWENGO_DB_PASSWORD=postgres +DWENGO_DB_UPDATE=true + +# Auth + +DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student +DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs +DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher +DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo +DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs + +# Allow Vite dev-server to access the backend (for testing purposes). Don't forget to remove this in production! +DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173 + +# +# Advanced configuration +# + +# LOKI_HOST=http://localhost:9001 # The address of the Loki instance, used for logging diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..68cef35d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,27 @@ +# +# Basic configuration +# + +DWENGO_PORT=3000 # The port the backend will listen on +DWENGO_DB_HOST=domain-or-ip-of-database +DWENGO_DB_PORT=5431 + +# Change this to the actual credentials of the user Dwengo should use in the backend +DWENGO_DB_USERNAME=postgres +DWENGO_DB_PASSWORD=postgres + +# Set this to true when the database scheme needs to be updated. In that case, take a backup first. +DWENGO_DB_UPDATE=false + +# Data for the identity provider via which the students authenticate. +DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student +DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs + +# Data for the identity provider via which the teachers authenticate. +DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher +DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo +DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs + +# The address of the Lokiinstance, used for logging +# LOKI_HOST=http://localhost:3102 diff --git a/backend/.env.production.example b/backend/.env.production.example new file mode 100644 index 00000000..390409d1 --- /dev/null +++ b/backend/.env.production.example @@ -0,0 +1,28 @@ +DWENGO_PORT=3000 # The port the backend will listen on +DWENGO_DB_HOST=db # Name of the database container +DWENGO_DB_PORT=5431 + +# Change this to the actual credentials of the user Dwengo should use in the backend +DWENGO_DB_NAME=postgres +DWENGO_DB_USERNAME=postgres +DWENGO_DB_PASSWORD=postgres + +# Set this to true when the database scheme needs to be updated. In that case, take a backup first. +DWENGO_DB_UPDATE=false + +# Data for the identity provider via which the students authenticate. +DWENGO_AUTH_STUDENT_URL=https://sel2-1.ugent.be/idp/realms/student +DWENGO_AUTH_STUDENT_CLIENT_ID=dwengo +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://idp:7080/idp/realms/student/protocol/openid-connect/certs # Name of the idp container +# Data for the identity provider via which the teachers authenticate. +DWENGO_AUTH_TEACHER_URL=https://sel2-1.ugent.be/idp/realms/teacher +DWENGO_AUTH_TEACHER_CLIENT_ID=dwengo +DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://idp:7080/idp/realms/teacher/protocol/openid-connect/certs # Name of the idp container + +# +# Advanced configuration +# + +# Logging and monitoring + +# LOKI_HOST=http://logging:3102 # The address of the Loki instance, used for logging diff --git a/backend/.env.test.example b/backend/.env.test.example new file mode 100644 index 00000000..b8a81003 --- /dev/null +++ b/backend/.env.test.example @@ -0,0 +1,3 @@ +PORT=3000 +DWENGO_DB_UPDATE=true +DWENGO_DB_NAME=":memory:" diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..2d50437c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,42 @@ +FROM node:22 AS build-stage + +WORKDIR /app + +# Install dependencies + +COPY package*.json ./ +COPY backend/package.json ./backend/ + +RUN npm install --silent + +# Build the backend + +# Root tsconfig.json +COPY tsconfig.json ./ + +WORKDIR /app/docs + +COPY docs ./ + +RUN npm run swagger + +WORKDIR /app/backend + +COPY backend ./ + +RUN npm run build + +FROM node:22 AS production-stage + +WORKDIR /app + +COPY package-lock.json backend/package.json ./ + +RUN npm install --silent --only=production + +COPY --from=build-stage /app/backend/dist ./dist/ +COPY --from=build-stage /app/docs/api ./docs/swagger + +EXPOSE 3000 + +CMD ["node", "--env-file=.env", "dist/app.js"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..442cea82 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,37 @@ +# dwengo-1-backend + +## Project setup + +```shell +npm install +``` + +Setup the environment variables in a `.env` file in the root of the project. You can use the `.env.example` file as a template. + +### Development + +```shell +npm run dev +``` + +### Production + +```shell +npm run build +npm run start +``` + +### Tests + +Voer volgend commando uit om de unit tests uit te voeren: + +``` +npm run test:unit +``` + +## Keycloak configuratie + +Tijdens development is het voldoende om gebruik te maken van de keycloak configuratie die automatisch ingeladen wordt. + +Voor productie is het ten sterkste aangeraden om keycloak manueel te configureren. +Voor meer informatie, zie de [administrator-handleiding](https://github.com/SELab-2/Dwengo-1/wiki/Administrator:-Productie-omgeving#installatie-en-server-configuratie). diff --git a/backend/_i18n/de.yml b/backend/_i18n/de.yml new file mode 100644 index 00000000..f38c16e8 --- /dev/null +++ b/backend/_i18n/de.yml @@ -0,0 +1,343 @@ +# translate theme pages + +strengths: + title: Unsere Stärken + innovative: Innovativ + research_based: Forschungsbasiert + inclusive: Inclusiv + socially_relevant: Gesellschaftlich relevant + innovative_text: Wir fügen ständig neue Projekte hinzu und gebrauchen neue Methoden für alle unsere Projekte. + research_based_text: Alle Lernpakete basieren auf fundierter wissenschaftlicher Forschung. + inclusive_text: Wir konzentrieren uns darauf, alle Kinder zu erreichen, mit besonderem Augenmerk auf die Geschlechterinklusion und die soziale Inklusion. + socially_relevant_text: Wir suchen Projekte, die zu aktuellen Ereignissen und gesellschaftlichen Themen passen. + summary: We develop innovative workshops and educational resources, and we provide them to students around the globe in collaboration with teachers and volunteers. Our train-the-trainer sessions enable them to bring our hands-on workshops to the students. + main: Wir fügen kontinuierlich neue Projekte und Methoden zu all unseren Projekten hinzu. Für diese Projekte suchen wir immer nach einem gesellschaftlich relevanten Thema. Darüber hinaus stellen wir sicher, dass unser didaktisches Material auf wissenschaftlicher Forschung basiert, und wir achten immer auf Inklusivität. + quote: + text: Du machst etwas Praktisches, du lernst mit Hardware zu arbeiten und du kannst etwas Neues schaffen. + name: Matthias und Bruno + affiliation: 4. Jahr der weiterführenden Schule + +curricula_page: + title: Unsere Unterrichtsthemen + read_more: Lees meer + curricula_files: Bestanden + algorithms: + title: Algorithmen + sub_title: Algorithmen + description: 'Schüler der zweiten und dritten Klasse (Sekundarstufe) lernen, wie sie Algorithmen verwenden können, um Probleme zu lösen. Sie lernen, wie sie Algorithmen entwerfen und die Effizienz von Algorithmen analysieren können. Sie lernen auch, wie sie Algorithmen zur Problemlösung einsetzen können.' + contact: '' + teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y + basics_ai: + title: Basisprincipes van AI + sub_title: Basisprincipes van AI + description: 'Onder dit lesthema bundelen we verschillende activiteiten waarin de basisprincipes van artificiële intelligentie (AI) aan bod komen. Leerlingen leren wat AI is, hoe het werkt en hoe het kan worden toegepast in verschillende domeinen.' + contact: '' + kiks: + title: KI und Klima + sub_title: KIKS + description: 'Schülerinnen und Schüler der dritten Klasse (SO) erforschen, wie Pflanzen sich über ihre Spaltöffnungen an den Klimawandel anpassen. Dazu zählen sie diese Spaltöffnungen mit künstlicher Intelligenz und Bilderkennung.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + curricula_files: + - file_title: 'Projektblatt KIKS' + file_info: 'Dies ist ein kurzer Überblick über das KIKS-Projekt mit Projektstruktur und -merkmalen.' + file_location: '/assets/files/kiks/projectfiche_kiks.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Die Anleitung - auch in gedruckter Version erhältlich' + file_info: 'Wir möchten den Lehrern Hintergrundwissen über den Inhalt dieses Projekts vermitteln: den Klimawandel, die Biologie der Spaltöffnungen und die Art und Weise, wie Pflanzen sich über die Spaltöffnungen an den Klimawandel anpassen, die wissenschaftliche Forschung der UGent und des Plantentuin Meise, die Bürgerwissenschaft, was künstliche Intelligenz (KI) ist, die Geschichte von KI, ihre Anwendung und Ethik, die Prinzipien digitaler Bilder, die Mathematik hinter den Algorithmen und die Grundlagen der derzeit am häufigsten verwendeten KI-Techniken. Wir zeigen auch, wie wir mit KIKS im Unterricht gearbeitet haben.' + file_location: '/assets/files/kiks/KIKS_handleiding_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Ein Schülerkurs' + file_info: 'Mit dem Schülerkurs geben wir ein Beispiel für einen möglichen, umfassenden Weg, den ein Lehrer mit den Schülern gehen kann. Der Weg umfasst den Klimawandel, die Biologie der Spaltöffnungen mit einer Mikroskopie-Aufgabe, die Art und Weise, wie Pflanzen sich über die Spaltöffnungen an den Klimawandel anpassen, die wissenschaftliche Forschung der UGent und des Plantentuin Meise, das Sammeln von Daten zur Schulung eines neuronalen Netzwerks, was künstliche Intelligenz (KI) ist, die Geschichte von KI, ihre Anwendung und Ethik, die Prinzipien digitaler Bilder, das Arbeiten mit Convolutional Neural Networks, die Mathematik hinter dem Perceptron-Algorithmus, das lineare und nicht-lineare Klassifizieren von Daten und die Grundlagen des maschinellen Lernens.' + file_location: '/assets/files/kiks/KIKS_leerlingencursus_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Die Lehrziele' + file_info: 'Im Rahmen des KIKS-Projekts können viele Lehrziele erreicht werden. Der Lehrer entscheidet selbst, welche Lehrziele mit dem Projekt in Verbindung gebracht werden. Darüber hinaus bietet das Projekt viele Möglichkeiten, die Schüler aktiv und selbstständig lernen zu lassen und ICT-Kenntnisse zu vermitteln. KIKS kann auch verwendet werden, um eine Forschungsaufgabe zu entwickeln. In den Endzielen und Lehrplänen der verschiedenen Verbände finden sich viele Lehrziele, die KIKS mit Biologie, Geografie und Mathematik verknüpfen.' + file_location: '/assets/files/kiks/Leerdoelen-KIKS.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Das Erstellen eines Nagellaktabdrucks eines Blattteils' + file_info: 'Um die Anzahl der Spaltöffnungen auf einem Teil eines Blattes einer Pflanze zu kennen, betrachten wir die Blattoberfläche unter dem Mikroskop. Wir können dazu einen Teil der dünnen Cuticula des Blattes entfernen, aber bei einigen Pflanzen gelingt das nicht so gut, zum Beispiel aufgrund der Steifheit des Blattes. Dies kann jedoch aufgefangen werden, indem die gleiche Methode verwendet wird wie die Forscher des Plantentuin Meise, nämlich einen Abdruck eines Teils der Blattoberfläche mit transparentem Nagellack zu machen. Das mikroskopische Bild kann mit einem Smartphone fotografiert werden.' + file_location: 'https://vimeo.com/467062270' + file_icon_name: 'play_arrow' + link_name: 'Ansehen' + socialrobot: + title: Sozialer Roboter + sub_title: Robotik im Klassenzimmer + description: 'Während Schülerinnen und Schüler der ersten Klasse (SO) einen sozialen Roboter basteln und programmieren, lernen sie komplexe Probleme durch computational thinking zu lösen. Auf spielerische Weise arbeiten sie an den neuen Endzielen für digitale Kompetenzen.' + contact: Fragen? Kontaktieren Sie uns unter team@aiopschool.be. Die Presse kann sich an Francis Wyffels wenden unter Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Projektblatt Sozialer Roboter' + file_info: "Dies ist ein kurzer Überblick über das Projekt 'Sozialer Roboter' mit Projektstruktur und -merkmalen." + file_location: '/assets/files/socialrobot/projectfiche_socialerobot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Collage sozialer Roboter' + file_info: 'Die Roboter auf diesem Foto sind Arbeiten von Schülern der ersten Klasse der Sekundarstufe oder Prototypen.' + file_location: '/assets/files/socialrobot/collage.png' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Bausatz für das 'Sozialer Roboter'-Projekt" + file_info: 'Möchten Sie, dass Ihre Schülerinnen und Schüler auch einen sozialen Roboter entwerfen und bauen? Das geht mit dem Bausatz von Dwengo vzw. Auf der Abbildung sehen Sie die aktuelle Zusammensetzung des Bausatzes. Interessiert? Kontaktieren Sie uns per E-Mail oder finden Sie weitere Informationen auf dieser Projektseite.' + file_location: '/assets/files/socialrobot/constructiekit_socialerobot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Vom Projekt zu den Endzielen' + file_info: "Die Endziele, an denen gearbeitet werden kann, werden mit den verschiedenen Phasen des Projekts 'Sozialer Roboter' verknüpft." + file_location: '/assets/files/socialrobot/EindtermenAStroomSsocialeRobot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotionsmaschine (unvollständig) - Computational Thinking (unplugged Aktivität)' + file_info: 'Unplugged Aktivität' + file_location: '/assets/files/socialrobot/emotiemachine_gewoon_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotionsmaschine (Aufgabe) - Computational Thinking (unplugged Aktivität)' + file_info: 'Unplugged Aktivität' + file_location: '/assets/files/socialrobot/emotiemachine_gids.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotionsmaschine (vollständig) - Computational Thinking (unplugged Aktivität)' + file_info: 'Unplugged Aktivität' + file_location: '/assets/files/socialrobot/emotiemachine_ingevuld_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotionsmaschine mit LED-Matrix - Computational Thinking (unplugged Aktivität)' + file_info: 'Unplugged Aktivität' + file_location: '/assets/files/socialrobot/emotiemachine_matrices_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Lehrerhandbuch' + file_info: 'Die gebündelten Handbücher, in denen die Verwendung des Dwenguino und der Sensoren und Aktuatoren erklärt wird.' + file_location: '/assets/files/socialrobot/ficheboekje_lkn.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Handbücher für Schülerinnen und Schüler' + file_info: 'In diesen Handbüchern wird die Verwendung des Dwenguino und der Sensoren und Aktuatoren erklärt.' + file_location: '/assets/files/socialrobot/fichesSocialeRobot_lln.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Erstelle ein Gesicht - Computational Thinking (unplugged Aktivität)' + file_info: 'Unplugged Aktivität' + file_location: '/assets/files/socialrobot/maakeengezicht_activiteit.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Aufgaben der Übungen im MOOC' + file_info: 'Eine Übersicht über die Programmieraufgaben im MOOC.' + file_location: '/assets/files/socialrobot/Opgaven_MOOC.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Poster 'Sozialer Roboter'-Projekt" + file_info: 'Das Poster zeigt die verschiedenen Aspekte des Projekts.' + file_location: '/assets/files/socialrobot/posterSocialeRobot_nl_Qo4ANmV.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Handbuch 'Hallo Roboter!'" + file_info: 'Mit diesem Lehrbuch bringen Sie Ihren eigenen sozialen Roboter zum Leben.' + file_location: '/assets/files/socialrobot/handleiding_hallo_robot.pdf' + file_icon_name: 'description' + link_name: 'Download' + chatbot: + title: Sprachtechnologie in der Schule + sub_title: Mit einem Chatbot arbeiten + description: 'Wo Sprache und Technologie aufeinandertreffen, entsteht das Gebiet des Natural Language Processing. Kann ein Computer Texte verstehen, übersetzen oder sogar schreiben? Kann ein Computer Emotionen erkennen? Schülerinnen und Schüler der zweiten und dritten Klasse (SO) lernen in diesem Paket alles darüber.' + contact: Fragen? Kontaktieren Sie uns unter team@aiopschool.be. Die Presse kann sich an Francis Wyffels wenden unter Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Projektblatt Chatbot' + file_info: 'Dies ist ein kurzer Überblick über das Chatbot-Projekt mit Projektstruktur und -merkmalen.' + file_location: '/assets/files/chatbot/projectfiche_chatbot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'BrAInfood Chatbots' + file_info: 'In diesem brAInfood - für Jugendliche konzipiert - gibt das Zentrum für Daten & Gesellschaft weitere Informationen zu Chatbots. Das brAInfood enthält eine fiktive Geschichte über Lotte, die mit einem Chatbot spricht und vermutlich Informationen über sie an Unternehmen weitergibt. Es werden auch einige Punkte zu Chatbots erläutert sowie einige Tipps für Jugendliche, um ihre (personenbezogenen) Daten besser zu schützen. Auf diese Weise möchten wir Jugendliche für die Funktionsweise von Chatbots sensibilisieren und sie dazu anregen, über die gesammelten Daten nachzudenken.' + file_location: '/assets/files/chatbot/Brainfood13_Chatbots_NL.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'BrAInfood Personalisierte Nachrichten' + file_info: 'In diesem brAInfood des Zentrums für Daten & Gesellschaft werden Tipps gegeben, wie Sie die Kontrolle über Ihren Nachrichten-Feed behalten können.' + file_location: '/assets/files/chatbot/brainfoodaanbevelingnieuws.jpg' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Handbuch 'Chatbot' - Auch in gedruckter Form erhältlich" + file_info: "Lehrkräfte erhalten über dieses Handbuch ausreichend Hintergrundinformationen, um mit (einem Teil des) Projekts 'Chatbot' im Klassenzimmer zu arbeiten. Das Buch behandelt verschiedene Aspekte der Sprachtechnologie, wie die Geschichte der künstlichen Intelligenz, ethische Aspekte, Sentimentanalyse und Cybermobbingerkennung, Chatbots, sprechende digitale Assistenten und Autorenerkennung. Es wird auch auf die STEM-Endziele sowie die Endziele zu digitaler Kompetenz und Medienkompetenz eingegangen." + file_location: '/assets/files/chatbot/Chatbot_handleiding_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Improbotics - Lehrermappe - Lehrerfassung' + file_info: 'In der Theateraufführung Improbotics improvisiert ein sozialer Roboter in den Szenen mit. In der Mappe für Lehrkräfte finden Sie Informationen zu den verwendeten Technologien.' + file_location: '/assets/files/chatbot/Improbotics_lesmap_Leerkracht.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Improbotics - Lehrermappe - Schülerinnen und Schüler' + file_info: 'In der Theateraufführung Improbotics improvisiert ein sozialer Roboter in den Szenen mit. In der Mappe finden Sie Informationen zu den verwendeten Technologien.' + file_location: '/assets/files/chatbot/Improbotics_lesmap_Leerling.pdf' + file_icon_name: 'description' + link_name: 'Download' + care: + title: KI im Gesundheitswesen + sub_title: KI-Systeme, die im Gesundheitswesen helfen + description: 'Krankenhäuser machen bereits heute Gebrauch von künstlicher Intelligenz. Schülerinnen und Schüler der zweiten und dritten Klasse (SO) entdecken, welche Systeme existieren und wie sie Ärzte bei Entscheidungen unterstützen. Auf diese Weise lernen die Schülerinnen und Schüler die Prinzipien des Entscheidungsbaums, einer weit verbreiteten Technik im maschinellen Lernen.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + curricula_files: + - file_title: 'Projektblatt KI im Gesundheitswesen' + file_info: 'Dies ist ein kurzer Überblick über das Projekt KI im Gesundheitswesen mit Projektstruktur und -merkmalen.' + file_location: '/assets/files/care/projectfiche_aiindezorg.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Sprouts' + file_info: "Ein Spiel zur Einführung in 'Graphen'." + file_location: '/assets/files/care/Sprouts.mov' + file_icon_name: 'play_arrow' + link_name: 'Download' + - file_title: 'Erklärung zu Übungen mit Graphen aus dem Schülerkurs' + file_info: 'Welche Figuren repräsentieren denselben Graphen? Eine formalere Methode.' + file_location: '/assets/files/care/dezelfdegraafFormeel.mov' + file_icon_name: 'play_arrow' + link_name: 'Download' + - file_title: 'Erklärung zu Übungen mit Graphen aus dem Schülerkurs' + file_info: 'Welche Figuren repräsentieren denselben Graphen? Eine Methode mit Farben.' + file_location: '/assets/files/care/dezelfdeGraaf.mov' + file_icon_name: 'play_arrow' + link_name: 'Download' + - file_title: 'Der Schülerkurs - Ziel Durchfluss' + file_info: 'Schülerkurs' + file_location: '/assets/files/care/AIindeZorg_doorstroom_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Kartenset - Auch in gedruckter Form erhältlich' + file_info: 'Mit diesem Kartenset können Lehrer die Schüler über ethische Aspekte neuer Technologien nachdenken lassen. Wie steht es um die Privatsphäre? Werden soziale Kontakte beeinträchtigt? Welche Technologien werden begeistert aufgenommen? Was ist nicht wünschenswert? Sind die neuen Technologien für alle erschwinglich?' + file_location: '/assets/files/care/Kaartset_AIIndeZorg_AIOpSchool_Dwengo.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Anleitung für das Kartenset' + file_info: 'Diese Anleitung bietet zusätzliche Erläuterungen zum Kartenset.' + file_location: '/assets/files/care/AIIndeZorgKaartenset_UitlegVoorLeerkracht.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Entscheidungsbaum 'mBrAIn'-Projekt in nicht kompakter Form" + file_info: "Im Forschungsprojekt 'mBrain', das die Entwicklung einer App zum Vorhersagen eines Migräneanfalls zum Ziel hat, wurde ein Entscheidungsbaum erstellt. Mit diesem Entscheidungsbaum wird ein Problem der binären Klassifikation angegangen: Es gibt nur zwei Klassen: 'Migräne' und 'Keine Migräne'. (Quellen: Femke Ongenae. (2021), UGent; Van Hoecke, S., Ongenae, F., Paemeleire, K., & Vandenbussche, N. (2020). App muss Kopfschmerzen vorhersagen. EOS Wissenschaftsspezial, Technologie und Gesundheit, 25.) Wir haben die Form dieses Entscheidungsbaums in einen binären Entscheidungsbaum umgewandelt, um zu zeigen, dass diese Form nicht immer benutzerfreundlich ist. Weitere Informationen finden Sie im vierten Kapitel des Schülerkurses dieses Projekts." + file_location: '/assets/files/care/MBrainBeslissingsboom.png' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Suche nach Sprache - Computerdenken (unplugged Aktivität)' + file_info: 'Das Locked-In-Syndrom ist eine der schlimmsten medizinischen Erkrankungen. Man ist vollständig gelähmt, außer dass man vielleicht noch mit einem Auge blinzeln kann. Der intelligente Geist ist in einem nutzlosen Körper eingesperrt: Man kann alles fühlen, aber nicht kommunizieren. Es kann jedem passieren, aus dem Nichts, als Folge eines Schlaganfalls. Wenn Sie Menschen mit dem Locked-In-Syndrom helfen möchten, ist es besser, Arzt oder Krankenschwester zu werden? Oder kann man als Informatiker auch helfen?' + file_location: '/assets/files/care/ZoektochtNaarSpraak.pdf' + file_icon_name: 'description' + link_name: 'Download' + stem: + title: Python in MINT + sub_title: Datavisualisierungen mit Python + description: 'In diesem Paket lernen Sie, komplexe Probleme einfacher und schneller mit der Programmiersprache Python zu lösen. Programmieren kann nämlich eine verbindende Rolle zwischen Wissenschaft, Technik, Design und angewandter Mathematik spielen. Kurz gesagt, dank Python holen wir das Beste aus MINT heraus.' + contact: Fragen? Kontaktieren Sie uns unter team@aiopschool.be. Die Presse kann sich an Francis Wyffels unter Francis@dwengo.org wenden. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + agriculture: + title: KI in der Landwirtschaft + sub_title: KI in der Landwirtschaft + description: 'Verfaulte Tomaten während der Ernte entfernen? Künstliche Intelligenz kann dabei helfen. Aber wie? Schülerinnen und Schüler der zweiten und dritten Klasse (SO) setzen KI ein. Vielleicht kann ein vibrierendes Laufband das System noch verbessern?' + contact: Fragen? Kontaktieren Sie uns unter team@aiopschool.be. Die Presse kann sich an Francis Wyffels unter Francis@dwengo.org wenden. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Design' + file_info: 'Design des Laufbands' + file_location: '/assets/files/art/Transportband_InnoVET_RCH.zip' + file_icon_name: 'description' + link_name: 'Download' + art: + title: KI in der Kunst + sub_title: Zeichenroboter im Unterricht + description: 'Können wir Kunst mit künstlicher Intelligenz schaffen? Schülerinnen und Schüler der zweiten und dritten Klasse (SO) setzen sich kreativ mit KI auseinander und reflektieren über das Ergebnis. Ist das Kunst? Sie entdecken auch, wie KI unser kulturelles Erbe schützen kann.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + wegostem: + title: WeGoSTEM + sub_title: Zeichenroboter im Unterricht + description: 'Wir fordern Kinder der dritten Klasse (GS) heraus, einen zeichnenden Kunstroboter zu programmieren. Spielerisch lernen die Kinder viele MINT-Fähigkeiten, von Technik bis zum algorithmischen Denken.' + contact: Fragen? Kontaktieren Sie uns unter team@aiopschool.be. Die Presse kann sich an Francis Wyffels unter Francis@dwengo.org wenden. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + computational_thinking: + title: Algorithmisches Denken + sub_title: Algorithmisches Denken im Unterricht + description: 'Wie können wir komplexe Probleme mithilfe eines Computers lösen? Dank algorithmischem Denken! Das können Sie durch verschiedene Aktivitäten mit oder ohne Computer lernen. Wir helfen Ihnen gerne auf dem Weg.' + contact: Fragen? Kontaktieren Sie uns unter team@aiopschool.be. Die Presse kann sich an Francis Wyffels unter Francis@dwengo.org wenden. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Konzepte und Ansätze des algorithmischen Denkens' + file_info: 'Poster​' + file_location: '/assets/files/computational_thinking/CDposterDwengo2.png' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Erklärung der vier Konzepte des algorithmischen Denkens' + file_info: 'Präsentation über die vier Konzepte des algorithmischen Denkens: Dekomposition, Mustererkennung, Abstraktion und Algorithmus. Sie können die PDF herunterladen > öffnen > präsentieren über CTRL-L und mit den Pfeiltasten navigieren.' + file_location: '/assets/files/computational_thinking/CD4concepten_33V3gBg.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Konzepte und Prinzipien' + file_info: 'Übersicht​' + file_location: '/assets/files/computational_thinking/Icoontjes.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged-Aktivität - Emotionsmaschine (Aufgabe)' + file_info: 'Wie können Sie Emotionen bei einem Roboter fördern?​' + file_location: '/assets/files/computational_thinking/emotiemachine_gids.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged-Aktivität - Emotionsmaschine' + file_info: 'Wie können Sie Emotionen bei einem Roboter fördern?​' + file_location: '/assets/files/computational_thinking/emotiemachine_gewoon_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged-Aktivität - Farben nach Zahlen' + file_info: "​Bilder können auf verschiedene Weisen dargestellt werden. Bei diesem Farben-nach-Zahlen-Rätsel müssen Sie ein Bild rekonstruieren, indem Sie die gegebene Liste von Zahlen verwenden. Diese Liste sagt Ihnen, welche Farbe Sie jedem Quadrat ('Pixel') zuordnen." + file_location: '/assets/files/computational_thinking/kleurenopnummer1.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged-Aktivität - Ein menschliches Computernetzwerk' + file_info: 'In dieser Aktivität lernen die Schülerinnen und Schüler, wie die Datenübertragung im Internet funktioniert. Computer, Smartphones und andere Geräte, die über das Internet miteinander verbunden sind, können kommunizieren. Um sich gegenseitig zu verstehen, muss diese Kommunikation nach bestimmten Regeln erfolgen. Diese Regeln nennen wir Protokoll.​' + file_location: '/assets/files/computational_thinking/menselijkComputernetwerk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged-Aktivität - Komprimierung' + file_info: 'Um Bilder über Netzwerke zu senden, möchten wir die Informationen mit möglichst wenig Daten darstellen. Hier kommt die Komprimierung ins Spiel. Mit Hilfe von Algorithmen werden Bilder mit möglichst wenigen Zahlen dargestellt, aber so, dass Sie das ursprüngliche Bild immer noch wiederherstellen können. Andere Algorithmen stellen das ursprüngliche Bild wieder her, wenn die Informationen ihr Ziel erreicht haben.​' + file_location: '/assets/files/computational_thinking/puzzel-gecomprimeerdepixel11.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged-Aktivität - Programmieren Sie einmal einen Menschen' + file_info: 'Computer können nicht interpretieren und führen daher buchstäblich jede Anweisung aus, die Sie ihnen geben. Die Herausforderung für den Programmierer besteht darin, Probleme in kleine, vom Computer ausführbare Schritte zu zerlegen und dem Computer die Anweisungen auf die richtige Weise zu geben.​' + file_location: '/assets/files/computational_thinking/programmeerEensEenMens.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged-Aktivität - Suche nach Sprache' + file_info: 'Das Locked-In-Syndrom ist eine der schlimmsten medizinischen Erkrankungen. Sie sind vollständig gelähmt, außer dass Sie vielleicht noch mit einem Auge blinzeln können. Ihr intelligenter Geist ist in einem nutzlosen Körper gefangen: Sie können alles fühlen, aber nicht kommunizieren. Es kann jedem passieren, aus dem Nichts, als Folge eines Schlaganfalls. Wenn Sie Menschen mit dem Locked-In-Syndrom helfen möchten, werden Sie am besten Arzt oder Krankenschwester? Oder kann Ihnen auch ein Informatiker helfen?​' + file_location: '/assets/files/computational_thinking/ZoektochtNaarSpraak.pdf' + file_icon_name: 'description' + link_name: 'Download' + math_with_python: + title: Python im Mathematikunterricht + sub_title: Python in der Mathematik + description: 'Die Programmiersprache Python bietet interessante Möglichkeiten, den Mathematikunterricht zu bereichern. Von der Pythagoras-Theorie bis zur Erstellung eigener Grafiken, digitalen Bildverarbeitung und linearer Regression wird alles mit Python klarer.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + python_programming: + title: Programmieren mit Python + sub_title: Grundlagen des Programmierens in Python + description: 'Die Grundprinzipien des Programmierens erlernen? Das ist perfekt möglich mit diesem Paket. Wir verwenden Python und lernen alles über Sequenzen, Wiederholungsstrukturen und Auswahlstrukturen. Genau das steht in den Endzielen. Und noch mehr!' + contact: Fragen? Kontaktieren Sie uns unter team@aiopschool.be. Die Presse kann sich an Francis Wyffels unter Francis@dwengo.org wenden. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + physical_computing: + title: Physical Computing + sub_title: Programmieren von Robotern im Unterricht + description: 'Ein Musikinstrument, Auto oder Wetterstation bauen? Das geht mit Dwenguino, einer Mikrocontroller-Plattform mit einer eigenen Programmierumgebung. Schülerinnen und Schüler der Grundschule und der Sekundarstufe können sofort damit arbeiten. Echt oder in unserem Simulator, blockbasiert oder textuell.' + contact: Fragen? Kontaktieren Sie uns unter team@aiopschool.be. Die Presse kann sich an Francis Wyffels unter Francis@dwengo.org wenden. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Bouw jouw eigen robot' + file_info: 'Baue deinen eigenen fahrenden Roboter.' + file_location: '/assets/files/physical_computing/bouwjouweigenrobot.pdf' + file_icon_name: 'description' + link_name: 'Download' diff --git a/backend/_i18n/en.yml b/backend/_i18n/en.yml new file mode 100644 index 00000000..20a34e77 --- /dev/null +++ b/backend/_i18n/en.yml @@ -0,0 +1,344 @@ +# translate theme pages + +strengths: + title: Our strengths + innovative: Innovative + research_based: Research-based + inclusive: Inclusive + socially_relevant: Socially relevant + innovative_text: We continuously add new projects and apply new methodologies in our projects. + research_based_text: All learning materials of Dwengo are based on profound scientific research. + inclusive_text: We target all children, making gender inclusion and accessibility for disadvantaged children a priority. + socially_relevant_text: We look for projects that fit current events and are socially relevant. + summary: We develop innovative workshops and educational resources, and we provide them to students around the globe in collaboration with teachers and volunteers. Our train-the-trainer sessions enable them to bring our hands-on workshops to the students. + main: We continuously add new projects and methodologies to all our projects. For these projects, we always look for a socially relevant theme. Additionally, we ensure that our didactic material is based on scientific research and always keep an eye on inclusivity. + quote: + text: You make something practical, learn how to use the hardware and create something new. + name: Matthias and Bruno + affiliation: Grade 10 + +curricula_page: + title: Our teaching topics + read_more: Read more + curricula_files: Files + algorithms: + title: Algorithms + sub_title: Algorithms + description: 'Students in the second and third grade (secondary education) learn how to use algorithms to solve problems. They learn how to design algorithms and analyze the efficiency of algorithms. They also learn how to use algorithms to solve problems.' + contact: '' + teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y + basics_ai: + title: Basisprincipes van AI + sub_title: Basisprincipes van AI + description: 'Onder dit lesthema bundelen we verschillende activiteiten waarin de basisprincipes van artificiële intelligentie (AI) aan bod komen. Leerlingen leren wat AI is, hoe het werkt en hoe het kan worden toegepast in verschillende domeinen.' + contact: '' + kiks: + title: AI and Climate + sub_title: KIKS + description: 'Students in the third grade (SO) investigate how plants adapt to climate change through their stomata. They use artificial intelligence and image recognition to count these stomata.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + curricula_files: + - file_title: 'Project Sheet KIKS' + file_info: 'This is a brief overview of the KIKS project with project structure and characteristics.' + file_location: '/assets/files/kiks/projectfiche_kiks.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'The manual - also available in print' + file_info: 'We want to provide teachers with background knowledge about the content of this project: climate change, the biology of stomata, how plants adapt to climate change through stomata, the scientific research of UGent and Plantentuin Meise underlying this project, citizen science, what artificial intelligence (AI) is, the history of AI, its use and ethics, principles of digital images, mathematics behind algorithms, and foundations of currently most-used AI techniques. We also explain how we implemented KIKS in the classroom.' + file_location: '/assets/files/kiks/KIKS_handleiding_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Student Course' + file_info: 'With the student course, we provide an example of a possible, comprehensive trajectory that a teacher can go through with students. The trajectory includes climate change, the biology of stomata with a microscopy assignment, how plants adapt to climate change through stomata, the scientific research of UGent and Plantentuin Meise, collecting data to train a neural network, what artificial intelligence (AI) is, the history of AI, its use and ethics, principles of digital images, working with convolutions, mathematics behind the Perceptron algorithm, linear and non-linear classification of data, and foundations of machine learning.' + file_location: '/assets/files/kiks/KIKS_leerlingencursus_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Curriculum Goals' + file_info: 'Within the KIKS project, many curriculum goals can be addressed. The teacher determines which goals are related to the project. Moreover, the project offers many opportunities to actively engage and let students learn independently, as well as teach ICT skills. KIKS can also be used for a research assignment. In the final objectives and curricula of various educational bodies, many goals can be found linking KIKS with biology, geography, and mathematics.' + file_location: '/assets/files/kiks/Leerdoelen-KIKS.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Making a Nail Polish Impression of a Leaf Part' + file_info: 'To know the number of stomata on a part of a plant leaf, we examine the leaf surface under the microscope. We can remove a piece of the thin cuticle of the leaf for this, but for some plants, it is not so successful, for example, due to the stiffness of the leaf. However, this can be compensated by using the same method as the researchers at Plantentuin Meise, namely, taking an impression of a part of the leaf surface with transparent nail polish. The microscopic image can be photographed with a smartphone.' + file_location: 'https://vimeo.com/467062270' + file_icon_name: 'play_arrow' + link_name: 'Watch' + socialrobot: + title: Social Robot + sub_title: Robotics in the classroom + description: 'While first-year students (secondary education) build and program a social robot, they learn to solve complex problems through computational thinking. They playfully work on the new end terms for digital competencies.' + contact: Questions? Contact us at team@aiopschool.be. The press can contact Francis Wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Project Sheet Social Robot' + file_info: "This is a brief overview of the 'Social Robot' project with project structure and characteristics." + file_location: '/assets/files/socialrobot/projectfiche_socialerobot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Collage of Social Robots' + file_info: 'The robots in this photo are creations of first-year secondary education students or prototypes.' + file_location: '/assets/files/socialrobot/collage.png' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Construction Kit for 'Social Robot' Project" + file_info: 'Do you want your students to design and build a social robot too? This is possible via the construction kit of Dwengovzw. The figure shows the current composition of the construction kit. Interested? Contact us by email or find more information on this project page.' + file_location: '/assets/files/socialrobot/constructiekit_socialerobot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'From Project to End Terms' + file_info: "The end terms that can be worked on are linked to the different phases of the 'Social Robot' project." + file_location: '/assets/files/socialrobot/EindtermenAStroomSsocialeRobot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotion Machine (Incomplete) - Computational Thinking (Unplugged Activity)' + file_info: 'Unplugged activity' + file_location: '/assets/files/socialrobot/emotiemachine_gewoon_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotion Machine (Task) - Computational Thinking (Unplugged Activity)' + file_info: 'Unplugged activity' + file_location: '/assets/files/socialrobot/emotiemachine_gids.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotion Machine (Complete) - Computational Thinking (Unplugged Activity)' + file_info: 'Unplugged activity' + file_location: '/assets/files/socialrobot/emotiemachine_ingevuld_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotion Machine with LED Matrix - Computational Thinking (Unplugged Activity)' + file_info: 'Unplugged activity' + file_location: '/assets/files/socialrobot/emotiemachine_matrices_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Teacher's Guide" + file_info: 'The bundled sheets explain the use of the Dwenguino and the sensors and actuators.' + file_location: '/assets/files/socialrobot/ficheboekje_lkn.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Information Sheets for Students' + file_info: 'These information sheets explain the use of the Dwenguino and sensors and actuators.' + file_location: '/assets/files/socialrobot/fichesSocialeRobot_lln.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Create a Face - Computational Thinking (Unplugged Activity)' + file_info: 'Unplugged activity' + file_location: '/assets/files/socialrobot/maakeengezicht_activiteit.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Tasks for Exercises in the MOOC' + file_info: 'An overview of programming exercises in the MOOC' + file_location: '/assets/files/socialrobot/Opgaven_MOOC.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Poster 'Social Robot' Project" + file_info: 'The poster displays the different aspects of the project.' + file_location: '/assets/files/socialrobot/posterSocialeRobot_nl_Qo4ANmV.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Manual 'Hello Robot!'" + file_info: 'With this lesson booklet, you bring your own social robot to life.' + file_location: '/assets/files/socialrobot/handleiding_hallo_robot.pdf' + file_icon_name: 'description' + link_name: 'Download' + chatbot: + title: Language Technology at School + sub_title: Getting Started with a Chatbot + description: 'Where language and technology come together, the domain of Natural Language Processing emerges. Can a computer understand, translate, or even write texts? Can a computer recognize emotions? Students in the second and third grades (SO) learn all about it in this package.' + contact: Questions? Contact us at team@aiopschool.be. The press can contact Francis Wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Project Sheet Chatbot' + file_info: 'This is a brief overview of the Chatbot project with project structure and characteristics.' + file_location: '/assets/files/chatbot/projectfiche_chatbot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'BrAInfood Chatbots' + file_info: 'In this BrAInfood - aimed at young people - the Knowledge Center Data & Society provides more information about chatbots. The BrAInfood includes a fictional story about Lotte talking to a chatbot and presumably providing information about her to companies. Further, some points related to chatbots are explained, along with some tips for young people to better protect their (personal) data. In this way, we aim to make young people more aware of how chatbots work and encourage them to reflect on the data collected about them.' + file_location: '/assets/files/chatbot/Brainfood13_Chatbots_NL.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'BrAInfood Personalized Newsfeeds' + file_info: 'In this BrAInfood from the Knowledge Center Data & Society, tips are given on how to keep control over your newsfeed.' + file_location: '/assets/files/chatbot/brainfoodaanbevelingnieuws.jpg' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Manual 'Chatbot' - Also available in print" + file_info: "Teachers acquire sufficient background information through this manual to work with (a part of) the 'Chatbot' project in the classroom. The book covers various aspects of language technology, such as the history of artificial intelligence, its ethical aspects, sentiment analysis, and cyberbullying detection, chatbots, speaking digital assistants, and author recognition. It also addresses the STEM objectives and the objectives related to digital competence and media literacy." + file_location: '/assets/files/chatbot/Chatbot_handleiding_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Improbotics - Lesson Plan - Teacher's Version" + file_info: 'In the theater performance Improbotics, a social robot improvises in the scenes. The lesson plan provides information about the technologies used.' + file_location: '/assets/files/chatbot/Improbotics_lesmap_Leerkracht.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Improbotics - Lesson Plan - Students' + file_info: 'In the theater performance Improbotics, a social robot improvises in the scenes. The lesson plan provides information about the technologies used.' + file_location: '/assets/files/chatbot/Improbotics_lesmap_Leerling.pdf' + file_icon_name: 'description' + link_name: 'Download' + care: + title: AI in Healthcare + sub_title: AI Systems Assisting in Healthcare + description: 'Hospitals are already using artificial intelligence today. Students in the second and third grades (SO) discover the existing systems and how they help doctors make decisions. This way, students learn the principles of the decision tree, a commonly used technique in machine learning.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + curricula_files: + - file_title: 'Project Sheet AI in Healthcare' + file_info: 'This is a brief overview of the AI in Healthcare project with project structure and characteristics.' + file_location: '/assets/files/care/projectfiche_aiindezorg.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Sprouts' + file_info: "A game as an introduction to 'Graphs'." + file_location: '/assets/files/care/Sprouts.mov' + file_icon_name: 'play_arrow' + link_name: 'Download' + - file_title: 'Explanation of Exercises on Graphs from the Student Course' + file_info: 'Which figures represent the same graph? A more formal approach.' + file_location: '/assets/files/care/dezelfdegraafFormeel.mov' + file_icon_name: 'play_arrow' + link_name: 'Download' + - file_title: 'Explanation of Exercises on Graphs from the Student Course' + file_info: 'Which figures represent the same graph? An approach with colors.' + file_location: '/assets/files/care/dezelfdeGraaf.mov' + file_icon_name: 'play_arrow' + link_name: 'Download' + - file_title: 'Student Course - Finality Throughflow' + file_info: 'Student course.' + file_location: '/assets/files/care/AIinHealthcare_throughflow_firstedition.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Card Set - Also available in print' + file_info: 'With this card set, you can make students reflect on the ethical aspects of new technologies. What about privacy? Are social contacts not at risk? Which technologies are welcomed? What is undesirable? Are the new technologies affordable for everyone?' + file_location: '/assets/files/care/Cardset_AIinHealthcare_AIOpSchool_Dwengo.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Manual for Card Set' + file_info: 'This manual provides additional explanation for the card set.' + file_location: '/assets/files/care/AIinHealthcareCardset_InstructionForTeacher.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Decision Tree 'mBrAIn' Project in Non-compact Form" + file_info: "In the research project 'mBrain,' which aims to develop an app predicting a migraine attack, a decision tree was constructed. This decision tree addresses a binary classification problem: there are only two classes, 'Migraine' and 'No Migraine.' (Sources: Femke Ongenae. (2021), UGent; Van Hoecke, S., Ongenae, F., Paemeleire, K., & Vandenbussche, N. (2020). App moet hoofdpijn voorspellen. EOS Wetenschap Special, Technologie en gezondheid, 25.) We have transformed the shape of this decision tree into a binary decision tree to demonstrate that this form is not always user-friendly. For more explanation, see chapter 4 of the student course of this project." + file_location: '/assets/files/care/MBrainBeslissingsboom.png' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Search for Speech - Computational Thinking (unplugged activity)' + file_info: 'Locked-in syndrome is one of the worst medical conditions. You are completely paralyzed, except that you might still be able to blink with one eye. Your intelligent mind is trapped in a useless body: you can feel everything but cannot communicate. It can happen to anyone, out of nowhere, as a result of a stroke. If you want to help people with the locked-in syndrome, is it best to become a doctor or a nurse? Or can you also help as a computer scientist?' + file_location: '/assets/files/care/ZoektochtNaarSpraak.pdf' + file_icon_name: 'description' + link_name: 'Download' + stem: + title: Python in STEM + sub_title: Data Visualizations with Python + description: "In this package, you'll learn to tackle complex problems more easily and quickly with the programming language Python. Programming can indeed play a connecting role between science, technology, design, and applied mathematics. In short, thanks to Python, we get the best out of STEM." + contact: Questions? Contact us at team@aiopschool.be. The press can contact Francis Wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + agriculture: + title: AI in Agriculture + sub_title: AI in agriculture + description: 'Removing rotten tomatoes during harvest? Artificial intelligence can help with that. But how? Students in the second and third grades (SO) get hands-on experience with AI. Perhaps a vibrating conveyor belt can improve the system even more?' + contact: Questions? Contact us at team@aiopschool.be. The press can contact Francis Wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Design' + file_info: 'Design of the conveyor belt' + file_location: '/assets/files/art/Transportband_InnoVET_RCH.zip' + file_icon_name: 'description' + link_name: 'Download' + art: + title: AI in Art + sub_title: Drawing Robots in the Classroom + description: 'Can we create art with artificial intelligence? Students in the second and third grades (SO) express their creativity with AI and reflect on the result. Is this art? They also discover how AI can protect our cultural heritage.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + wegostem: + title: WeGoSTEM + sub_title: Drawing Robots in the Classroom + description: 'We challenge third-grade (BO) children to program a drawing robot. Through play, children learn a lot of STEM skills, from technology to computational thinking.' + contact: Questions? Contact us at team@aiopschool.be. The press can contact Francis Wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + + computational_thinking: + title: Computational Thinking + sub_title: Computational Thinking in the Classroom + description: "How can we solve complex problems with the help of a computer? Thanks to computational thinking! You can learn this through various activities with or without a computer. We'll help you get started." + contact: Questions? Contact us at team@aiopschool.be. The press can contact Francis Wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Concepts and Approach to Computational Thinking' + file_info: 'poster' + file_location: '/assets/files/computational_thinking/CDposterDwengo2.png' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Explaining Four Concepts of Computational Thinking' + file_info: 'Presentation on four concepts of computational thinking: decomposition, pattern recognition, abstraction, and algorithm. You can download the PDF, open it, present it using CTRL-L, and navigate with the arrow keys.' + file_location: '/assets/files/computational_thinking/CD4concepten_33V3gBg.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Concepts and Principles' + file_info: 'Overview' + file_location: '/assets/files/computational_thinking/Icoontjes.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged Activity - Emotion Machine (Assignment)' + file_info: 'How can you stimulate emotions in a robot?' + file_location: '/assets/files/computational_thinking/emotiemachine_gids.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged Activity - Emotion Machine' + file_info: 'How can you stimulate emotions in a robot?' + file_location: '/assets/files/computational_thinking/emotiemachine_gewoon_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged Activity - Color by Number' + file_info: "Images can be represented in many ways. In this color by number puzzle, you have to reconstruct an image using the given list of numbers; this list tells you which color to fill in each square ('pixel')." + file_location: '/assets/files/computational_thinking/kleurenopnummer1.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged Activity - Human Computer Network' + file_info: 'In this activity, students learn how data transfer over the Internet works. Computers, smartphones, and other devices connected via the Internet can communicate with each other. To understand each other, this communication must follow certain agreements. We call these agreements a protocol.' + file_location: '/assets/files/computational_thinking/menselijkComputernetwerk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged Activity - Compression' + file_info: 'To send images over networks, we want to represent information with as little data as possible. Compression comes into play here. Using algorithms, images are represented with as few numbers as possible, but in a way that you can still recover the original figure. Other algorithms restore the original image when the information reaches its destination.' + file_location: '/assets/files/computational_thinking/puzzel-gecomprimeerdepixel11.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged Activity - Program a Human' + file_info: "Computers cannot interpret, so they literally execute every instruction you give them. The programmer's challenge is to solve problems by breaking them down into small steps that the computer can execute and giving the instructions to the computer correctly." + file_location: '/assets/files/computational_thinking/programmeerEensEenMens.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged Activity - Search for Speech' + file_info: 'Locked-in syndrome is one of the worst medical conditions. You are completely paralyzed, except that you may still be able to blink with one eye. Your intelligent mind is trapped in a useless body: you can feel everything but cannot communicate. It can happen to anyone, out of nowhere, as a result of a stroke. If you wanted to help people with locked-in syndrome, would you be better off as a doctor or nurse? Or can you help as a computer scientist?' + file_location: '/assets/files/computational_thinking/ZoektochtNaarSpraak.pdf' + file_icon_name: 'description' + link_name: 'Download' + math_with_python: + title: Python in Math Class + sub_title: Python in Mathematics + description: 'The programming language Python offers great possibilities to enrich the math class. From the Pythagorean theorem to creating your own graphs, digital image processing, and linear regression, everything becomes clearer with Python.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + python_programming: + title: Programming with Python + sub_title: Basic Programming in Python + description: "Want to learn the basic principles of programming? You can do it perfectly with this package. We use Python and learn all about sequences, repetition structure, and decision structure. That's exactly what's in the end terms. And more!" + contact: Questions? Contact us at team@aiopschool.be. The press can contact Francis Wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + physical_computing: + title: Physical Computing + sub_title: Program Robots in the Classroom + description: 'Build a musical instrument, car, or weather station? You can do that with Dwenguino, a microcontroller platform with its own programming environment. Students from both primary and secondary education can get started with it right away. In real life or in our simulator, block-based or textual.' + contact: Questions? Contact us at team@aiopschool.be. The press can contact Francis Wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Bouw jouw eigen robot' + file_info: 'Build your own driving robot.' + file_location: '/assets/files/physical_computing/bouwjouweigenrobot.pdf' + file_icon_name: 'link' + link_name: 'Download' diff --git a/backend/_i18n/fr.yml b/backend/_i18n/fr.yml new file mode 100644 index 00000000..08a0b1d7 --- /dev/null +++ b/backend/_i18n/fr.yml @@ -0,0 +1,351 @@ +# translate theme pages + +strengths: + title: Nos atouts + innovative: Innovatif + research_based: Fondé sur la recherche + inclusive: Inclusif + socially_relevant: Socialement pertinent + innovative_text: On ajoute fréquemment de nouveaux projets et utilise de nouvelles méthodologies dans les projets. + research_based_text: Tout le matériel de cours de Dwengo ASBL est fondé sur la recherche scientifique profonde. + inclusive_text: On se concentre à atteindre tous les enfants avec une attention particulière pour l'égalite de genre et l'inclusion sociale. + socially_relevant_text: Nous recherchons des projects qui s'inscrivent dans l'actualité et les thèmes sociaux. + summary: Nous développons des ateliers innovants et des ressources éducatives, et nous les fournissons aux étudiants du monde entier en collaboration avec les enseignants et les bénévoles.Nos séances de train-Trainer leur permettent d'amener nos ateliers pratiques aux étudiants. + main: Nous ajoutons toujours de nouveaux projets et méthodologies à tous nos projets.Nous recherchons toujours un thème socialement pertinent pour ces projets.De plus, nous nous assurons toujours que notre matériel didactique est basé sur la recherche scientifique et nous gardons également un œil sur l'inclusivité. + quote: + text: Vous faites quelque chose de pratique, vous apprenez à travailler avec du matériel et vous pouvez exécuter quelque chose de nouveau Cre & Euml; + name: Matthias en Bruno + affiliation: 4e milieu + +curricula_page: + title: Notre sujets d'enseignement + read_more: 'Lees meer' + curricula_files: 'Bestanden' + algorithms: + title: Algorithmes + sub_title: Algorithmes + description: "Les élèves de la troisième à la sixième année (enseignement secondaire) apprennent comment utiliser des algorithmes pour résoudre des problèmes. Ils apprennent à concevoir des algorithmes et à analyser l'efficacité de ces derniers. Ils apprennent également à utiliser des algorithmes pour résoudre des problèmes." + contact: '' + teaser: https://www.youtube.com/embed/2B6gZ9HdQ1Y + basics_ai: + title: Basisprincipes van AI + sub_title: Basisprincipes van AI + description: 'Onder dit lesthema bundelen we verschillende activiteiten waarin de basisprincipes van artificiële intelligentie (AI) aan bod komen. Leerlingen leren wat AI is, hoe het werkt en hoe het kan worden toegepast in verschillende domeinen.' + contact: '' + kiks: + title: 'IA et changement climatique' + sub_title: 'KIKS' + description: "Les élèves de la troisième année du secondaire explorent comment les plantes s'adaptent au changement climatique via leurs stomates. Ils utilisent l'intelligence artificielle et la reconnaissance d'image pour compter ces stomates." + contact: '' + teaser: 'https://www.youtube.com/embed/dO-E33G20co' + curricula_files: + - file_title: 'Fiche de projet KIKS' + file_info: "Il s'agit d'un bref aperçu du projet KIKS avec la structure et les caractéristiques du projet." + file_location: '/assets/files/kiks/projectfiche_kiks.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Le manuel - également disponible en version imprimée' + file_info: "Nous voulons donner aux enseignants des connaissances de base sur le contenu de ce projet : le changement climatique, la biologie des stomates et la manière dont les plantes s'adaptent à ce changement climatique via les stomates, la recherche scientifique de l'UGent et du Jardin botanique de Meise à l'origine de ce projet, la science citoyenne, ce qu'est l'intelligence artificielle (IA), l'histoire de l'IA, son utilisation et l'éthique qui l'entoure, les principes des images numériques, les mathématiques derrière les algorithmes et les fondements des techniques d'IA actuellement les plus utilisées. Nous expliquons également comment nous avons utilisé KIKS en classe." + file_location: '/assets/files/kiks/KIKS_handleiding_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Un cours pour les élèves' + file_info: "Avec le cours pour les élèves, nous donnons un exemple d'un parcours possible que l'enseignant peut suivre avec les élèves. Le parcours inclut le changement climatique, la biologie des stomates avec une tâche de microscopie, la manière dont les plantes s'adaptent au changement climatique via les stomates, la recherche scientifique de l'UGent et du Jardin botanique de Meise, la collecte de données pour entraîner un réseau neuronal, ce qu'est l'intelligence artificielle (IA), l'histoire de l'IA, son utilisation et l'éthique qui l'entoure, les principes des images numériques, le travail avec les convolutions, les mathématiques derrière l'algorithme du Perceptron, la classification linéaire et non linéaire des données, et les fondements de l'apprentissage machine." + file_location: '/assets/files/kiks/KIKS_leerlingencursus_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: "Les objectifs d'apprentissage" + file_info: "Dans le projet KIKS, de nombreux objectifs d'apprentissage peuvent être abordés. L'enseignant décide lui-même des objectifs d'apprentissage liés au projet. De plus, le projet offre de nombreuses possibilités pour permettre aux élèves d'apprendre de manière active et autonome, et pour enseigner les compétences en TIC. KIKS peut également être utilisé pour développer une mission de recherche. Les objectifs d'apprentissage et les plans de cours des différentes institutions contiennent de nombreux objectifs d'apprentissage liés à la biologie, à la géographie et aux mathématiques." + file_location: '/assets/files/kiks/Leerdoelen-KIKS.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: "La réalisation d'une empreinte de vernis à ongles d'une partie d'une feuille" + file_info: "Pour connaître le nombre de stomates sur une partie d'une feuille d'une plante, nous examinons la surface de la feuille sous le microscope. Pour ce faire, nous pouvons retirer une partie de la cuticule très fine de la feuille, mais cela ne fonctionne pas aussi bien pour certaines plantes en raison de la rigidité de la feuille. Cela peut cependant être compensé en utilisant la même méthode que les chercheurs du Jardin botanique de Meise, à savoir prendre une empreinte d'une partie de la surface de la feuille avec du vernis à ongles transparent. L'image microscopique peut être photographiée avec un smartphone." + file_location: 'https://vimeo.com/467062270' + file_icon_name: 'play_arrow' + link_name: 'Voir' + socialrobot: + title: 'Robot social' + sub_title: 'Robotique en classe' + description: 'Alors que les élèves de la première année du secondaire bricolent et programment un robot social, ils apprennent à résoudre des problèmes complexes grâce à la pensée computationnelle. Ils travaillent de manière ludique sur les nouvelles compétences finales en compétences numériques.' + contact: 'Questions ? Contactez-nous via team@aiopschool.be. La presse peut contacter Francis Wyffels via Francis@dwengo.org.' + teaser: 'https://www.youtube.com/embed/tqSnpAKLsu8' + curricula_files: + - file_title: 'Fiche de projet Robot social' + file_info: "Il s'agit d'un bref aperçu du projet 'Robot social' avec la structure et les caractéristiques du projet." + file_location: '/assets/files/socialrobot/projectfiche_socialerobot.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Collage des robots sociaux' + file_info: "Les robots sur cette photo sont des réalisations d'élèves de la première année du secondaire ou des prototypes." + file_location: '/assets/files/socialrobot/collage.png' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: "Kit de construction pour le projet 'Robot social'" + file_info: "Vous souhaitez que vos élèves conçoivent et construisent également un robot social ? Cela peut se faire avec le kit de construction de Dwengovzw. Sur la figure, vous découvrirez la composition actuelle du kit de construction. Intéressé ? Contactez-nous par e-mail ou trouvez plus d'informations sur cette page du projet." + file_location: '/assets/files/socialrobot/constructiekit_socialerobot.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'De la mission aux compétences finales' + file_info: "Les compétences finales qui peuvent être travaillées sont liées aux différentes phases du projet 'Robot social'." + file_location: '/assets/files/socialrobot/EindtermenAStroomSsocialeRobot.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Machine à émotions (incomplète) - Pensée computationnelle (activité débranchée)' + file_info: 'Activité débranchée' + file_location: '/assets/files/socialrobot/emotiemachine_gewoon_nl.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Machine à émotions (mission) - Pensée computationnelle (activité débranchée)' + file_info: 'Activité débranchée' + file_location: '/assets/files/socialrobot/emotiemachine_gids.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Machine à émotions (complète) - Pensée computationnelle (activité débranchée)' + file_info: 'Activité débranchée' + file_location: '/assets/files/socialrobot/emotiemachine_ingevuld_nl.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Machine à émotions avec matrice de LED - Pensée computationnelle (activité débranchée)' + file_info: 'Activité débranchée' + file_location: '/assets/files/socialrobot/emotiemachine_matrices_nl.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Guide pour les enseignants' + file_info: "Les fiches regroupées détaillant l'utilisation du Dwenguino et des capteurs et actionneurs." + file_location: '/assets/files/socialrobot/ficheboekje_lkn.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Fiches pour les élèves' + file_info: "Ces fiches expliquent l'utilisation du Dwenguino et des capteurs et actionneurs." + file_location: '/assets/files/socialrobot/fichesSocialeRobot_lln.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Créer un visage - Pensée computationnelle (activité débranchée)' + file_info: 'Activité débranchée' + file_location: '/assets/files/socialrobot/maakeengezicht_activiteit.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Exercices de la MOOC' + file_info: 'Aperçu des exercices de programmation dans le MOOC.' + file_location: '/assets/files/socialrobot/Opgaven_MOOC.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: "Affiche du projet 'Robot social'" + file_info: "L'affiche représente les différents aspects du projet." + file_location: '/assets/files/socialrobot/posterSocialeRobot_nl_Qo4ANmV.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: "Guide 'Bonjour robot!'" + file_info: 'Avec ce livret, donnez vie à votre propre robot social.' + file_location: '/assets/files/socialrobot/handleiding_hallo_robot.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + chatbot: + title: "Technologie du langage à l'école" + sub_title: 'Se mettre au travail avec un chatbot' + description: 'Là où le langage et la technologie se rencontrent, naît le domaine du Traitement du Langage Naturel. Un ordinateur peut-il comprendre, traduire ou même écrire des textes ? Peut-il reconnaître les émotions ? Les élèves de la deuxième et de la troisième année (enseignement secondaire) en apprennent davantage dans ce programme.' + contact: 'Questions ? Contactez-nous via team@aiopschool.be. La presse peut contacter Francis Wyffels via Francis@dwengo.org.' + teaser: 'https://www.youtube.com/embed/tqSnpAKLsu8' + curricula_files: + - file_title: 'Fiche de projet Chatbot' + file_info: "Il s'agit d'un bref aperçu du projet Chatbot avec la structure et les caractéristiques du projet." + file_location: '/assets/files/chatbot/projectfiche_chatbot.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'BrAInfood Chatbots' + file_info: "Dans ce BrAInfood destiné aux jeunes, le Centre de connaissances Data & Société donne plus d'informations sur les chatbots. Le BrAInfood contient une histoire fictive sur Lotte qui discute avec un chatbot et transmet potentiellement des informations à des entreprises. En outre, quelques points d'attention concernant les chatbots sont expliqués, ainsi que quelques conseils pour les jeunes afin de mieux protéger leurs données personnelles. De cette manière, nous voulons sensibiliser les jeunes au fonctionnement des chatbots et les encourager à réfléchir aux données qui sont collectées à leur sujet." + file_location: '/assets/files/chatbot/Brainfood13_Chatbots_FR.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'BrAInfood Nouvelles personnalisées' + file_info: "Dans ce BrAInfood du Centre de connaissances Data & Société, des conseils sont donnés sur la façon de garder le contrôle sur votre fil d'actualité." + file_location: '/assets/files/chatbot/brainfoodrecommendationnews.jpg' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: "Manuel 'Chatbot' - Également disponible en version imprimée" + file_info: "Les enseignants acquièrent, via ce manuel, des informations de base suffisantes pour aborder (une partie du) projet 'Chatbot' en classe. Le livre aborde différents aspects de la technologie du langage, tels que l'histoire de l'intelligence artificielle, ses aspects éthiques, l'analyse des sentiments et la détection du cyberharcèlement, les chatbots, les assistants numériques parlants et la reconnaissance d'auteur. Il aborde également les compétences finales STEM et les compétences finales en compétences numériques et en éducation aux médias." + file_location: '/assets/files/chatbot/Chatbot_handleiding_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Improbotics - Livret - Version enseignant' + file_info: 'Dans la pièce de théâtre Improbotics, un robot social improvise dans les scènes. Le livret contient des informations sur les technologies utilisées.' + file_location: '/assets/files/chatbot/Improbotics_lesmap_Enseignant.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Improbotics - Livret - Élèves' + file_info: 'Dans la pièce de théâtre Improbotics, un robot social improvise dans les scènes. Le livret contient des informations sur les technologies utilisées.' + file_location: '/assets/files/chatbot/Improbotics_lesmap_Eleve.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + + care: + title: 'IA dans les soins de santé' + sub_title: "Systèmes d'IA aidant dans les soins de santé" + description: "Les hôpitaux utilisent déjà l'intelligence artificielle aujourd'hui. Les élèves de la deuxième et de la troisième année (enseignement secondaire) découvrent quels systèmes existent et comment ils aident les médecins à prendre des décisions. Ainsi, les élèves apprennent les principes de l'arbre de décision, une technique couramment utilisée dans l'apprentissage automatique." + contact: '' + teaser: 'https://www.youtube.com/embed/dO-E33G20co' + curricula_files: + - file_title: 'Fiche de projet IA dans les soins de santé' + file_info: "Il s'agit d'un bref aperçu du projet IA dans les soins de santé avec la structure et les caractéristiques du projet." + file_location: '/assets/files/care/projectfiche_aiindezorg.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Sprouts' + file_info: "Un jeu d'introduction aux 'Graphes'." + file_location: '/assets/files/care/Sprouts.mov' + file_icon_name: 'play_arrow' + link_name: 'Télécharger' + - file_title: 'Explication des exercices de graphes du cours des élèves' + file_info: 'Quels dessins représentent le même graphe ? Une approche plus formelle.' + file_location: '/assets/files/care/dezelfdegraafFormeel.mov' + file_icon_name: 'play_arrow' + link_name: 'Télécharger' + - file_title: 'Explication des exercices de graphes du cours des élèves' + file_info: 'Quels dessins représentent le même graphe ? Une approche avec des couleurs.' + file_location: '/assets/files/care/dezelfdeGraaf.mov' + file_icon_name: 'play_arrow' + link_name: 'Télécharger' + - file_title: 'Le cours des élèves - Finalité Générale' + file_info: 'Cours des élèves' + file_location: '/assets/files/care/AIindeZorg_doorstroom_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Ensemble de cartes - Également disponible en version imprimée' + file_info: "À l'aide de cet ensemble de cartes, vous pouvez amener les élèves à réfléchir aux aspects éthiques des nouvelles technologies. Qu'en est-il de la vie privée ? Les contacts sociaux ne sont-ils pas compromis ? Quelles technologies sont bien accueillies ? Qu'est-ce qui n'est pas souhaitable ? Les nouvelles technologies sont-elles abordables pour tous ?" + file_location: '/assets/files/care/Kaartset_AIIndeZorg_AIOpSchool_Dwengo.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: "Manuel de l'ensemble de cartes" + file_info: "Ce manuel fournit des explications supplémentaires sur l'ensemble de cartes." + file_location: '/assets/files/care/AIIndeZorgKaartenset_UitlegVoorLeerkracht.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: "Arbre de décision du projet 'mBrAIn' sous forme non compacte" + file_info: "Dans le projet de recherche 'mBrain', qui vise le développement d'une application prédisant une crise de migraine, une arborescence de décision a été construite. Cette arborescence de décision aborde un problème de classification binaire : il n'y a que deux classes, 'Migraine' et 'Pas de migraine'. (Sources : Femke Ongenae. (2021), UGent ; Van Hoecke, S., Ongenae, F., Paemeleire, K., & Vandenbussche, N. (2020). App doit prédire les maux de tête. EOS Wetenschap Special, Technologie en gezondheid, 25.) Nous avons transformé la forme de cette arborescence de décision en une arborescence de décision binaire pour montrer que cette forme n'est pas toujours conviviale. Pour plus d'explications, voir le chapitre 4 du cours des élèves de ce projet." + file_location: '/assets/files/care/MBrainBeslissingsboom.png' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Recherche de la parole - Pensée computationnelle (activité débranchée)' + file_info: "Le syndrome d'enfermement est l'une des pires conditions médicales. Vous êtes complètement paralysé, sauf peut-être pour cligner des yeux. Votre esprit intelligent est enfermé dans un corps inutile : vous pouvez tout ressentir, mais pas communiquer. Cela peut arriver à n'importe qui, à tout moment, suite à un AVC. Si vous voulez aider les personnes atteintes du syndrome d'enfermement, vaut-il mieux être médecin ou infirmier ? Ou en tant qu'informaticien, pouvez-vous aussi aider ?" + file_location: '/assets/files/care/ZoektochtNaarSpraak.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + stem: + title: 'Python en STEM' + sub_title: 'Visualisations de données avec Python' + description: 'Dans ce programme, vous apprendrez à aborder des problèmes complexes de manière plus simple et plus rapide avec le langage de programmation Python. La programmation peut jouer un rôle de liaison entre la science, la technologie, la conception et les mathématiques appliquées. En bref, grâce à Python, nous tirons le meilleur parti de STEM.' + contact: 'Des questions ? Contactez-nous via team@aiopschool.be. La presse peut contacter Francis Wyffels via Francis@dwengo.org.' + teaser: 'https://www.youtube.com/embed/tqSnpAKLsu8' + + agriculture: + title: "IA dans l'Agriculture" + sub_title: "IA dans l'agriculture" + description: "Retirer les tomates pourries pendant la récolte ? L'intelligence artificielle peut aider. Mais comment ? Les élèves de la deuxième et de la troisième année (enseignement secondaire) travaillent avec l'IA. Peut-être qu'un tapis roulant vibrant pourrait améliorer le système encore davantage ?" + contact: 'Des questions ? Contactez-nous via team@aiopschool.be. La presse peut contacter Francis Wyffels via Francis@dwengo.org.' + teaser: 'https://www.youtube.com/embed/tqSnpAKLsu8' + curricula_files: + - file_title: 'Conception' + file_info: 'Conception du tapis roulant' + file_location: '/assets/files/art/Transportband_InnoVET_RCH.zip' + file_icon_name: 'description' + link_name: 'Télécharger' + + art: + title: "IA dans l'Art" + sub_title: 'Robots de dessin en classe' + description: "Peut-on créer de l'art avec l'intelligence artificielle ? Les élèves de la deuxième et de la troisième année (enseignement secondaire) expriment leur créativité avec l'IA et réfléchissent au résultat. Est-ce de l'art ? Ils découvrent également comment l'IA peut protéger notre patrimoine culturel." + contact: '' + teaser: 'https://www.youtube.com/embed/dO-E33G20co' + + wegostem: + title: 'WeGoSTEM' + sub_title: 'Robots de dessin en classe' + description: 'Nous mettons au défi les enfants de la troisième année (enseignement primaire) de programmer un robot artistique capable de dessiner. Les enfants apprennent de manière ludique de nombreuses compétences STEM, de la technologie à la pensée informatique.' + contact: 'Des questions ? Contactez-nous via team@aiopschool.be. La presse peut contacter Francis Wyffels via Francis@dwengo.org.' + teaser: 'https://www.youtube.com/embed/tqSnpAKLsu8' + + computational_thinking: + title: 'Pensée informatique' + sub_title: 'Pensée informatique en classe' + description: "Comment pouvons-nous résoudre des problèmes complexes à l'aide d'un ordinateur ? Grâce à la pensée informatique ! Vous pouvez apprendre cela grâce à diverses activités avec ou sans ordinateur. Nous vous aidons déjà à démarrer." + contact: 'Des questions ? Contactez-nous via team@aiopschool.be. La presse peut contacter Francis Wyffels via Francis@dwengo.org.' + teaser: 'https://www.youtube.com/embed/tqSnpAKLsu8' + curricula_files: + - file_title: 'Concepts et approches de la pensée informatique' + file_info: 'poster' + file_location: '/assets/files/computational_thinking/CDposterDwengo2.png' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Explication des quatre concepts de la pensée informatique' + file_info: 'Présentation des quatre concepts de la pensée informatique : décomposition, reconnaissance de motifs, abstraction et algorithme. Vous pouvez télécharger le PDF > ouvrir > présenter avec CTRL-L et naviguer avec les touches fléchées.' + file_location: '/assets/files/computational_thinking/CD4concepten_33V3gBg.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Concepts et principes' + file_info: 'Aperçu' + file_location: '/assets/files/computational_thinking/Icoontjes.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Activité sans ordinateur - Machine émotionnelle (tâche)' + file_info: 'Comment stimuler les émotions chez un robot ?' + file_location: '/assets/files/computational_thinking/emotiemachine_gids.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Activité sans ordinateur - Machine émotionnelle' + file_info: 'Comment stimuler les émotions chez un robot ?' + file_location: '/assets/files/computational_thinking/emotiemachine_gewoon_nl.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Activité sans ordinateur - Coloriage par numéro' + file_info: "Les images peuvent être représentées de nombreuses manières. Dans ce casse-tête de coloriage par numéro, vous devez reconstruire une image en utilisant la liste donnée de numéros ; cette liste vous dit dans quelle couleur remplir chaque carré ('pixel')." + file_location: '/assets/files/computational_thinking/kleurenopnummer1.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Activité sans ordinateur - Un réseau informatique humain' + file_info: "Dans cette activité, les élèves découvrent comment fonctionne le transfert de données sur Internet. Les ordinateurs, les smartphones et d'autres appareils connectés par Internet peuvent communiquer entre eux. Pour se comprendre, cette communication doit suivre certaines règles. Nous appelons ces règles un protocole." + file_location: '/assets/files/computational_thinking/menselijkComputernetwerk.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Activité sans ordinateur - Compression' + file_info: "Pour envoyer des images sur des réseaux, nous voulons représenter l'information avec le moins de données possible. C'est là qu'intervient la compression. À l'aide d'algorithmes, les images sont représentées avec le moins de nombres possible, mais de manière à ce que vous puissiez récupérer la figure originale. D'autres algorithmes restaurent l'image d'origine une fois que l'information a atteint sa destination." + file_location: '/assets/files/computational_thinking/puzzel-gecomprimeerdepixel11.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Activité sans ordinateur - Programmez un être humain' + file_info: "Les ordinateurs ne peuvent pas interpréter et exécutent donc littéralement chaque instruction que vous leur donnez. Le défi du programmeur consiste à résoudre des problèmes en les divisant en petites étapes exécutables par l'ordinateur, et à donner les instructions à l'ordinateur de la bonne manière." + file_location: '/assets/files/computational_thinking/programmeerEensEenMens.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + - file_title: 'Activité sans ordinateur - Recherche de la parole' + file_info: "Le locked-in syndrome est l'une des pires affections médicales. Vous êtes complètement paralysé, sauf peut-être que vous pouvez cligner des yeux. Votre esprit intelligent est emprisonné dans un corps inutile : vous pouvez tout ressentir, mais pas communiquer. Cela peut arriver à n'importe qui, sans avertissement, à la suite d'une attaque. Si vous voulez aider les personnes atteintes du locked-in syndrome, vaut-il mieux être médecin ou infirmier ? Ou un informaticien peut-il aussi aider ?" + file_location: '/assets/files/computational_thinking/ZoektochtNaarSpraak.pdf' + file_icon_name: 'description' + link_name: 'Télécharger' + + math_with_python: + title: 'Python dans le cours de mathématiques' + sub_title: 'Python en mathématiques' + description: "Le langage de programmation Python offre des possibilités intéressantes pour enrichir le cours de mathématiques. De la théorie de Pythagore à la création de graphiques personnalisés, en passant par le traitement d'images numériques et la régression linéaire, tout devient plus clair avec Python." + contact: '' + teaser: 'https://www.youtube.com/embed/dO-E33G20co' + + python_programming: + title: 'Programmation avec Python' + sub_title: 'Programmation de base en Python' + description: "Apprendre les principes de base de la programmation ? C'est possible avec ce programme. Nous utilisons Python et apprenons tout sur les séquences, les structures de répétition et les structures de choix. C'est exactement ce qui est stipulé dans les objectifs finaux. Et plus encore !" + contact: 'Des questions ? Contactez-nous via team@aiopschool.be. La presse peut contacter Francis Wyffels via Francis@dwengo.org.' + teaser: 'https://www.youtube.com/embed/tqSnpAKLsu8' + + physical_computing: + title: 'Informatique physique' + sub_title: 'Programmer des robots en classe' + description: "Construire un instrument de musique, une voiture ou une station météorologique ? C'est possible avec Dwenguino, une plateforme de microcontrôleurs avec son propre environnement de programmation. Les élèves du primaire et du secondaire peuvent commencer à l'utiliser immédiatement. En vrai ou dans notre simulateur, en mode blocs ou textuel." + contact: 'Des questions ? Contactez-nous via team@aiopschool.be. La presse peut contacter Francis Wyffels via Francis@dwengo.org.' + teaser: 'https://www.youtube.com/embed/tqSnpAKLsu8' + curricula_files: + - file_title: 'Bouw jouw eigen robot' + file_info: 'Construisez votre propre robot de conduite.' + file_location: '/assets/files/physical_computing/bouwjouweigenrobot.pdf' + file_icon_name: 'link' + link_name: 'Download' diff --git a/backend/_i18n/nl.yml b/backend/_i18n/nl.yml new file mode 100644 index 00000000..3a29c50a --- /dev/null +++ b/backend/_i18n/nl.yml @@ -0,0 +1,381 @@ +# translate theme pages + +strengths: + title: Verrijk je lessen met AI en robotica! + innovative: Innovatief + research_based: Onderzoeksgedreven + inclusive: Inclusief + socially_relevant: Maatschappelijk relevant + innovative_text: We voegen steeds nieuwe projecten en methodieken toe aan onze projecten. + research_based_text: Alle lespakketten van Dwengo vzw zijn gebaseerd op gedegen wetenschappelijk onderzoek. + inclusive_text: We richten ons op alle kinderen en jongeren met een specifieke aandacht voor het evenwicht tussen meisjes en jongens en leerlingen uit kansengroepen. + socially_relevant_text: We zoeken projecten uit die passen binnen de actualiteit en maatschappelijke thema's. + summary: '' + main: Al onze pakketten zijn gebruiksvriendelijk, maatschappelijk relevant, wetenschappelijk onderbouwd, én inclusief. Leerkrachten over de hele wereld gingen hiermee reeds aan de slag. Jij ook? Verken de lesthema's op onze website! + quote: + text: Je maakt iets praktisch, je leert werken met hardware en je kan zelf iets nieuws creëren. + name: Matthias en Bruno + affiliation: 4e middelbaar + +curricula_page: + title: Onze lesthema's + read_more: Lees meer + curricula_files: Bestanden + + algorithms: + title: Algoritmes + sub_title: Algoritmes + description: 'Leerlingen uit de tweede en de derde graad (SO) leren hoe ze algoritmes kunnen gebruiken om problemen op te lossen. Ze leren hoe ze algoritmes kunnen ontwerpen en hoe ze de efficiëntie van algoritmes kunnen analyseren. Ze leren ook hoe ze algoritmes kunnen gebruiken om problemen op te lossen.' + contact: '' + + basics_ai: + title: Basisprincipes van AI + sub_title: Basisprincipes van AI + description: 'Onder dit lesthema bundelen we verschillende activiteiten waarin de basisprincipes van artificiële intelligentie (AI) aan bod komen. Leerlingen leren wat AI is, hoe het werkt en hoe het kan worden toegepast in verschillende domeinen.' + contact: '' + + kiks: + title: AI en Klimaat + sub_title: KIKS + description: 'Leerlingen uit de derde graad (SO) onderzoeken hoe planten zich via hun huidmondjes aanpassen aan de klimaatverandering. Daarvoor tellen ze deze huidmondjes met artificiële intelligentie en beeldherkenning.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + curricula_files: + - file_title: 'Projectfiche KIKS' + file_info: 'Dit is een kort overzicht van het KIKS-project met projectstructuur- en kenmerken.' + file_location: '/assets/files/kiks/projectfiche_kiks.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'De handleiding - ook verkrijgbaar in gedrukte versie' + file_info: 'We willen de leerkrachten achtergrondkennis geven over de inhoud van dit project: de klimaatverandering, de biologie van de huidmondjes en de manier waarop planten zich via de huidmondjes aan die klimaatverandering aanpassen, het wetenschappelijk onderzoek van de UGent en de Plantentuin Meise dat aan de grondslag ligt van dit project, burgerwetenschap, wat is artificiële intelligentie (AI), de geschiedenis van AI, het gebruik ervan en de ethiek errond, de principes van digitale beelden, de wiskunde achter de algoritmen en de fundamenten van de momenteel meest gebruikte AI-technieken. We geven ook aan hoe wij met KIKS aan de slag gegaan zijn in de klas.​' + file_location: '/assets/files/kiks/KIKS_handleiding_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Een leerlingencursus' + file_info: 'Met de leerlingencursus geven we een voorbeeld van een mogelijk, uitgebreid traject dat een leerkracht met leerlingen kan doorlopen. Het traject omvat de klimaatverandering, de biologie van de huidmondjes met een microscopie-opdracht, de manier waarop planten zich via de huidmondjes aan die klimaatverandering aanpassen, het wetenschappelijk onderzoek van de UGent en de Plantentuin Meise, het verzamelen van de data om een neuraal netwerk te trainen, wat is artificiële intelligentie (AI), de geschiedenis van AI, het gebruik ervan en de ethiek errond, de principes van digitale beelden, het werken met convoluties, de wiskunde achter het Perceptron-algoritme, het lineaire en niet-lineaire classificeren van data, en de fundamenten van machinaal leren.' + file_location: '/assets/files/kiks/KIKS_leerlingencursus_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'De leerplandoelen' + file_info: "Binnen het KIKS-project kunnen behoorlijk veel leerdoelen aan bod komen. De leerkracht bepaalt zelf welke leerdoelen in verband gebracht worden met het project. Bovendien biedt het project heel wat mogelijkheden om de leerlingen actief en zelfstandig te laten leren en om ICT-vaardigheden bij te brengen. KIKS kan ook gebruikt worden om een onderzoeksopdracht uit te werken.\n In de minimumdoelen en leerplannen van de verschillende koepels zijn heel wat leerdoelen te vinden die KIKS linken met biologie, aardrijkskunde en wiskunde." + file_location: '/assets/files/kiks/Leerdoelen-KIKS.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Het maken van een nagellakafdruk van een deel van een blad' + file_info: "Om het aantal huidmondjes op een deel van een blad van een plant te kennen, bekijken we het bladoppervlak onder de microscoop. We kunnen daarvoor een stuk van de flinterdunne cuticula van het blad verwijderen, maar bij sommige planten lukt dat niet zo goed, bijvoorbeeld door de stugheid van het blad.\nDit kan echter opgevangen worden door dezelfde methode te gebruiken als de onderzoekers van de Plantentuin Meise, nl. een afdruk nemen van een deel van het bladoppervlak met doorzichtige nagellak.\nHet miscroscopische beeld kan met een smartphone gefotografeerd worden." + file_location: 'https://www.youtube.com/watch?v=JptF3jhOV5k' + file_icon_name: 'play_arrow' + link_name: 'Kijk' + + socialrobot: + title: Sociale robot + sub_title: Robotica in de klas + description: 'Terwijl leerlingen uit de eerste graad (SO) een sociale robot knutselen en programmeren, leren ze complexe problemen oplossen via computationeel denken. Spelenderwijs werken ze aan de nieuwe eindtermen rond digitale competenties.' + contact: Vragen? Contacteer ons via team@aiopschool.be. De pers kan contact opnemen met Francis wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Projectfiche Sociale Robot' + file_info: "Dit is een kort overzicht van het 'Sociale Robot'-project met projectstructuur en -kenmerken." + file_location: '/assets/files/socialrobot/projectfiche_socialerobot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Collage sociale robots' + file_info: 'De robots op deze foto zijn realisaties van leerlingen van de eerste graad van het secundair onderwijs of zijn prototypes.​' + file_location: '/assets/files/socialrobot/collage.png' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Constructiekit voor 'Sociale Robot'-project" + file_info: '​Wil jij dat jouw leerlingen ook een sociale robot ontwerpen en bouwen? Dat kan via de constructiekit van Dwengo vzw. Op de figuur ontdek je de huidige samenstelling van de constructiekit. Geïnteresseerd? Contacteer ons via e-mail, bekijk de kit in de shop, of vind meer informatie op deze projectpagina.' + file_location: '/assets/files/socialrobot/constructiekit_socialerobot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Van project naar minimumdoelen eerste graad A-stroom' + file_info: "De minimumdoelen waaraan gewerkt kan worden, worden gelinkt aan de verschillende fasen van het 'Sociale Robot'-project.​" + file_location: '/assets/files/socialrobot/MinimumdoelenA-stroomSocialeRobot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Van project naar minimumdoelen eerste graad B-stroom' + file_info: "De minimumdoelen waaraan gewerkt kan worden, worden gelinkt aan de verschillende fasen van het 'Sociale Robot'-project.​" + file_location: '/assets/files/socialrobot/minimumdoelenBstroomsocialerobot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Van project naar minimumdoelen tweede graad' + file_info: "De minimumdoelen waaraan gewerkt kan worden, worden gelinkt aan de verschillende fasen van het 'Sociale Robot'-project.​" + file_location: '/assets/files/socialrobot/minimumdoelentweedegraadsocialerobot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotiemachine (onvolledig) - Computationeel denken (unplugged activiteit)' + file_info: '​Unplugged activiteit' + file_location: '/assets/files/socialrobot/emotiemachine_gewoon_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotiemachine (opdracht) - Computationeel denken (unplugged activiteit)' + file_info: 'Unplugged activiteit​' + file_location: '/assets/files/socialrobot/emotiemachine_gids.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotiemachine (volledig) - Computationeel denken (unplugged activiteit)' + file_info: 'Unplugged activiteit​' + file_location: '/assets/files/socialrobot/emotiemachine_ingevuld_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Emotiemachine met ledmatrix - Computationeel denken (unplugged activiteit)' + file_info: 'Unplugged activiteit​' + file_location: '/assets/files/socialrobot/emotiemachine_matrices_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Ficheboekje voor leerkrachten' + file_info: 'De gebundelde fiches waarin het gebruik van de Dwenguino en van de sensoren en actuatoren uit de doeken wordt gedaan.' + file_location: '/assets/files/socialrobot/ficheboekje_lkr.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Fiches voor de leerlingen' + file_info: 'In deze fiches wordt het gebruik van de Dwenguino en van de sensoren en actuatoren uit de doeken gedaan.​' + file_location: '/assets/files/socialrobot/fiches_lln.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Maak een gezicht - Computationeel denken (unplugged activiteit)' + file_info: 'Unplugged activiteit​' + file_location: '/assets/files/socialrobot/maakeengezicht_activiteit.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Opgaven van opdrachten in de MOOC' + file_info: '​Een overzicht van de programmeeroefeningen in de MOOC' + file_location: '/assets/files/socialrobot/Opgaven_MOOC.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Poster 'Sociale Robot'-project" + file_info: 'De poster geeft de verschillende aspecten van het project weer.​' + file_location: '/assets/files/socialrobot/posterSocialeRobot_nl_Qo4ANmV.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Handleiding 'Hallo robot!'" + file_info: 'Met dit lesboekje breng je je eigen sociale robot tot leven.​' + file_location: '/assets/files/socialrobot/handleiding_hallo_robot.pdf' + file_icon_name: 'description' + link_name: 'Download' + + agriculture: + title: AI in de Landbouw + sub_title: AI in de landbouw + description: 'Rotte tomaten weghalen tijdens de oogst? Daar kan artificiële intelligentie bij helpen. Maar hoe? Leerlingen uit de tweede en derde graad (SO) gaan met AI aan de slag. Misschien kan een trillende lopende band het systeem nog beter maken?' + contact: Vragen? Contacteer ons via team@aiopschool.be. De pers kan contact opnemen met Francis wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Ontwerp' + file_info: 'Ontwerp van de lopende band' + file_location: '/assets/files/art/Transportband_InnoVET_RCH.zip' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'De lopende band' + file_info: 'Video van de detectie van tomaten' + file_location: 'https://www.youtube.com/watch?v=6TSqY4ECMU04' + file_icon_name: 'play_arrow' + link_name: 'Kijk' + + art: + title: AI in de Kunst + sub_title: Tekenrobots in de klas + description: 'Kunnen we kunst maken met artificiële intelligentie? Leerlingen uit de tweede en derde graad (SO) leven zich creatief uit met AI en reflecteren over het resultaat. Is dit kunst? Ook ontdekken ze hoe AI ons cultureel erfgoed kan beschermen.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + + wegostem: + title: WeGoSTEM + sub_title: Tekenrobots in de klas + description: 'We dagen kinderen van de derde graad (BO) uit om een kunstrobot die kan tekenen te programmeren. Spelenderwijs leren de kinderen heel wat STEM-vaardigheden, van techniek tot computationeel denken.' + contact: Vragen? Contacteer ons via team@aiopschool.be. De pers kan contact opnemen met Francis wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + + computational_thinking: + title: Computationeel denken + sub_title: Computationeel denken in de klas + description: 'Hoe kunnen we complexe problemen oplossen met behulp van een computer? Dankzij computationeel denken! Dat kan je leren via allerlei activiteiten met óf zonder computer. Wij helpen je alvast op weg.' + contact: Vragen? Contacteer ons via team@aiopschool.be. De pers kan contact opnemen met Francis wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Computationeel denken en programmeren in een STEM context' + file_info: 'Informatievideo over het project' + file_location: 'https://www.youtube.com/watch?v=Nifa0vooyKg' + file_icon_name: 'play_arrow' + link_name: 'Kijk' + - file_title: 'Concepten en aanpak computationeel denken' + file_info: 'Poster​' + file_location: '/assets/files/computational_thinking/CDposterDwengo2.png' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Vier concepten van Computationeel denken uitgelegd' + file_info: 'Presentatie over vier concepten van computationeel denken: decompositie, patroonherkenning, abstractie en algoritme. Je kan de pdf downloaden > openen > presenteren via CTRL-L en navigeren met de pijltjestoetsen.' + file_location: '/assets/files/computational_thinking/CD4concepten_33V3gBg.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Concepten en principes' + file_info: 'Overzicht​' + file_location: '/assets/files/computational_thinking/Icoontjes.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged activiteit - Emotiemachine (opdracht)' + file_info: 'Hoe kan je emoties stimuleren bij een robot?​' + file_location: '/assets/files/computational_thinking/emotiemachine_gids.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged activiteit - Emotiemachine' + file_info: 'Hoe kan je emoties stimuleren bij een robot?​' + file_location: '/assets/files/computational_thinking/emotiemachine_gewoon_nl.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged activiteit - Kleuren op nummer' + file_info: '​Afbeeldingen kunnen op veel manieren worden gerepresenteerd. In deze kleuren op nummer puzzel moet je een afbeelding reconstrueren, gebruikmakend van de gegeven lijst van nummers; deze lijst vertelt je in welke kleur je elk vierkant (‘pixel’) inkleurt.' + file_location: '/assets/files/computational_thinking/kleurenopnummer1.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged activiteit - Een menselijk computernetwerk' + file_info: 'In deze activiteit maken de leerlingen kennis met hoe overdracht van gegevens over het internet werkt. Computers, smartphones en andere toestellen die met elkaar verbonden zijn via het internet, kunnen met elkaar communiceren. Om elkaar te kunnen begrijpen, dient die communicatie volgens bepaalde afspraken te verlopen. We noemen deze afspraken een protocol.​' + file_location: '/assets/files/computational_thinking/menselijkComputernetwerk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged activiteit - Comprimeren' + file_info: 'Om afbeeldingen te verzenden over netwerken, willen we de informatie met zo weinig mogelijk data representeren. Daar komt compressie aan te pas. Met behulp van algoritmes worden afbeeldingen met zo weinig mogelijk getallen gerepresenteerd, maar wel op zo’n manier dat je de oorspronkelijke figuur nog kunt terugkrijgen. Andere algoritmes herstellen de oorspronkelijke afbeelding als de informatie de bestemming bereikt heeft.​' + file_location: '/assets/files/computational_thinking/puzzel-gecomprimeerdepixel11.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged activiteit - Programmeer eens een mens' + file_info: 'Computers kunnen niet interpreteren en voeren dus letterlijk iedere instructie uit die je ze geeft. De uitdaging van de programmeur bestaat erin problemen op te lossen door ze op te delen in kleine stappen die uitvoerbaar zijn door de computer, en de instructies op de juiste manier aan de computer te geven.​' + file_location: '/assets/files/computational_thinking/programmeerEensEenMens.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Unplugged activiteit - Zoektocht naar spraak' + file_info: 'Het locked-in syndroom is een van de ergste medische aandoeningen. Je bent volledig verlamd, behalve dat je misschien nog kunt knipperen met een oog. Je intelligente geest zit opgesloten in een nutteloos lichaam: je kan alles voelen, maar niet communiceren. Het kan iedereen overkomen, uit het niets, als gevolg van een beroerte. Als je mensen met het locked-in syndroom zou willen helpen, word je dan best arts of verpleegkundige? Of kan je als computerwetenschapper ook helpen?​' + file_location: '/assets/files/computational_thinking/ZoektochtNaarSpraak.pdf' + file_icon_name: 'description' + link_name: 'Download' + + math_with_python: + title: Python in de Wiskundeles + sub_title: Python in wiskunde + description: 'De programmeertaal Python biedt toffe mogelijkheden om de wiskundeles te verrijken. Van de stelling van Pythagoras tot de opmaak van eigen grafieken, digitale beeldverwerking en lineaire regressie, alles wordt duidelijker met Python.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + + python_programming: + title: Programmeren met Python + sub_title: Basis programmeren in Python + description: 'De basisprincipes van programmeren aanleren? Dat kan perfect via dit pakket. We gaan aan de slag met Python en leren alles over sequenties, herhalingsstructuur en keuzestructuur. Dat is precies wat er in de eindtermen staat. En meer!' + contact: Vragen? Contacteer ons via team@aiopschool.be. De pers kan contact opnemen met Francis wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + + stem: + title: Python in STEM + sub_title: Datavisualisaties met Python + description: 'In dit pakket leer je complexe problemen eenvoudiger én sneller aanpakken met de programmeertaal Python. Programmeren kan immers een verbindende rol spelen tussen wetenschap, techniek, ontwerp en toegepaste wiskunde. Kortom, dankzij Python halen we het beste uit STEM.' + contact: Vragen? Contacteer ons via team@aiopschool.be. De pers kan contact opnemen met Francis wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + + care: + title: AI in de Zorg + sub_title: AI systemen die helpen in de zorg + description: 'Ziekenhuizen maken vandaag al gebruik van artificiële intelligentie. Leerlingen uit de tweede en derde graad (SO) ontdekken welke systemen er bestaan en hoe ze dokters helpen om beslissingen te nemen. Zo leren leerlingen de principes van de beslissingsboom, een veelgebruikte techniek in machine learning.' + contact: '' + teaser: https://www.youtube.com/embed/dO-E33G20co + curricula_files: + - file_title: 'Projectfiche AI in de Zorg' + file_info: 'Dit is een kort overzicht van project AI in de Zorg met projectstructuur- en kenmerken.' + file_location: '/assets/files/care/projectfiche_aiindezorg.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Sprouts' + file_info: "Een spel ter inleiding op 'Grafen'.​" + file_location: '/assets/files/care/Sprouts.mov' + file_icon_name: 'play_arrow' + link_name: 'Download' + - file_title: 'Uitleg bij oefeningen grafen uit de leerlingencursus' + file_info: 'Welke figuren stellen er dezelfde graaf voor? Een meer formele werkwijze.​' + file_location: '/assets/files/care/dezelfdegraafFormeel.mov' + file_icon_name: 'play_arrow' + link_name: 'Download' + - file_title: 'Uitleg bij oefeningen grafen uit de leerlingencursus' + file_info: 'Welke figuren stellen er dezelfde graaf voor? Een werkwijze met kleuren.​' + file_location: '/assets/files/care/dezelfdeGraaf.mov' + file_icon_name: 'play_arrow' + link_name: 'Download' + - file_title: 'De leerlingencursus - Finaliteit Doorstroom' + file_info: 'Leerlingencursus' + file_location: '/assets/files/care/AIindeZorg_doorstroom_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Kaartenset (unplugged activiteit) - Ook verkrijgbaar in gedrukte versie' + file_info: 'Aan de hand van deze kaartenset kan je de leerlingen laten stilstaan bij de ethische aspecten van nieuwe technologieën. Hoe zit het met privacy? Komen de sociale contacten niet in het gedrang? Welke technologieën worden met open armen ontvangen? Wat is niet wenselijk? Zijn de nieuwe technologieën voor iedereen betaalbaar?' + file_location: '/assets/files/care/Kaartset_AIIndeZorg_AIOpSchool_Dwengo.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Handleiding kaartenset' + file_info: 'Deze handleiding voorziet extra uitleg bij de kaartenset.' + file_location: '/assets/files/care/AIIndeZorgKaartenset_UitlegVoorLeerkracht.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Beslissingsboom 'mBrAIn'-project in niet-compacte vorm" + file_info: "In het onderzoeksproject 'mBrain', dat de ontwikkeling van een app die een migraineaanval voorspelt, als doel heeft, werd een beslissingsboom geconstrueerd. Met deze beslissingsboom wordt een probleem van binaire classificatie aangepakt: er zijn slechts twee klassen: ‘Migraine’ en ‘Geen migraine’. (Bronnen: Femke Ongenae. (2021), UGent; Van Hoecke, S., Ongenae, F., Paemeleire, K., & Vandenbussche, N. (2020). App moet hoofdpijn voorspellen. EOS Wetenschap Special, Technologie en gezondheid, 25.) Wij hebben de vorm van deze beslissingsboom omgevormd tot een binaire beslissingsboom om aan te tonen dat deze vorm niet altijd gebruiksvriendelijk is. Voor meer uitleg zie hoofdstuk 4 van de leerlingencursus van dit project.​" + file_location: '/assets/files/care/MBrainBeslissingsboom.png' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Zoektocht naar spraak - Computationeel denken (unplugged activiteit)' + file_info: 'Het locked-in syndroom is een van de ergste medische aandoeningen. Je bent volledig verlamd, behalve dat je misschien nog kunt knipperen met een oog. Je intelligente geest zit opgesloten in een nutteloos lichaam: je kan alles voelen, maar niet communiceren. Het kan iedereen overkomen, uit het niets, als gevolg van een beroerte. Als je mensen met het locked-in syndroom zou willen helpen, word je dan best arts of verpleegkundige? Of kan je als computerwetenschapper ook helpen?​' + file_location: '/assets/files/care/ZoektochtNaarSpraak.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Sociale robots en stellingenspel (unplugged activiteit)' + file_info: 'In deze presentatie maken leerlingen eerst kennis met sociale robots. Nadien worden de leerlingen aan de hand van enkele stellingen uitgedaagd om stil te staan bij de ethische aspecten van nieuwe technologieën.​ Hoe zit het met privacy? Komen de sociale contacten niet in het gedrang? Welke technologieën worden met open armen ontvangen? Wat is niet wenselijk? Zijn de nieuwe technologieën voor iedereen betaalbaar?' + file_location: '/assets/files/care/StellingenspelByDwengo.pdf' + file_icon_name: 'description' + link_name: 'Download' + + chatbot: + title: Taaltechnologie + sub_title: Aan de slag met een chatbot + description: 'Waar taal en technologie samenkomen, ontstaat het domein van Natural Language Processing. Kan een computer teksten begrijpen, vertalen of zelfs schrijven? Kan een computer emoties herkennen? Leerlingen uit de tweede en derde graad (SO) leren er alles over in dit pakket.' + contact: Vragen? Contacteer ons via team@aiopschool.be. De pers kan contact opnemen met Francis wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Projectfiche Chatbot' + file_info: 'Dit is een kort overzicht van Chatbot-project met projectstructuur- en kenmerken.' + file_location: '/assets/files/chatbot/projectfiche_chatbot.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'BrAInfood Chatbots' + file_info: 'In deze brAInfood - gericht naar jongeren - geeft het Kenniscentrum Data & Maatschappij meer informatie over chatbots. De brAInfood bevat een fictief verhaal over Lotte die praat met een chatbot, en vermoedelijk informatie over haar doorgeeft aan bedrijven. Verder worden enkele aandachtspunten met betrekking tot chatbots toegelicht, alsook enkele tips voor jongeren om hun (persoons)gegevens beter te beschermen. Op die manier willen we jongeren bewuster maken van de werking van chatbots en hen stimuleren te reflecteren over de gegevens die van hen worden verzameld.​' + file_location: '/assets/files/chatbot/Brainfood13_Chatbots_NL.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'BrAInfood Gepersonaliseerde nieuwsberichten' + file_info: 'In deze brAInfood van het Kenniscentrum Data & Maatschappij worden tips gegeven over hoe je de controle houdt over je newsfeed.​' + file_location: '/assets/files/chatbot/brainfoodaanbevelingnieuws.jpg' + file_icon_name: 'description' + link_name: 'Download' + - file_title: "Handleiding 'Chatbot' - Ook verkrijgbaar in gedrukte versie" + file_info: "​Leerkrachten verwerven via deze handleiding voldoende achtergrondinformatie om met (een deel van) het 'Chatbot'-project aan de slag te gaan in de klas. Het boek behandelt verschillende aspecten van taaltechnologie, zoals de geschiedenis van de artificiële intelligentie, de ethische aspecten ervan, sentimentanalyse en cyberpestdetectie, chatbots, sprekende digitale assistenten, en auteursherkenning. Er wordt ook ingegaan op de STEM-eindtermen en de eindtermen rond digitale competentie en mediawijsheid." + file_location: '/assets/files/chatbot/Chatbot_handleiding_eerstedruk.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Improbotics - Lesmap - Leerkrachtenversie' + file_info: 'In de theatervoorstelling Improbotics improviseert een sociale robot mee in de scènes. In de lesmap vind je informatie over de gebruikte technologieën.​' + file_location: '/assets/files/chatbot/Improbotics_lesmap_Leerkracht.pdf' + file_icon_name: 'description' + link_name: 'Download' + - file_title: 'Improbotics - Lesmap - Leerlingen' + file_info: 'In de theatervoorstelling Improbotics improviseert een sociale robot mee in de scènes. In de lesmap vind je informatie over de gebruikte technologieën.​' + file_location: '/assets/files/chatbot/Improbotics_lesmap_Leerling.pdf' + file_icon_name: 'description' + link_name: 'Download' + + physical_computing: + title: Physical computing + sub_title: Programmeer robots in de klas + description: 'Een muziekinstrument, auto of weerstation bouwen? Dat kan met Dwenguino, een microcontrollerplatform met een eigen programmeeromgeving. Leerlingen uit zowel basis- als secundair onderwijs kunnen er meteen mee aan de slag. In het echt of in onze simulator, blokgebaseerd of tekstueel. ' + contact: Vragen? Contacteer ons via team@aiopschool.be. De pers kan contact opnemen met Francis wyffels via Francis@dwengo.org. + teaser: https://www.youtube.com/embed/tqSnpAKLsu8 + curricula_files: + - file_title: 'Bouw jouw eigen robot' + file_info: 'Bouw je eigen rijdende robot.' + file_location: '/assets/files/physical_computing/bouwjouweigenrobot.pdf' + file_icon_name: 'description' + link_name: 'Download' diff --git a/backend/config.js b/backend/config.js new file mode 100644 index 00000000..be42027c --- /dev/null +++ b/backend/config.js @@ -0,0 +1,7 @@ +// Can be placed in dotenv but found it redundant +// Import dotenv from "dotenv"; +// Load .env file +// Dotenv.config(); +export const DWENGO_API_BASE = 'https://dwengo.org/backend/api'; +export const FALLBACK_LANG = 'nl'; +export const FALLBACK_SEQ_NUM = 1; diff --git a/backend/eslint.config.ts b/backend/eslint.config.ts new file mode 100644 index 00000000..6b696021 --- /dev/null +++ b/backend/eslint.config.ts @@ -0,0 +1,21 @@ +import globals from 'globals'; +import rootConfig from '../eslint.config'; + +export default [ + ...rootConfig, + { + languageOptions: { + globals: globals.node, + }, + }, + + { + files: ['tests/**/*.ts'], + languageOptions: { + globals: globals.node, + }, + rules: { + 'no-console': 'off', + }, + }, +]; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 00000000..9d7e886a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,56 @@ +{ + "name": "dwengo-1-backend", + "version": "0.0.1", + "description": "Backend for Dwengo-1", + "private": true, + "type": "module", + "scripts": { + "build": "cross-env NODE_ENV=production tsc --project tsconfig.json", + "dev": "cross-env NODE_ENV=development tsx watch --env-file=.env.development.local src/app.ts", + "start": "cross-env NODE_ENV=production node --env-file=.env dist/app.js", + "format": "prettier --write src/", + "format-check": "prettier --check src/", + "lint": "eslint . --fix", + "test:unit": "vitest" + }, + "dependencies": { + "@mikro-orm/core": "6.4.9", + "@mikro-orm/knex": "6.4.9", + "@mikro-orm/postgresql": "6.4.9", + "@mikro-orm/reflection": "6.4.9", + "@mikro-orm/sqlite": "6.4.9", + "axios": "^1.8.2", + "cors": "^2.8.5", + "cross": "^1.0.0", + "cross-env": "^7.0.3", + "dotenv": "^16.4.7", + "express": "^5.0.1", + "express-jwt": "^8.5.1", + "gift-pegjs": "^1.0.2", + "isomorphic-dompurify": "^2.22.0", + "js-yaml": "^4.1.0", + "jsonpath-plus": "^10.3.0", + "jwks-rsa": "^3.1.0", + "loki-logger-ts": "^1.0.2", + "marked": "^15.0.7", + "response-time": "^2.3.3", + "swagger-ui-express": "^5.0.1", + "uuid": "^11.1.0", + "winston": "^3.17.0", + "winston-loki": "^6.1.3" + }, + "devDependencies": { + "@mikro-orm/cli": "6.4.9", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.13.4", + "@types/response-time": "^2.3.8", + "@types/swagger-ui-express": "^4.1.8", + "globals": "^15.15.0", + "ts-node": "^10.9.2", + "tsx": "^4.19.3", + "typescript": "^5.7.3", + "vitest": "^3.0.6" + } +} diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 00000000..eb715a1c --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,37 @@ +import express, { Express } from 'express'; +import { initORM } from './orm.js'; +import { authenticateUser } from './middleware/auth/auth.js'; +import cors from './middleware/cors.js'; +import { getLogger, Logger } from './logging/initalize.js'; +import { responseTimeLogger } from './logging/responseTimeLogger.js'; +import responseTime from 'response-time'; +import { EnvVars, getNumericEnvVar } from './util/envvars.js'; +import apiRouter from './routes/router.js'; +import swaggerMiddleware from './swagger.js'; +import swaggerUi from 'swagger-ui-express'; + +const logger: Logger = getLogger(); + +const app: Express = express(); +const port: string | number = getNumericEnvVar(EnvVars.Port); + +app.use(express.json()); +app.use(cors); +app.use(authenticateUser); +// Add response time logging +app.use(responseTime(responseTimeLogger)); + +// Swagger +app.get('/api', apiRouter); + +app.use('/api-docs', swaggerUi.serve, swaggerMiddleware); + +async function startServer() { + await initORM(); + + app.listen(port, () => { + logger.info(`Server is running at http://localhost:${port}/api`); + }); +} + +await startServer(); diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 00000000..69af5d74 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,12 @@ +import { EnvVars, getEnvVar } from './util/envvars.js'; +import { Language } from './entities/content/language.js'; + +// API +export const DWENGO_API_BASE = getEnvVar(EnvVars.LearningContentRepoApiBaseUrl); +export const FALLBACK_LANG = getEnvVar(EnvVars.FallbackLanguage); + +// Logging +export const LOG_LEVEL: string = 'development' === process.env.NODE_ENV ? 'debug' : 'info'; +export const LOKI_HOST: string = process.env.LOKI_HOST || 'http://localhost:3102'; + +export const FALLBACK_SEQ_NUM = 1; diff --git a/backend/src/controllers/assignments.ts b/backend/src/controllers/assignments.ts new file mode 100644 index 00000000..03332469 --- /dev/null +++ b/backend/src/controllers/assignments.ts @@ -0,0 +1,76 @@ +import { Request, Response } from 'express'; +import { createAssignment, getAllAssignments, getAssignment, getAssignmentsSubmissions } from '../services/assignments.js'; +import { AssignmentDTO } from '../interfaces/assignment.js'; + +// Typescript is annoy with with parameter forwarding from class.ts +interface AssignmentParams { + classid: string; + id: string; +} + +export async function getAllAssignmentsHandler(req: Request, res: Response): Promise { + const classid = req.params.classid; + const full = req.query.full === 'true'; + + const assignments = await getAllAssignments(classid, full); + + res.json({ + assignments: assignments, + }); +} + +export async function createAssignmentHandler(req: Request, res: Response): Promise { + const classid = req.params.classid; + const assignmentData = req.body as AssignmentDTO; + + if (!assignmentData.description || !assignmentData.language || !assignmentData.learningPath || !assignmentData.title) { + res.status(400).json({ + error: 'Missing one or more required fields: title, description, learningPath, language', + }); + return; + } + + const assignment = await createAssignment(classid, assignmentData); + + if (!assignment) { + res.status(500).json({ error: 'Could not create assignment ' }); + return; + } + + res.status(201).json({ assignment: assignment }); +} + +export async function getAssignmentHandler(req: Request, res: Response): Promise { + const id = +req.params.id; + const classid = req.params.classid; + + if (isNaN(id)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const assignment = await getAssignment(classid, id); + + if (!assignment) { + res.status(404).json({ error: 'Assignment not found' }); + return; + } + + res.json(assignment); +} + +export async function getAssignmentsSubmissionsHandler(req: Request, res: Response): Promise { + const classid = req.params.classid; + const assignmentNumber = +req.params.id; + + if (isNaN(assignmentNumber)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const submissions = await getAssignmentsSubmissions(classid, assignmentNumber); + + res.json({ + submissions: submissions, + }); +} diff --git a/backend/src/controllers/auth.ts b/backend/src/controllers/auth.ts new file mode 100644 index 00000000..409ead0c --- /dev/null +++ b/backend/src/controllers/auth.ts @@ -0,0 +1,33 @@ +import { EnvVars, getEnvVar } from '../util/envvars.js'; + +type FrontendIdpConfig = { + authority: string; + clientId: string; + scope: string; + responseType: string; +}; + +type FrontendAuthConfig = { + student: FrontendIdpConfig; + teacher: FrontendIdpConfig; +}; + +const SCOPE = 'openid profile email'; +const RESPONSE_TYPE = 'code'; + +export function getFrontendAuthConfig(): FrontendAuthConfig { + return { + student: { + authority: getEnvVar(EnvVars.IdpStudentUrl), + clientId: getEnvVar(EnvVars.IdpStudentClientId), + scope: SCOPE, + responseType: RESPONSE_TYPE, + }, + teacher: { + authority: getEnvVar(EnvVars.IdpTeacherUrl), + clientId: getEnvVar(EnvVars.IdpTeacherClientId), + scope: SCOPE, + responseType: RESPONSE_TYPE, + }, + }; +} diff --git a/backend/src/controllers/classes.ts b/backend/src/controllers/classes.ts new file mode 100644 index 00000000..ca2f5698 --- /dev/null +++ b/backend/src/controllers/classes.ts @@ -0,0 +1,77 @@ +import { Request, Response } from 'express'; +import { createClass, getAllClasses, getClass, getClassStudents, getClassStudentsIds, getClassTeacherInvitations } from '../services/class.js'; +import { ClassDTO } from '../interfaces/class.js'; + +export async function getAllClassesHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + const classes = await getAllClasses(full); + + res.json({ + classes: classes, + }); +} + +export async function createClassHandler(req: Request, res: Response): Promise { + const classData = req.body as ClassDTO; + + if (!classData.displayName) { + res.status(400).json({ + error: 'Missing one or more required fields: displayName', + }); + return; + } + + const cls = await createClass(classData); + + if (!cls) { + res.status(500).json({ error: 'Something went wrong while creating class' }); + return; + } + + res.status(201).json({ class: cls }); +} + +export async function getClassHandler(req: Request, res: Response): Promise { + try { + const classId = req.params.id; + const cls = await getClass(classId); + + if (!cls) { + res.status(404).json({ error: 'Class not found' }); + return; + } + cls.endpoints = { + self: `${req.baseUrl}/${req.params.id}`, + invitations: `${req.baseUrl}/${req.params.id}/invitations`, + assignments: `${req.baseUrl}/${req.params.id}/assignments`, + students: `${req.baseUrl}/${req.params.id}/students`, + }; + + res.json(cls); + } catch (error) { + console.error('Error fetching learning objects:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +export async function getClassStudentsHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const full = req.query.full === 'true'; + + const students = full ? await getClassStudents(classId) : await getClassStudentsIds(classId); + + res.json({ + students: students, + }); +} + +export async function getTeacherInvitationsHandler(req: Request, res: Response): Promise { + const classId = req.params.id; + const full = req.query.full === 'true'; // TODO: not implemented yet + + const invitations = await getClassTeacherInvitations(classId, full); + + res.json({ + invitations: invitations, + }); +} diff --git a/backend/src/controllers/groups.ts b/backend/src/controllers/groups.ts new file mode 100644 index 00000000..b7bfd212 --- /dev/null +++ b/backend/src/controllers/groups.ts @@ -0,0 +1,95 @@ +import { Request, Response } from 'express'; +import { createGroup, getAllGroups, getGroup, getGroupSubmissions } from '../services/groups.js'; +import { GroupDTO } from '../interfaces/group.js'; + +// Typescript is annoywith with parameter forwarding from class.ts +interface GroupParams { + classid: string; + assignmentid: string; + groupid?: string; +} + +export async function getGroupHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + const full = req.query.full === 'true'; + const assignmentId = +req.params.assignmentid; + + if (isNaN(assignmentId)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const groupId = +req.params.groupid!; // Can't be undefined + + if (isNaN(groupId)) { + res.status(400).json({ error: 'Group id must be a number' }); + return; + } + + const group = await getGroup(classId, assignmentId, groupId, full); + + res.json(group); +} + +export async function getAllGroupsHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + const full = req.query.full === 'true'; + + const assignmentId = +req.params.assignmentid; + + if (isNaN(assignmentId)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const groups = await getAllGroups(classId, assignmentId, full); + + res.json({ + groups: groups, + }); +} + +export async function createGroupHandler(req: Request, res: Response): Promise { + const classid = req.params.classid; + const assignmentId = +req.params.assignmentid; + + if (isNaN(assignmentId)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const groupData = req.body as GroupDTO; + const group = await createGroup(groupData, classid, assignmentId); + + if (!group) { + res.status(500).json({ error: 'Something went wrong while creating group' }); + return; + } + + res.status(201).json({ group: group }); +} + +export async function getGroupSubmissionsHandler(req: Request, res: Response): Promise { + const classId = req.params.classid; + // Const full = req.query.full === 'true'; + + const assignmentId = +req.params.assignmentid; + + if (isNaN(assignmentId)) { + res.status(400).json({ error: 'Assignment id must be a number' }); + return; + } + + const groupId = +req.params.groupid!; // Can't be undefined + + if (isNaN(groupId)) { + res.status(400).json({ error: 'Group id must be a number' }); + return; + } + + const submissions = await getGroupSubmissions(classId, assignmentId, groupId); + + res.json({ + submissions: submissions, + }); +} diff --git a/backend/src/controllers/learning-objects.ts b/backend/src/controllers/learning-objects.ts new file mode 100644 index 00000000..455a4006 --- /dev/null +++ b/backend/src/controllers/learning-objects.ts @@ -0,0 +1,69 @@ +import { Request, Response } from 'express'; +import { FALLBACK_LANG } from '../config.js'; +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../interfaces/learning-content.js'; +import learningObjectService from '../services/learning-objects/learning-object-service.js'; +import { EnvVars, getEnvVar } from '../util/envvars.js'; +import { Language } from '../entities/content/language.js'; +import { BadRequestException } from '../exceptions.js'; +import attachmentService from '../services/learning-objects/attachment-service.js'; +import { NotFoundError } from '@mikro-orm/core'; + +function getLearningObjectIdentifierFromRequest(req: Request): LearningObjectIdentifier { + if (!req.params.hruid) { + throw new BadRequestException('HRUID is required.'); + } + return { + hruid: req.params.hruid as string, + language: (req.query.language || getEnvVar(EnvVars.FallbackLanguage)) as Language, + version: parseInt(req.query.version as string), + }; +} + +function getLearningPathIdentifierFromRequest(req: Request): LearningPathIdentifier { + if (!req.query.hruid) { + throw new BadRequestException('HRUID is required.'); + } + return { + hruid: req.params.hruid as string, + language: (req.query.language as Language) || FALLBACK_LANG, + }; +} + +export async function getAllLearningObjects(req: Request, res: Response): Promise { + const learningPathId = getLearningPathIdentifierFromRequest(req); + const full = req.query.full; + + let learningObjects: FilteredLearningObject[] | string[]; + if (full) { + learningObjects = await learningObjectService.getLearningObjectsFromPath(learningPathId); + } else { + learningObjects = await learningObjectService.getLearningObjectIdsFromPath(learningPathId); + } + + res.json(learningObjects); +} + +export async function getLearningObject(req: Request, res: Response): Promise { + const learningObjectId = getLearningObjectIdentifierFromRequest(req); + + const learningObject = await learningObjectService.getLearningObjectById(learningObjectId); + res.json(learningObject); +} + +export async function getLearningObjectHTML(req: Request, res: Response): Promise { + const learningObjectId = getLearningObjectIdentifierFromRequest(req); + + const learningObject = await learningObjectService.getLearningObjectHTML(learningObjectId); + res.send(learningObject); +} + +export async function getAttachment(req: Request, res: Response): Promise { + const learningObjectId = getLearningObjectIdentifierFromRequest(req); + const name = req.params.attachmentName; + const attachment = await attachmentService.getAttachment(learningObjectId, name); + + if (!attachment) { + throw new NotFoundError(`Attachment ${name} not found`); + } + res.setHeader('Content-Type', attachment.mimeType).send(attachment.content); +} diff --git a/backend/src/controllers/learning-paths.ts b/backend/src/controllers/learning-paths.ts new file mode 100644 index 00000000..37f92d91 --- /dev/null +++ b/backend/src/controllers/learning-paths.ts @@ -0,0 +1,64 @@ +import { Request, Response } from 'express'; +import { themes } from '../data/themes.js'; +import { FALLBACK_LANG } from '../config.js'; +import learningPathService from '../services/learning-paths/learning-path-service.js'; +import { BadRequestException, NotFoundException } from '../exceptions.js'; +import { Language } from '../entities/content/language.js'; +import { + PersonalizationTarget, + personalizedForGroup, + personalizedForStudent, +} from '../services/learning-paths/learning-path-personalization-util.js'; + +/** + * Fetch learning paths based on query parameters. + */ +export async function getLearningPaths(req: Request, res: Response): Promise { + const hruids = req.query.hruid; + const themeKey = req.query.theme as string; + const searchQuery = req.query.search as string; + const language = (req.query.language as string) || FALLBACK_LANG; + + const forStudent = req.query.forStudent as string; + const forGroupNo = req.query.forGroup as string; + const assignmentNo = req.query.assignmentNo as string; + const classId = req.query.classId as string; + + let personalizationTarget: PersonalizationTarget | undefined; + + if (forStudent) { + personalizationTarget = await personalizedForStudent(forStudent); + } else if (forGroupNo) { + if (!assignmentNo || !classId) { + throw new BadRequestException('If forGroupNo is specified, assignmentNo and classId must also be specified.'); + } + personalizationTarget = await personalizedForGroup(classId, parseInt(assignmentNo), parseInt(forGroupNo)); + } + + let hruidList; + + if (hruids) { + hruidList = Array.isArray(hruids) ? hruids.map(String) : [String(hruids)]; + } else if (themeKey) { + const theme = themes.find((t) => t.title === themeKey); + if (theme) { + hruidList = theme.hruids; + } else { + throw new NotFoundException(`Theme "${themeKey}" not found.`); + } + } else if (searchQuery) { + const searchResults = await learningPathService.searchLearningPaths(searchQuery, language as Language, personalizationTarget); + res.json(searchResults); + return; + } else { + hruidList = themes.flatMap((theme) => theme.hruids); + } + + const learningPaths = await learningPathService.fetchLearningPaths( + hruidList, + language as Language, + `HRUIDs: ${hruidList.join(', ')}`, + personalizationTarget + ); + res.json(learningPaths.data); +} diff --git a/backend/src/controllers/questions.ts b/backend/src/controllers/questions.ts new file mode 100644 index 00000000..917b48ae --- /dev/null +++ b/backend/src/controllers/questions.ts @@ -0,0 +1,119 @@ +import { Request, Response } from 'express'; +import { createQuestion, deleteQuestion, getAllQuestions, getAnswersByQuestion, getQuestion } from '../services/questions.js'; +import { QuestionDTO, QuestionId } from '../interfaces/question.js'; +import { FALLBACK_LANG, FALLBACK_SEQ_NUM } from '../config.js'; +import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; +import { Language } from '../entities/content/language.js'; + +function getObjectId(req: Request, res: Response): LearningObjectIdentifier | null { + const { hruid, version } = req.params; + const lang = req.query.lang; + + if (!hruid || !version) { + res.status(400).json({ error: 'Missing required parameters.' }); + return null; + } + + return { + hruid, + language: (lang as Language) || FALLBACK_LANG, + version: +version, + }; +} + +function getQuestionId(req: Request, res: Response): QuestionId | null { + const seq = req.params.seq; + const learningObjectIdentifier = getObjectId(req, res); + + if (!learningObjectIdentifier) { + return null; + } + + return { + learningObjectIdentifier, + sequenceNumber: seq ? Number(seq) : FALLBACK_SEQ_NUM, + }; +} + +export async function getAllQuestionsHandler(req: Request, res: Response): Promise { + const objectId = getObjectId(req, res); + const full = req.query.full === 'true'; + + if (!objectId) { + return; + } + + const questions = await getAllQuestions(objectId, full); + + if (!questions) { + res.status(404).json({ error: `Questions not found.` }); + } else { + res.json(questions); + } +} + +export async function getQuestionHandler(req: Request, res: Response): Promise { + const questionId = getQuestionId(req, res); + + if (!questionId) { + return; + } + + const question = await getQuestion(questionId); + + if (!question) { + res.status(404).json({ error: `Question not found.` }); + } else { + res.json(question); + } +} + +export async function getQuestionAnswersHandler(req: Request, res: Response): Promise { + const questionId = getQuestionId(req, res); + const full = req.query.full === 'true'; + + if (!questionId) { + return; + } + + const answers = getAnswersByQuestion(questionId, full); + + if (!answers) { + res.status(404).json({ error: `Questions not found.` }); + } else { + res.json(answers); + } +} + +export async function createQuestionHandler(req: Request, res: Response): Promise { + const questionDTO = req.body as QuestionDTO; + + if (!questionDTO.learningObjectIdentifier || !questionDTO.author || !questionDTO.content) { + res.status(400).json({ error: 'Missing required fields: identifier and content' }); + return; + } + + const question = await createQuestion(questionDTO); + + if (!question) { + res.status(400).json({ error: 'Could not add question' }); + } else { + res.json(question); + } +} + +export async function deleteQuestionHandler(req: Request, res: Response): Promise { + const questionId = getQuestionId(req, res); + + if (!questionId) { + return; + } + + const question = await deleteQuestion(questionId); + + if (!question) { + res.status(400).json({ error: 'Could not find nor delete question' }); + } else { + res.json(question); + } +} diff --git a/backend/src/controllers/students.ts b/backend/src/controllers/students.ts new file mode 100644 index 00000000..6c253cff --- /dev/null +++ b/backend/src/controllers/students.ts @@ -0,0 +1,146 @@ +import { Request, Response } from 'express'; +import { + createStudent, + deleteStudent, + getAllStudents, + getStudent, + getStudentAssignments, + getStudentClasses, + getStudentGroups, + getStudentSubmissions, +} from '../services/students.js'; +import { ClassDTO } from '../interfaces/class.js'; +import { getAllAssignments } from '../services/assignments.js'; +import { getUserHandler } from './users.js'; +import { Student } from '../entities/users/student.entity.js'; +import { StudentDTO } from '../interfaces/student.js'; +import { getStudentRepository } from '../data/repositories.js'; +import { UserDTO } from '../interfaces/user.js'; + +// TODO: accept arguments (full, ...) +// TODO: endpoints +export async function getAllStudentsHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + + const studentRepository = getStudentRepository(); + + const students: StudentDTO[] | string[] = full ? await getAllStudents() : await getAllStudents(); + + if (!students) { + res.status(404).json({ error: `Student not found.` }); + return; + } + + res.status(201).json(students); +} + +export async function getStudentHandler(req: Request, res: Response): Promise { + const username = req.params.username; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const user = await getStudent(username); + + if (!user) { + res.status(404).json({ + error: `User with username '${username}' not found.`, + }); + return; + } + + res.status(201).json(user); +} + +export async function createStudentHandler(req: Request, res: Response) { + const userData = req.body as StudentDTO; + + if (!userData.username || !userData.firstName || !userData.lastName) { + res.status(400).json({ + error: 'Missing required fields: username, firstName, lastName', + }); + return; + } + + const newUser = await createStudent(userData); + res.status(201).json(newUser); +} + +export async function deleteStudentHandler(req: Request, res: Response) { + const username = req.params.username; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const deletedUser = await deleteStudent(username); + if (!deletedUser) { + res.status(404).json({ + error: `User with username '${username}' not found.`, + }); + return; + } + + res.status(200).json(deletedUser); +} + +export async function getStudentClassesHandler(req: Request, res: Response): Promise { + try { + const full = req.query.full === 'true'; + const username = req.params.id; + + const classes = await getStudentClasses(username, full); + + res.json({ + classes: classes, + endpoints: { + self: `${req.baseUrl}/${req.params.id}`, + classes: `${req.baseUrl}/${req.params.id}/invitations`, + questions: `${req.baseUrl}/${req.params.id}/assignments`, + students: `${req.baseUrl}/${req.params.id}/students`, + }, + }); + } catch (error) { + console.error('Error fetching learning objects:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +// TODO +// Might not be fully correct depending on if +// A class has an assignment, that all students +// Have this assignment. +export async function getStudentAssignmentsHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + const username = req.params.id; + + const assignments = getStudentAssignments(username, full); + + res.json({ + assignments: assignments, + }); +} + +export async function getStudentGroupsHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + const username = req.params.id; + + const groups = await getStudentGroups(username, full); + + res.json({ + groups: groups, + }); +} + +export async function getStudentSubmissionsHandler(req: Request, res: Response): Promise { + const username = req.params.id; + + const submissions = await getStudentSubmissions(username); + + res.json({ + submissions: submissions, + }); +} diff --git a/backend/src/controllers/submissions.ts b/backend/src/controllers/submissions.ts new file mode 100644 index 00000000..1e66dbe9 --- /dev/null +++ b/backend/src/controllers/submissions.ts @@ -0,0 +1,59 @@ +import { Request, Response } from 'express'; +import { createSubmission, deleteSubmission, getSubmission } from '../services/submissions.js'; +import { Language, languageMap } from '../entities/content/language.js'; +import { SubmissionDTO } from '../interfaces/submission'; + +interface SubmissionParams { + hruid: string; + id: number; +} + +export async function getSubmissionHandler(req: Request, res: Response): Promise { + const lohruid = req.params.hruid; + const submissionNumber = +req.params.id; + + if (isNaN(submissionNumber)) { + res.status(400).json({ error: 'Submission number is not a number' }); + return; + } + + const lang = languageMap[req.query.language as string] || Language.Dutch; + const version = (req.query.version || 1) as number; + + const submission = await getSubmission(lohruid, lang, version, submissionNumber); + + if (!submission) { + res.status(404).json({ error: 'Submission not found' }); + return; + } + + res.json(submission); +} + +export async function createSubmissionHandler(req: Request, res: Response) { + const submissionDTO = req.body as SubmissionDTO; + + const submission = await createSubmission(submissionDTO); + + if (!submission) { + res.status(404).json({ error: 'Submission not added' }); + } else { + res.json(submission); + } +} + +export async function deleteSubmissionHandler(req: Request, res: Response) { + const hruid = req.params.hruid; + const submissionNumber = +req.params.id; + + const lang = languageMap[req.query.language as string] || Language.Dutch; + const version = (req.query.version || 1) as number; + + const submission = await deleteSubmission(hruid, lang, version, submissionNumber); + + if (!submission) { + res.status(404).json({ error: 'Submission not found' }); + } else { + res.json(submission); + } +} diff --git a/backend/src/controllers/teachers.ts b/backend/src/controllers/teachers.ts new file mode 100644 index 00000000..52e5e713 --- /dev/null +++ b/backend/src/controllers/teachers.ts @@ -0,0 +1,144 @@ +import { Request, Response } from 'express'; +import { + createTeacher, + deleteTeacher, + getAllTeachers, + getClassesByTeacher, + getClassIdsByTeacher, + getQuestionIdsByTeacher, + getQuestionsByTeacher, + getStudentIdsByTeacher, + getStudentsByTeacher, + getTeacher, +} from '../services/teachers.js'; +import { ClassDTO } from '../interfaces/class.js'; +import { StudentDTO } from '../interfaces/student.js'; +import { QuestionDTO, QuestionId } from '../interfaces/question.js'; +import { Teacher } from '../entities/users/teacher.entity.js'; +import { TeacherDTO } from '../interfaces/teacher.js'; +import { getTeacherRepository } from '../data/repositories.js'; + +export async function getAllTeachersHandler(req: Request, res: Response): Promise { + const full = req.query.full === 'true'; + + const teacherRepository = getTeacherRepository(); + + const teachers: TeacherDTO[] | string[] = full ? await getAllTeachers() : await getAllTeachers(); + + if (!teachers) { + res.status(404).json({ error: `Teacher not found.` }); + return; + } + + res.status(201).json(teachers); +} + +export async function getTeacherHandler(req: Request, res: Response): Promise { + const username = req.params.username; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const user = await getTeacher(username); + + if (!user) { + res.status(404).json({ + error: `User with username '${username}' not found.`, + }); + return; + } + + res.status(201).json(user); +} + +export async function createTeacherHandler(req: Request, res: Response) { + const userData = req.body as TeacherDTO; + + if (!userData.username || !userData.firstName || !userData.lastName) { + res.status(400).json({ + error: 'Missing required fields: username, firstName, lastName', + }); + return; + } + + const newUser = await createTeacher(userData); + res.status(201).json(newUser); +} + +export async function deleteTeacherHandler(req: Request, res: Response) { + const username = req.params.username; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const deletedUser = await deleteTeacher(username); + if (!deletedUser) { + res.status(404).json({ + error: `User with username '${username}' not found.`, + }); + return; + } + + res.status(200).json(deletedUser); +} + +export async function getTeacherClassHandler(req: Request, res: Response): Promise { + try { + const username = req.params.username as string; + const full = req.query.full === 'true'; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const classes: ClassDTO[] | string[] = full ? await getClassesByTeacher(username) : await getClassIdsByTeacher(username); + + res.status(201).json(classes); + } catch (error) { + console.error('Error fetching classes by teacher:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +export async function getTeacherStudentHandler(req: Request, res: Response): Promise { + try { + const username = req.params.username as string; + const full = req.query.full === 'true'; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const students: StudentDTO[] | string[] = full ? await getStudentsByTeacher(username) : await getStudentIdsByTeacher(username); + + res.status(201).json(students); + } catch (error) { + console.error('Error fetching students by teacher:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +export async function getTeacherQuestionHandler(req: Request, res: Response): Promise { + try { + const username = req.params.username as string; + const full = req.query.full === 'true'; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const questions: QuestionDTO[] | QuestionId[] = full ? await getQuestionsByTeacher(username) : await getQuestionIdsByTeacher(username); + + res.status(201).json(questions); + } catch (error) { + console.error('Error fetching questions by teacher:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/backend/src/controllers/themes.ts b/backend/src/controllers/themes.ts new file mode 100644 index 00000000..61a1a834 --- /dev/null +++ b/backend/src/controllers/themes.ts @@ -0,0 +1,33 @@ +import { Request, Response } from 'express'; +import { themes } from '../data/themes.js'; +import { loadTranslations } from '../util/translation-helper.js'; + +interface Translations { + curricula_page: { + [key: string]: { title: string; description?: string }; + }; +} + +export function getThemes(req: Request, res: Response) { + const language = (req.query.language as string)?.toLowerCase() || 'nl'; + const translations = loadTranslations(language); + const themeList = themes.map((theme) => ({ + key: theme.title, + title: translations.curricula_page[theme.title]?.title || theme.title, + description: translations.curricula_page[theme.title]?.description, + image: `https://dwengo.org/images/curricula/logo_${theme.title}.png`, + })); + + res.json(themeList); +} + +export function getThemeByTitle(req: Request, res: Response) { + const themeKey = req.params.theme; + const theme = themes.find((t) => t.title === themeKey); + + if (theme) { + res.json(theme.hruids); + } else { + res.status(404).json({ error: 'Theme not found' }); + } +} diff --git a/backend/src/controllers/users.ts b/backend/src/controllers/users.ts new file mode 100644 index 00000000..850c6549 --- /dev/null +++ b/backend/src/controllers/users.ts @@ -0,0 +1,91 @@ +import { Request, Response } from 'express'; +import { UserService } from '../services/users.js'; +import { UserDTO } from '../interfaces/user.js'; +import { User } from '../entities/users/user.entity.js'; + +export async function getAllUsersHandler(req: Request, res: Response, service: UserService): Promise { + try { + const full = req.query.full === 'true'; + + const users: UserDTO[] | string[] = full ? await service.getAllUsers() : await service.getAllUserIds(); + + if (!users) { + res.status(404).json({ error: `Users not found.` }); + return; + } + + res.status(201).json(users); + } catch (error) { + console.error('❌ Error fetching users:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +export async function getUserHandler(req: Request, res: Response, service: UserService): Promise { + try { + const username = req.params.username as string; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const user = await service.getUserByUsername(username); + + if (!user) { + res.status(404).json({ + error: `User with username '${username}' not found.`, + }); + return; + } + + res.status(201).json(user); + } catch (error) { + console.error('❌ Error fetching users:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +export async function createUserHandler(req: Request, res: Response, service: UserService, UserClass: new () => T) { + try { + console.log('req', req); + const userData = req.body as UserDTO; + + if (!userData.username || !userData.firstName || !userData.lastName) { + res.status(400).json({ + error: 'Missing required fields: username, firstName, lastName', + }); + return; + } + + const newUser = await service.createUser(userData, UserClass); + res.status(201).json(newUser); + } catch (error) { + console.error('❌ Error creating user:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +export async function deleteUserHandler(req: Request, res: Response, service: UserService) { + try { + const username = req.params.username; + + if (!username) { + res.status(400).json({ error: 'Missing required field: username' }); + return; + } + + const deletedUser = await service.deleteUser(username); + if (!deletedUser) { + res.status(404).json({ + error: `User with username '${username}' not found.`, + }); + return; + } + + res.status(200).json(deletedUser); + } catch (error) { + console.error('❌ Error deleting user:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} diff --git a/backend/src/data/assignments/assignment-repository.ts b/backend/src/data/assignments/assignment-repository.ts new file mode 100644 index 00000000..c3c457d3 --- /dev/null +++ b/backend/src/data/assignments/assignment-repository.ts @@ -0,0 +1,15 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { Assignment } from '../../entities/assignments/assignment.entity.js'; +import { Class } from '../../entities/classes/class.entity.js'; + +export class AssignmentRepository extends DwengoEntityRepository { + public findByClassAndId(within: Class, id: number): Promise { + return this.findOne({ within: within, id: id }); + } + public findAllAssignmentsInClass(within: Class): Promise { + return this.findAll({ where: { within: within } }); + } + public deleteByClassAndId(within: Class, id: number): Promise { + return this.deleteWhere({ within: within, id: id }); + } +} diff --git a/backend/src/data/assignments/group-repository.ts b/backend/src/data/assignments/group-repository.ts new file mode 100644 index 00000000..eb1b09e2 --- /dev/null +++ b/backend/src/data/assignments/group-repository.ts @@ -0,0 +1,31 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { Group } from '../../entities/assignments/group.entity.js'; +import { Assignment } from '../../entities/assignments/assignment.entity.js'; +import { Student } from '../../entities/users/student.entity.js'; + +export class GroupRepository extends DwengoEntityRepository { + public findByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number): Promise { + return this.findOne( + { + assignment: assignment, + groupNumber: groupNumber, + }, + { populate: ['members'] } + ); + } + public findAllGroupsForAssignment(assignment: Assignment): Promise { + return this.findAll({ + where: { assignment: assignment }, + populate: ['members'], + }); + } + public findAllGroupsWithStudent(student: Student): Promise { + return this.find({ members: student }, { populate: ['members'] }); + } + public deleteByAssignmentAndGroupNumber(assignment: Assignment, groupNumber: number) { + return this.deleteWhere({ + assignment: assignment, + groupNumber: groupNumber, + }); + } +} diff --git a/backend/src/data/assignments/submission-repository.ts b/backend/src/data/assignments/submission-repository.ts new file mode 100644 index 00000000..251823fa --- /dev/null +++ b/backend/src/data/assignments/submission-repository.ts @@ -0,0 +1,57 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { Group } from '../../entities/assignments/group.entity.js'; +import { Submission } from '../../entities/assignments/submission.entity.js'; +import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; +import { Student } from '../../entities/users/student.entity.js'; + +export class SubmissionRepository extends DwengoEntityRepository { + public findSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise { + return this.findOne({ + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + submissionNumber: submissionNumber, + }); + } + + public findMostRecentSubmissionForStudent(loId: LearningObjectIdentifier, submitter: Student): Promise { + return this.findOne( + { + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + submitter: submitter, + }, + { orderBy: { submissionNumber: 'DESC' } } + ); + } + + public findMostRecentSubmissionForGroup(loId: LearningObjectIdentifier, group: Group): Promise { + return this.findOne( + { + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + onBehalfOf: group, + }, + { orderBy: { submissionNumber: 'DESC' } } + ); + } + + public findAllSubmissionsForGroup(group: Group): Promise { + return this.find({ onBehalfOf: group }); + } + + public findAllSubmissionsForStudent(student: Student): Promise { + return this.find({ submitter: student }); + } + + public deleteSubmissionByLearningObjectAndSubmissionNumber(loId: LearningObjectIdentifier, submissionNumber: number): Promise { + return this.deleteWhere({ + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + submissionNumber: submissionNumber, + }); + } +} diff --git a/backend/src/data/classes/class-join-request-repository.ts b/backend/src/data/classes/class-join-request-repository.ts new file mode 100644 index 00000000..c1443c1c --- /dev/null +++ b/backend/src/data/classes/class-join-request-repository.ts @@ -0,0 +1,16 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { Class } from '../../entities/classes/class.entity.js'; +import { ClassJoinRequest } from '../../entities/classes/class-join-request.entity.js'; +import { Student } from '../../entities/users/student.entity.js'; + +export class ClassJoinRequestRepository extends DwengoEntityRepository { + public findAllRequestsBy(requester: Student): Promise { + return this.findAll({ where: { requester: requester } }); + } + public findAllOpenRequestsTo(clazz: Class): Promise { + return this.findAll({ where: { class: clazz } }); + } + public deleteBy(requester: Student, clazz: Class): Promise { + return this.deleteWhere({ requester: requester, class: clazz }); + } +} diff --git a/backend/src/data/classes/class-repository.ts b/backend/src/data/classes/class-repository.ts new file mode 100644 index 00000000..0ceed98e --- /dev/null +++ b/backend/src/data/classes/class-repository.ts @@ -0,0 +1,23 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { Class } from '../../entities/classes/class.entity.js'; +import { Student } from '../../entities/users/student.entity.js'; +import { Teacher } from '../../entities/users/teacher.entity'; + +export class ClassRepository extends DwengoEntityRepository { + public findById(id: string): Promise { + return this.findOne({ classId: id }, { populate: ['students', 'teachers'] }); + } + public deleteById(id: string): Promise { + return this.deleteWhere({ classId: id }); + } + public findByStudent(student: Student): Promise { + return this.find( + { students: student }, + { populate: ['students', 'teachers'] } // Voegt student en teacher objecten toe + ); + } + + public findByTeacher(teacher: Teacher): Promise { + return this.find({ teachers: teacher }, { populate: ['students', 'teachers'] }); + } +} diff --git a/backend/src/data/classes/teacher-invitation-repository.ts b/backend/src/data/classes/teacher-invitation-repository.ts new file mode 100644 index 00000000..6b94deec --- /dev/null +++ b/backend/src/data/classes/teacher-invitation-repository.ts @@ -0,0 +1,23 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { Class } from '../../entities/classes/class.entity.js'; +import { TeacherInvitation } from '../../entities/classes/teacher-invitation.entity.js'; +import { Teacher } from '../../entities/users/teacher.entity.js'; + +export class TeacherInvitationRepository extends DwengoEntityRepository { + public findAllInvitationsForClass(clazz: Class): Promise { + return this.findAll({ where: { class: clazz } }); + } + public findAllInvitationsBy(sender: Teacher): Promise { + return this.findAll({ where: { sender: sender } }); + } + public findAllInvitationsFor(receiver: Teacher): Promise { + return this.findAll({ where: { receiver: receiver } }); + } + public deleteBy(clazz: Class, sender: Teacher, receiver: Teacher): Promise { + return this.deleteWhere({ + sender: sender, + receiver: receiver, + class: clazz, + }); + } +} diff --git a/backend/src/data/content/attachment-repository.ts b/backend/src/data/content/attachment-repository.ts new file mode 100644 index 00000000..95c5ab1c --- /dev/null +++ b/backend/src/data/content/attachment-repository.ts @@ -0,0 +1,37 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { Attachment } from '../../entities/content/attachment.entity.js'; +import { Language } from '../../entities/content/language'; +import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier'; + +export class AttachmentRepository extends DwengoEntityRepository { + public findByLearningObjectIdAndName(learningObjectId: LearningObjectIdentifier, name: string): Promise { + return this.findOne({ + learningObject: { + hruid: learningObjectId.hruid, + language: learningObjectId.language, + version: learningObjectId.version, + }, + name: name, + }); + } + + public findByMostRecentVersionOfLearningObjectAndName(hruid: string, language: Language, attachmentName: string): Promise { + return this.findOne( + { + learningObject: { + hruid: hruid, + language: language, + }, + name: attachmentName, + }, + { + orderBy: { + learningObject: { + version: 'DESC', + }, + }, + } + ); + } + // This repository is read-only for now since creating own learning object is an extension feature. +} diff --git a/backend/src/data/content/learning-object-repository.ts b/backend/src/data/content/learning-object-repository.ts new file mode 100644 index 00000000..49b4c536 --- /dev/null +++ b/backend/src/data/content/learning-object-repository.ts @@ -0,0 +1,42 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { LearningObject } from '../../entities/content/learning-object.entity.js'; +import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; +import { Language } from '../../entities/content/language.js'; +import { Teacher } from '../../entities/users/teacher.entity.js'; + +export class LearningObjectRepository extends DwengoEntityRepository { + public findByIdentifier(identifier: LearningObjectIdentifier): Promise { + return this.findOne( + { + hruid: identifier.hruid, + language: identifier.language, + version: identifier.version, + }, + { + populate: ['keywords'], + } + ); + } + + public findLatestByHruidAndLanguage(hruid: string, language: Language) { + return this.findOne( + { + hruid: hruid, + language: language, + }, + { + populate: ['keywords'], + orderBy: { + version: 'DESC', + }, + } + ); + } + + public findAllByTeacher(teacher: Teacher): Promise { + return this.find( + { admins: teacher }, + { populate: ['admins'] } // Make sure to load admin relations + ); + } +} diff --git a/backend/src/data/content/learning-path-repository.ts b/backend/src/data/content/learning-path-repository.ts new file mode 100644 index 00000000..a2f9b47e --- /dev/null +++ b/backend/src/data/content/learning-path-repository.ts @@ -0,0 +1,26 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { LearningPath } from '../../entities/content/learning-path.entity.js'; +import { Language } from '../../entities/content/language.js'; + +export class LearningPathRepository extends DwengoEntityRepository { + public findByHruidAndLanguage(hruid: string, language: Language): Promise { + return this.findOne({ hruid: hruid, language: language }, { populate: ['nodes', 'nodes.transitions'] }); + } + + /** + * Returns all learning paths which have the given language and whose title OR description contains the + * query string. + * + * @param query The query string we want to seach for in the title or description. + * @param language The language of the learning paths we want to find. + */ + public async findByQueryStringAndLanguage(query: string, language: Language): Promise { + return this.findAll({ + where: { + language: language, + $or: [{ title: { $like: `%${query}%` } }, { description: { $like: `%${query}%` } }], + }, + populate: ['nodes', 'nodes.transitions'], + }); + } +} diff --git a/backend/src/data/dwengo-entity-repository.ts b/backend/src/data/dwengo-entity-repository.ts new file mode 100644 index 00000000..6538d6f5 --- /dev/null +++ b/backend/src/data/dwengo-entity-repository.ts @@ -0,0 +1,17 @@ +import { EntityRepository, FilterQuery } from '@mikro-orm/core'; + +export abstract class DwengoEntityRepository extends EntityRepository { + public async save(entity: T) { + const em = this.getEntityManager(); + em.persist(entity); + await em.flush(); + } + public async deleteWhere(query: FilterQuery) { + const toDelete = await this.findOne(query); + const em = this.getEntityManager(); + if (toDelete) { + em.remove(toDelete); + await em.flush(); + } + } +} diff --git a/backend/src/data/questions/answer-repository.ts b/backend/src/data/questions/answer-repository.ts new file mode 100644 index 00000000..a28342bd --- /dev/null +++ b/backend/src/data/questions/answer-repository.ts @@ -0,0 +1,28 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { Answer } from '../../entities/questions/answer.entity.js'; +import { Question } from '../../entities/questions/question.entity.js'; +import { Teacher } from '../../entities/users/teacher.entity.js'; + +export class AnswerRepository extends DwengoEntityRepository { + public createAnswer(answer: { toQuestion: Question; author: Teacher; content: string }): Promise { + const answerEntity = this.create({ + toQuestion: answer.toQuestion, + author: answer.author, + content: answer.content, + timestamp: new Date(), + }); + return this.insert(answerEntity); + } + public findAllAnswersToQuestion(question: Question): Promise { + return this.findAll({ + where: { toQuestion: question }, + orderBy: { sequenceNumber: 'ASC' }, + }); + } + public removeAnswerByQuestionAndSequenceNumber(question: Question, sequenceNumber: number): Promise { + return this.deleteWhere({ + toQuestion: question, + sequenceNumber: sequenceNumber, + }); + } +} diff --git a/backend/src/data/questions/question-repository.ts b/backend/src/data/questions/question-repository.ts new file mode 100644 index 00000000..9207e1dd --- /dev/null +++ b/backend/src/data/questions/question-repository.ts @@ -0,0 +1,57 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { Question } from '../../entities/questions/question.entity.js'; +import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; +import { Student } from '../../entities/users/student.entity.js'; +import { LearningObject } from '../../entities/content/learning-object.entity.js'; + +export class QuestionRepository extends DwengoEntityRepository { + public createQuestion(question: { loId: LearningObjectIdentifier; author: Student; content: string }): Promise { + const questionEntity = this.create({ + learningObjectHruid: question.loId.hruid, + learningObjectLanguage: question.loId.language, + learningObjectVersion: question.loId.version, + author: question.author, + content: question.content, + timestamp: new Date(), + }); + questionEntity.learningObjectHruid = question.loId.hruid; + questionEntity.learningObjectLanguage = question.loId.language; + questionEntity.learningObjectVersion = question.loId.version; + questionEntity.author = question.author; + questionEntity.content = question.content; + return this.insert(questionEntity); + } + public findAllQuestionsAboutLearningObject(loId: LearningObjectIdentifier): Promise { + return this.findAll({ + where: { + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + }, + orderBy: { + sequenceNumber: 'ASC', + }, + }); + } + public removeQuestionByLearningObjectAndSequenceNumber(loId: LearningObjectIdentifier, sequenceNumber: number): Promise { + return this.deleteWhere({ + learningObjectHruid: loId.hruid, + learningObjectLanguage: loId.language, + learningObjectVersion: loId.version, + sequenceNumber: sequenceNumber, + }); + } + + public async findAllByLearningObjects(learningObjects: LearningObject[]): Promise { + const objectIdentifiers = learningObjects.map((lo) => ({ + learningObjectHruid: lo.hruid, + learningObjectLanguage: lo.language, + learningObjectVersion: lo.version, + })); + + return this.findAll({ + where: { $or: objectIdentifiers }, + orderBy: { timestamp: 'ASC' }, + }); + } +} diff --git a/backend/src/data/repositories.ts b/backend/src/data/repositories.ts new file mode 100644 index 00000000..02385109 --- /dev/null +++ b/backend/src/data/repositories.ts @@ -0,0 +1,79 @@ +import { AnyEntity, EntityManager, EntityName, EntityRepository } from '@mikro-orm/core'; +import { forkEntityManager } from '../orm.js'; +import { StudentRepository } from './users/student-repository.js'; +import { Student } from '../entities/users/student.entity.js'; +import { User } from '../entities/users/user.entity.js'; +import { UserRepository } from './users/user-repository.js'; +import { Teacher } from '../entities/users/teacher.entity.js'; +import { TeacherRepository } from './users/teacher-repository.js'; +import { Class } from '../entities/classes/class.entity.js'; +import { ClassRepository } from './classes/class-repository.js'; +import { ClassJoinRequest } from '../entities/classes/class-join-request.entity.js'; +import { ClassJoinRequestRepository } from './classes/class-join-request-repository.js'; +import { TeacherInvitationRepository } from './classes/teacher-invitation-repository.js'; +import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js'; +import { Assignment } from '../entities/assignments/assignment.entity.js'; +import { AssignmentRepository } from './assignments/assignment-repository.js'; +import { GroupRepository } from './assignments/group-repository.js'; +import { Group } from '../entities/assignments/group.entity.js'; +import { Submission } from '../entities/assignments/submission.entity.js'; +import { SubmissionRepository } from './assignments/submission-repository.js'; +import { Question } from '../entities/questions/question.entity.js'; +import { QuestionRepository } from './questions/question-repository.js'; +import { Answer } from '../entities/questions/answer.entity.js'; +import { AnswerRepository } from './questions/answer-repository.js'; +import { LearningObject } from '../entities/content/learning-object.entity.js'; +import { LearningObjectRepository } from './content/learning-object-repository.js'; +import { LearningPath } from '../entities/content/learning-path.entity.js'; +import { LearningPathRepository } from './content/learning-path-repository.js'; +import { AttachmentRepository } from './content/attachment-repository.js'; +import { Attachment } from '../entities/content/attachment.entity.js'; +import { LearningPathNode } from '../entities/content/learning-path-node.entity.js'; +import { LearningPathTransition } from '../entities/content/learning-path-transition.entity.js'; + +let entityManager: EntityManager | undefined; + +/** + * Execute all the database operations within the function f in a single transaction. + */ +export function transactional(f: () => Promise) { + entityManager?.transactional(f); +} + +function repositoryGetter>(entity: EntityName): () => R { + let cachedRepo: R | undefined; + return (): R => { + if (!cachedRepo) { + if (!entityManager) { + entityManager = forkEntityManager(); + } + cachedRepo = entityManager.getRepository(entity) as R; + } + return cachedRepo; + }; +} + +/* Users */ +export const getStudentRepository = repositoryGetter(Student); +export const getTeacherRepository = repositoryGetter(Teacher); + +/* Classes */ +export const getClassRepository = repositoryGetter(Class); +export const getClassJoinRequestRepository = repositoryGetter(ClassJoinRequest); +export const getTeacherInvitationRepository = repositoryGetter(TeacherInvitation); + +/* Assignments */ +export const getAssignmentRepository = repositoryGetter(Assignment); +export const getGroupRepository = repositoryGetter(Group); +export const getSubmissionRepository = repositoryGetter(Submission); + +/* Questions and answers */ +export const getQuestionRepository = repositoryGetter(Question); +export const getAnswerRepository = repositoryGetter(Answer); + +/* Learning content */ +export const getLearningObjectRepository = repositoryGetter(LearningObject); +export const getLearningPathRepository = repositoryGetter(LearningPath); +export const getLearningPathNodeRepository = repositoryGetter(LearningPathNode); +export const getLearningPathTransitionRepository = repositoryGetter(LearningPathTransition); +export const getAttachmentRepository = repositoryGetter(Attachment); diff --git a/backend/src/data/themes.ts b/backend/src/data/themes.ts new file mode 100644 index 00000000..b0fc930c --- /dev/null +++ b/backend/src/data/themes.ts @@ -0,0 +1,168 @@ +export interface Theme { + title: string; + hruids: string[]; +} + +export const themes: Theme[] = [ + { + title: 'kiks', + hruids: [ + 'pn_werking', + 'un_artificiele_intelligentie', + 'pn_klimaatverandering', + 'kiks1_microscopie', + 'kiks2_practicum', + 'pn_digitalebeelden', + 'kiks3_dl_basis', + 'kiks4_dl_gevorderd', + 'kiks5_classificatie', + 'kiks6_regressie', + 'kiks7_ethiek', + 'kiks8_eindtermen', + ], + }, + { + title: 'art', + hruids: ['pn_werking', 'un_artificiele_intelligentie', 'art1', 'art2', 'art3'], + }, + { + title: 'socialrobot', + hruids: ['sr0_lkr', 'sr0_lln', 'sr1', 'sr2', 'sr3', 'sr4'], + }, + { + title: 'agriculture', + hruids: ['pn_werking', 'un_artificiele_intelligentie', 'agri_landbouw', 'agri_lopendeband'], + }, + { + title: 'wegostem', + hruids: ['wegostem'], + }, + { + title: 'computational_thinking', + hruids: [ + 'ct1_concepten', + 'ct2_concreet', + 'ct3_voorbeelden', + 'ct6_cases', + 'ct9_impact', + 'ct10_bebras', + 'ct8_eindtermen', + 'ct7_historiek', + 'ct5_kijkwijzer', + 'ct4_evaluatiekader', + ], + }, + { + title: 'math_with_python', + hruids: [ + 'pn_werking', + 'maths_pythagoras', + 'maths_spreidingsdiagrammen', + 'maths_rechten', + 'maths_lineaireregressie', + 'maths_epidemie', + 'pn_digitalebeelden', + 'maths_logica', + 'maths_parameters', + 'maths_parabolen', + 'pn_regressie', + 'maths7_grafen', + 'maths8_statistiek', + ], + }, + { + title: 'python_programming', + hruids: ['pn_werking', 'pn_datatypes', 'pn_operatoren', 'pn_structuren', 'pn_functies', 'art2', 'stem_insectbooks', 'un_algoenprog'], + }, + { + title: 'stem', + hruids: [ + 'pn_werking', + 'maths_spreidingsdiagrammen', + 'pn_digitalebeelden', + 'maths_epidemie', + 'stem_ipadres', + 'pn_klimaatverandering', + 'stem_rechten', + 'stem_lineaireregressie', + 'stem_insectbooks', + ], + }, + { + title: 'care', + hruids: ['pn_werking', 'un_artificiele_intelligentie', 'aiz1_zorg', 'aiz2_grafen', 'aiz3_unplugged', 'aiz4_eindtermen', 'aiz5_triage'], + }, + { + title: 'chatbot', + hruids: [ + 'pn_werking', + 'un_artificiele_intelligentie', + 'cb5_chatbotunplugged', + 'cb1_chatbot', + 'cb2_sentimentanalyse', + 'cb3_vervoegmachine', + 'cb4_eindtermen', + 'cb6', + ], + }, + { + title: 'physical_computing', + hruids: [ + 'pc_starttodwenguino', + 'pc_rijdenderobot', + 'pc_theremin', + 'pc_leerlijn_introductie', + 'pc_leerlijn_invoer_verwerking_uitvoer', + 'pc_leerlijn_basisprincipes_digitale_elektronica', + 'pc_leerlijn_grafisch_naar_tekstueel', + 'pc_leerlijn_basis_programmeren', + 'pc_leerlijn_van_µc_naar_plc', + 'pc_leerlijn_fiches_dwenguino', + 'pc_leerlijn_seriele_monitor', + 'pc_leerlijn_bus_protocollen', + 'pc_leerlijn_wifi', + 'pc_leerlijn_fiches_arduino', + 'pc_leerlijn_project_lijnvolger', + 'pc_leerlijn_project_bluetooth', + 'pc_leerlijn_hddclock', + 'pc_leerlijn_fysica_valbeweging', + 'pc_leerlijn_luchtkwaliteit', + 'pc_leerlijn_weerstation', + 'pc_leerlijn_g0', + 'pc_leerlijn_g1', + 'pc_leerlijn_g3', + 'pc_leerlijn_g4', + 'pc_leerlijn_g5', + ], + }, + { + title: 'algorithms', + hruids: [ + 'art2', + 'anm1', + 'anm2', + 'anm3', + 'anm4', + 'anm11', + 'anm12', + 'anm13', + 'anm14', + 'anm15', + 'anm16', + 'anm17', + 'maths_epidemie', + 'stem_insectbooks', + ], + }, + { + title: 'basics_ai', + hruids: [ + 'un_artificiele_intelligentie', + 'org-dwengo-waisda-taal-murder-mistery', + 'art1', + 'org-dwengo-waisda-beelden-emoties-herkennen', + 'org-dwengo-waisda-beelden-unplugged-fax-lp', + 'org-dwengo-waisda-beelden-teachable-machine', + ], + }, +]; diff --git a/backend/src/data/users/student-repository.ts b/backend/src/data/users/student-repository.ts new file mode 100644 index 00000000..0792678d --- /dev/null +++ b/backend/src/data/users/student-repository.ts @@ -0,0 +1,15 @@ +import { Student } from '../../entities/users/student.entity.js'; +import { User } from '../../entities/users/user.entity.js'; +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +// Import { UserRepository } from './user-repository.js'; + +// Export class StudentRepository extends UserRepository {} + +export class StudentRepository extends DwengoEntityRepository { + public findByUsername(username: string): Promise { + return this.findOne({ username: username }); + } + public deleteByUsername(username: string): Promise { + return this.deleteWhere({ username: username }); + } +} diff --git a/backend/src/data/users/teacher-repository.ts b/backend/src/data/users/teacher-repository.ts new file mode 100644 index 00000000..2b2bee75 --- /dev/null +++ b/backend/src/data/users/teacher-repository.ts @@ -0,0 +1,12 @@ +import { Teacher } from '../../entities/users/teacher.entity.js'; +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { UserRepository } from './user-repository.js'; + +export class TeacherRepository extends DwengoEntityRepository { + public findByUsername(username: string): Promise { + return this.findOne({ username: username }); + } + public deleteByUsername(username: string): Promise { + return this.deleteWhere({ username: username }); + } +} diff --git a/backend/src/data/users/user-repository.ts b/backend/src/data/users/user-repository.ts new file mode 100644 index 00000000..21497b79 --- /dev/null +++ b/backend/src/data/users/user-repository.ts @@ -0,0 +1,11 @@ +import { DwengoEntityRepository } from '../dwengo-entity-repository.js'; +import { User } from '../../entities/users/user.entity.js'; + +export class UserRepository extends DwengoEntityRepository { + public findByUsername(username: string): Promise { + return this.findOne({ username } as Partial); + } + public deleteByUsername(username: string): Promise { + return this.deleteWhere({ username } as Partial); + } +} diff --git a/backend/src/entities/assignments/assignment.entity.ts b/backend/src/entities/assignments/assignment.entity.ts new file mode 100644 index 00000000..692e2112 --- /dev/null +++ b/backend/src/entities/assignments/assignment.entity.ts @@ -0,0 +1,39 @@ +import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { Class } from '../classes/class.entity.js'; +import { Group } from './group.entity.js'; +import { Language } from '../content/language.js'; +import { AssignmentRepository } from '../../data/assignments/assignment-repository.js'; + +@Entity({ + repository: () => AssignmentRepository, +}) +export class Assignment { + @ManyToOne({ + entity: () => Class, + primary: true, + }) + within!: Class; + + @PrimaryKey({ type: 'number', autoincrement: true }) + id?: number; + + @Property({ type: 'string' }) + title!: string; + + @Property({ type: 'text' }) + description!: string; + + @Property({ type: 'string' }) + learningPathHruid!: string; + + @Enum({ + items: () => Language, + }) + learningPathLanguage!: Language; + + @OneToMany({ + entity: () => Group, + mappedBy: 'assignment', + }) + groups!: Group[]; +} diff --git a/backend/src/entities/assignments/group.entity.ts b/backend/src/entities/assignments/group.entity.ts new file mode 100644 index 00000000..213e0f38 --- /dev/null +++ b/backend/src/entities/assignments/group.entity.ts @@ -0,0 +1,23 @@ +import { Collection, Entity, ManyToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; +import { Assignment } from './assignment.entity.js'; +import { Student } from '../users/student.entity.js'; +import { GroupRepository } from '../../data/assignments/group-repository.js'; + +@Entity({ + repository: () => GroupRepository, +}) +export class Group { + @ManyToOne({ + entity: () => Assignment, + primary: true, + }) + assignment!: Assignment; + + @PrimaryKey({ type: 'integer', autoincrement: true }) + groupNumber?: number; + + @ManyToMany({ + entity: () => Student, + }) + members!: Student[]; +} diff --git a/backend/src/entities/assignments/submission.entity.ts b/backend/src/entities/assignments/submission.entity.ts new file mode 100644 index 00000000..f008c8c2 --- /dev/null +++ b/backend/src/entities/assignments/submission.entity.ts @@ -0,0 +1,40 @@ +import { Student } from '../users/student.entity.js'; +import { Group } from './group.entity.js'; +import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; +import { Language } from '../content/language.js'; +import { SubmissionRepository } from '../../data/assignments/submission-repository.js'; + +@Entity({ repository: () => SubmissionRepository }) +export class Submission { + @PrimaryKey({ type: 'string' }) + learningObjectHruid!: string; + + @Enum({ + items: () => Language, + primary: true, + }) + learningObjectLanguage!: Language; + + @PrimaryKey({ type: 'numeric' }) + learningObjectVersion: number = 1; + + @PrimaryKey({ type: 'integer', autoincrement: true }) + submissionNumber!: number; + + @ManyToOne({ + entity: () => Student, + }) + submitter!: Student; + + @Property({ type: 'datetime' }) + submissionTime!: Date; + + @ManyToOne({ + entity: () => Group, + nullable: true, + }) + onBehalfOf?: Group; + + @Property({ type: 'json' }) + content!: string; +} diff --git a/backend/src/entities/classes/class-join-request.entity.ts b/backend/src/entities/classes/class-join-request.entity.ts new file mode 100644 index 00000000..bdef1f52 --- /dev/null +++ b/backend/src/entities/classes/class-join-request.entity.ts @@ -0,0 +1,30 @@ +import { Entity, Enum, ManyToOne } from '@mikro-orm/core'; +import { Student } from '../users/student.entity.js'; +import { Class } from './class.entity.js'; +import { ClassJoinRequestRepository } from '../../data/classes/class-join-request-repository.js'; + +@Entity({ + repository: () => ClassJoinRequestRepository, +}) +export class ClassJoinRequest { + @ManyToOne({ + entity: () => Student, + primary: true, + }) + requester!: Student; + + @ManyToOne({ + entity: () => Class, + primary: true, + }) + class!: Class; + + @Enum(() => ClassJoinRequestStatus) + status!: ClassJoinRequestStatus; +} + +export enum ClassJoinRequestStatus { + Open = 'open', + Accepted = 'accepted', + Declined = 'declined', +} diff --git a/backend/src/entities/classes/class.entity.ts b/backend/src/entities/classes/class.entity.ts new file mode 100644 index 00000000..63315304 --- /dev/null +++ b/backend/src/entities/classes/class.entity.ts @@ -0,0 +1,22 @@ +import { Collection, Entity, ManyToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { v4 } from 'uuid'; +import { Teacher } from '../users/teacher.entity.js'; +import { Student } from '../users/student.entity.js'; +import { ClassRepository } from '../../data/classes/class-repository.js'; + +@Entity({ + repository: () => ClassRepository, +}) +export class Class { + @PrimaryKey() + classId? = v4(); + + @Property({ type: 'string' }) + displayName!: string; + + @ManyToMany(() => Teacher) + teachers!: Collection; + + @ManyToMany(() => Student) + students!: Collection; +} diff --git a/backend/src/entities/classes/teacher-invitation.entity.ts b/backend/src/entities/classes/teacher-invitation.entity.ts new file mode 100644 index 00000000..668a0a1c --- /dev/null +++ b/backend/src/entities/classes/teacher-invitation.entity.ts @@ -0,0 +1,28 @@ +import { Entity, ManyToOne } from '@mikro-orm/core'; +import { Teacher } from '../users/teacher.entity.js'; +import { Class } from './class.entity.js'; +import { TeacherInvitationRepository } from '../../data/classes/teacher-invitation-repository.js'; + +/** + * Invitation of a teacher into a class (in order to teach it). + */ +@Entity({ repository: () => TeacherInvitationRepository }) +export class TeacherInvitation { + @ManyToOne({ + entity: () => Teacher, + primary: true, + }) + sender!: Teacher; + + @ManyToOne({ + entity: () => Teacher, + primary: true, + }) + receiver!: Teacher; + + @ManyToOne({ + entity: () => Class, + primary: true, + }) + class!: Class; +} diff --git a/backend/src/entities/content/attachment.entity.ts b/backend/src/entities/content/attachment.entity.ts new file mode 100644 index 00000000..80104f28 --- /dev/null +++ b/backend/src/entities/content/attachment.entity.ts @@ -0,0 +1,23 @@ +import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; +import { LearningObject } from './learning-object.entity.js'; +import { AttachmentRepository } from '../../data/content/attachment-repository.js'; + +@Entity({ + repository: () => AttachmentRepository, +}) +export class Attachment { + @ManyToOne({ + entity: () => LearningObject, + primary: true, + }) + learningObject!: LearningObject; + + @PrimaryKey({ type: 'string' }) + name!: string; + + @Property({ type: 'string' }) + mimeType!: string; + + @Property({ type: 'blob' }) + content!: Buffer; +} diff --git a/backend/src/entities/content/language.ts b/backend/src/entities/content/language.ts new file mode 100644 index 00000000..7e7b42d2 --- /dev/null +++ b/backend/src/entities/content/language.ts @@ -0,0 +1,193 @@ +export enum Language { + Afar = 'aa', + Abkhazian = 'ab', + Afrikaans = 'af', + Akan = 'ak', + Albanian = 'sq', + Amharic = 'am', + Arabic = 'ar', + Aragonese = 'an', + Armenian = 'hy', + Assamese = 'as', + Avaric = 'av', + Avestan = 'ae', + Aymara = 'ay', + Azerbaijani = 'az', + Bashkir = 'ba', + Bambara = 'bm', + Basque = 'eu', + Belarusian = 'be', + Bengali = 'bn', + Bihari = 'bh', + Bislama = 'bi', + Bosnian = 'bs', + Breton = 'br', + Bulgarian = 'bg', + Burmese = 'my', + Catalan = 'ca', + Chamorro = 'ch', + Chechen = 'ce', + Chinese = 'zh', + ChurchSlavic = 'cu', + Chuvash = 'cv', + Cornish = 'kw', + Corsican = 'co', + Cree = 'cr', + Czech = 'cs', + Danish = 'da', + Divehi = 'dv', + Dutch = 'nl', + Dzongkha = 'dz', + English = 'en', + Esperanto = 'eo', + Estonian = 'et', + Ewe = 'ee', + Faroese = 'fo', + Fijian = 'fj', + Finnish = 'fi', + French = 'fr', + Frisian = 'fy', + Fulah = 'ff', + Georgian = 'ka', + German = 'de', + Gaelic = 'gd', + Irish = 'ga', + Galician = 'gl', + Manx = 'gv', + Greek = 'el', + Guarani = 'gn', + Gujarati = 'gu', + Haitian = 'ht', + Hausa = 'ha', + Hebrew = 'he', + Herero = 'hz', + Hindi = 'hi', + HiriMotu = 'ho', + Croatian = 'hr', + Hungarian = 'hu', + Igbo = 'ig', + Icelandic = 'is', + Ido = 'io', + SichuanYi = 'ii', + Inuktitut = 'iu', + Interlingue = 'ie', + Interlingua = 'ia', + Indonesian = 'id', + Inupiaq = 'ik', + Italian = 'it', + Javanese = 'jv', + Japanese = 'ja', + Kalaallisut = 'kl', + Kannada = 'kn', + Kashmiri = 'ks', + Kanuri = 'kr', + Kazakh = 'kk', + Khmer = 'km', + Kikuyu = 'ki', + Kinyarwanda = 'rw', + Kirghiz = 'ky', + Komi = 'kv', + Kongo = 'kg', + Korean = 'ko', + Kuanyama = 'kj', + Kurdish = 'ku', + Lao = 'lo', + Latin = 'la', + Latvian = 'lv', + Limburgan = 'li', + Lingala = 'ln', + Lithuanian = 'lt', + Luxembourgish = 'lb', + LubaKatanga = 'lu', + Ganda = 'lg', + Macedonian = 'mk', + Marshallese = 'mh', + Malayalam = 'ml', + Maori = 'mi', + Marathi = 'mr', + Malay = 'ms', + Malagasy = 'mg', + Maltese = 'mt', + Mongolian = 'mn', + Nauru = 'na', + Navajo = 'nv', + SouthNdebele = 'nr', + NorthNdebele = 'nd', + Ndonga = 'ng', + Nepali = 'ne', + NorwegianNynorsk = 'nn', + NorwegianBokmal = 'nb', + Norwegian = 'no', + Chichewa = 'ny', + Occitan = 'oc', + Ojibwa = 'oj', + Oriya = 'or', + Oromo = 'om', + Ossetian = 'os', + Punjabi = 'pa', + Persian = 'fa', + Pali = 'pi', + Polish = 'pl', + Portuguese = 'pt', + Pashto = 'ps', + Quechua = 'qu', + Romansh = 'rm', + Romanian = 'ro', + Rundi = 'rn', + Russian = 'ru', + Sango = 'sg', + Sanskrit = 'sa', + Sinhala = 'si', + Slovak = 'sk', + Slovenian = 'sl', + NorthernSami = 'se', + Samoan = 'sm', + Shona = 'sn', + Sindhi = 'sd', + Somali = 'so', + Sotho = 'st', + Spanish = 'es', + Sardinian = 'sc', + Serbian = 'sr', + Swati = 'ss', + Sundanese = 'su', + Swahili = 'sw', + Swedish = 'sv', + Tahitian = 'ty', + Tamil = 'ta', + Tatar = 'tt', + Telugu = 'te', + Tajik = 'tg', + Tagalog = 'tl', + Thai = 'th', + Tibetan = 'bo', + Tigrinya = 'ti', + Tonga = 'to', + Tswana = 'tn', + Tsonga = 'ts', + Turkmen = 'tk', + Turkish = 'tr', + Twi = 'tw', + Uighur = 'ug', + Ukrainian = 'uk', + Urdu = 'ur', + Uzbek = 'uz', + Venda = 've', + Vietnamese = 'vi', + Volapuk = 'vo', + Welsh = 'cy', + Walloon = 'wa', + Wolof = 'wo', + Xhosa = 'xh', + Yiddish = 'yi', + Yoruba = 'yo', + Zhuang = 'za', + Zulu = 'zu', +} + +export const languageMap: Record = { + nl: Language.Dutch, + fr: Language.French, + en: Language.English, + de: Language.German, +}; diff --git a/backend/src/entities/content/learning-object-identifier.ts b/backend/src/entities/content/learning-object-identifier.ts new file mode 100644 index 00000000..3c020bd7 --- /dev/null +++ b/backend/src/entities/content/learning-object-identifier.ts @@ -0,0 +1,9 @@ +import { Language } from './language.js'; + +export class LearningObjectIdentifier { + constructor( + public hruid: string, + public language: Language, + public version: number + ) {} +} diff --git a/backend/src/entities/content/learning-object.entity.ts b/backend/src/entities/content/learning-object.entity.ts new file mode 100644 index 00000000..9eda22ba --- /dev/null +++ b/backend/src/entities/content/learning-object.entity.ts @@ -0,0 +1,107 @@ +import { Embeddable, Embedded, Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { Language } from './language.js'; +import { Attachment } from './attachment.entity.js'; +import { Teacher } from '../users/teacher.entity.js'; +import { DwengoContentType } from '../../services/learning-objects/processing/content-type.js'; +import { v4 } from 'uuid'; +import { LearningObjectRepository } from '../../data/content/learning-object-repository.js'; + +@Embeddable() +export class EducationalGoal { + @Property({ type: 'string' }) + source!: string; + + @Property({ type: 'string' }) + id!: string; +} + +@Embeddable() +export class ReturnValue { + @Property({ type: 'string' }) + callbackUrl!: string; + + @Property({ type: 'json' }) + callbackSchema!: string; +} + +@Entity({ repository: () => LearningObjectRepository }) +export class LearningObject { + @PrimaryKey({ type: 'string' }) + hruid!: string; + + @Enum({ + items: () => Language, + primary: true, + }) + language!: Language; + + @PrimaryKey({ type: 'number' }) + version: number = 1; + + @Property({ type: 'uuid', unique: true }) + uuid = v4(); + + @ManyToMany({ + entity: () => Teacher, + }) + admins!: Teacher[]; + + @Property({ type: 'string' }) + title!: string; + + @Property({ type: 'text' }) + description!: string; + + @Property({ type: 'string' }) + contentType!: DwengoContentType; + + @Property({ type: 'array' }) + keywords: string[] = []; + + @Property({ type: 'array', nullable: true }) + targetAges?: number[] = []; + + @Property({ type: 'bool' }) + teacherExclusive: boolean = false; + + @Property({ type: 'array' }) + skosConcepts: string[] = []; + + @Embedded({ + entity: () => EducationalGoal, + array: true, + }) + educationalGoals: EducationalGoal[] = []; + + @Property({ type: 'string' }) + copyright: string = ''; + + @Property({ type: 'string' }) + license: string = ''; + + @Property({ type: 'smallint', nullable: true }) + difficulty?: number; + + @Property({ type: 'integer', nullable: true }) + estimatedTime?: number; + + @Embedded({ + entity: () => ReturnValue, + }) + returnValue!: ReturnValue; + + @Property({ type: 'bool' }) + available: boolean = true; + + @Property({ type: 'string', nullable: true }) + contentLocation?: string; + + @OneToMany({ + entity: () => Attachment, + mappedBy: 'learningObject', + }) + attachments: Attachment[] = []; + + @Property({ type: 'blob' }) + content!: Buffer; +} diff --git a/backend/src/entities/content/learning-path-node.entity.ts b/backend/src/entities/content/learning-path-node.entity.ts new file mode 100644 index 00000000..03499270 --- /dev/null +++ b/backend/src/entities/content/learning-path-node.entity.ts @@ -0,0 +1,37 @@ +import { Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property, Rel } from '@mikro-orm/core'; +import { Language } from './language.js'; +import { LearningPath } from './learning-path.entity.js'; +import { LearningPathTransition } from './learning-path-transition.entity.js'; + +@Entity() +export class LearningPathNode { + @ManyToOne({ entity: () => LearningPath, primary: true }) + learningPath!: Rel; + + @PrimaryKey({ type: 'integer', autoincrement: true }) + nodeNumber!: number; + + @Property({ type: 'string' }) + learningObjectHruid!: string; + + @Enum({ items: () => Language }) + language!: Language; + + @Property({ type: 'number' }) + version!: number; + + @Property({ type: 'text', nullable: true }) + instruction?: string; + + @Property({ type: 'bool' }) + startNode!: boolean; + + @OneToMany({ entity: () => LearningPathTransition, mappedBy: 'node' }) + transitions: LearningPathTransition[] = []; + + @Property({ length: 3 }) + createdAt: Date = new Date(); + + @Property({ length: 3, onUpdate: () => new Date() }) + updatedAt: Date = new Date(); +} diff --git a/backend/src/entities/content/learning-path-transition.entity.ts b/backend/src/entities/content/learning-path-transition.entity.ts new file mode 100644 index 00000000..7d6601a3 --- /dev/null +++ b/backend/src/entities/content/learning-path-transition.entity.ts @@ -0,0 +1,17 @@ +import { Entity, ManyToOne, PrimaryKey, Property, Rel } from '@mikro-orm/core'; +import { LearningPathNode } from './learning-path-node.entity.js'; + +@Entity() +export class LearningPathTransition { + @ManyToOne({ entity: () => LearningPathNode, primary: true }) + node!: Rel; + + @PrimaryKey({ type: 'numeric' }) + transitionNumber!: number; + + @Property({ type: 'string' }) + condition!: string; + + @ManyToOne({ entity: () => LearningPathNode }) + next!: Rel; +} diff --git a/backend/src/entities/content/learning-path.entity.ts b/backend/src/entities/content/learning-path.entity.ts new file mode 100644 index 00000000..888cc0cf --- /dev/null +++ b/backend/src/entities/content/learning-path.entity.ts @@ -0,0 +1,29 @@ +import { Entity, Enum, ManyToMany, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'; +import { Language } from './language.js'; +import { Teacher } from '../users/teacher.entity.js'; +import { LearningPathRepository } from '../../data/content/learning-path-repository.js'; +import { LearningPathNode } from './learning-path-node.entity.js'; + +@Entity({ repository: () => LearningPathRepository }) +export class LearningPath { + @PrimaryKey({ type: 'string' }) + hruid!: string; + + @Enum({ items: () => Language, primary: true }) + language!: Language; + + @ManyToMany({ entity: () => Teacher }) + admins!: Teacher[]; + + @Property({ type: 'string' }) + title!: string; + + @Property({ type: 'text' }) + description!: string; + + @Property({ type: 'blob', nullable: true }) + image: Buffer | null = null; + + @OneToMany({ entity: () => LearningPathNode, mappedBy: 'learningPath' }) + nodes: LearningPathNode[] = []; +} diff --git a/backend/src/entities/questions/answer.entity.ts b/backend/src/entities/questions/answer.entity.ts new file mode 100644 index 00000000..96bdc27d --- /dev/null +++ b/backend/src/entities/questions/answer.entity.ts @@ -0,0 +1,28 @@ +import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; +import { Question } from './question.entity.js'; +import { Teacher } from '../users/teacher.entity.js'; +import { AnswerRepository } from '../../data/questions/answer-repository.js'; + +@Entity({ repository: () => AnswerRepository }) +export class Answer { + @ManyToOne({ + entity: () => Teacher, + primary: true, + }) + author!: Teacher; + + @ManyToOne({ + entity: () => Question, + primary: true, + }) + toQuestion!: Question; + + @PrimaryKey({ type: 'integer', autoincrement: true }) + sequenceNumber?: number; + + @Property({ type: 'datetime' }) + timestamp: Date = new Date(); + + @Property({ type: 'text' }) + content!: string; +} diff --git a/backend/src/entities/questions/question.entity.ts b/backend/src/entities/questions/question.entity.ts new file mode 100644 index 00000000..058ba6b3 --- /dev/null +++ b/backend/src/entities/questions/question.entity.ts @@ -0,0 +1,33 @@ +import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'; +import { Language } from '../content/language.js'; +import { Student } from '../users/student.entity.js'; +import { QuestionRepository } from '../../data/questions/question-repository.js'; + +@Entity({ repository: () => QuestionRepository }) +export class Question { + @PrimaryKey({ type: 'string' }) + learningObjectHruid!: string; + + @Enum({ + items: () => Language, + primary: true, + }) + learningObjectLanguage!: Language; + + @PrimaryKey({ type: 'number' }) + learningObjectVersion: number = 1; + + @PrimaryKey({ type: 'integer', autoincrement: true }) + sequenceNumber?: number; + + @ManyToOne({ + entity: () => Student, + }) + author!: Student; + + @Property({ type: 'datetime' }) + timestamp: Date = new Date(); + + @Property({ type: 'text' }) + content!: string; +} diff --git a/backend/src/entities/users/student.entity.ts b/backend/src/entities/users/student.entity.ts new file mode 100644 index 00000000..da5b4367 --- /dev/null +++ b/backend/src/entities/users/student.entity.ts @@ -0,0 +1,24 @@ +import { User } from './user.entity.js'; +import { Collection, Entity, ManyToMany } from '@mikro-orm/core'; +import { Class } from '../classes/class.entity.js'; +import { Group } from '../assignments/group.entity.js'; +import { StudentRepository } from '../../data/users/student-repository.js'; + +@Entity({ + repository: () => StudentRepository, +}) +export class Student extends User { + @ManyToMany(() => Class) + classes!: Collection; + + @ManyToMany(() => Group) + groups!: Collection; + + constructor( + public username: string, + public firstName: string, + public lastName: string + ) { + super(); + } +} diff --git a/backend/src/entities/users/teacher.entity.ts b/backend/src/entities/users/teacher.entity.ts new file mode 100644 index 00000000..8e22d1de --- /dev/null +++ b/backend/src/entities/users/teacher.entity.ts @@ -0,0 +1,18 @@ +import { Collection, Entity, ManyToMany } from '@mikro-orm/core'; +import { User } from './user.entity.js'; +import { Class } from '../classes/class.entity.js'; +import { TeacherRepository } from '../../data/users/teacher-repository.js'; + +@Entity({ repository: () => TeacherRepository }) +export class Teacher extends User { + @ManyToMany(() => Class) + classes!: Collection; + + constructor( + public username: string, + public firstName: string, + public lastName: string + ) { + super(); + } +} diff --git a/backend/src/entities/users/user.entity.ts b/backend/src/entities/users/user.entity.ts new file mode 100644 index 00000000..1f35a0f8 --- /dev/null +++ b/backend/src/entities/users/user.entity.ts @@ -0,0 +1,13 @@ +import { Entity, PrimaryKey, Property } from '@mikro-orm/core'; + +@Entity({ abstract: true }) +export abstract class User { + @PrimaryKey({ type: 'string' }) + username!: string; + + @Property() + firstName: string = ''; + + @Property() + lastName: string = ''; +} diff --git a/backend/src/exceptions.ts b/backend/src/exceptions.ts new file mode 100644 index 00000000..e93a6c93 --- /dev/null +++ b/backend/src/exceptions.ts @@ -0,0 +1,42 @@ +/** + * Exception for HTTP 400 Bad Request + */ +export class BadRequestException extends Error { + public status = 400; + + constructor(error: string) { + super(error); + } +} + +/** + * Exception for HTTP 401 Unauthorized + */ +export class UnauthorizedException extends Error { + status = 401; + constructor(message: string = 'Unauthorized') { + super(message); + } +} + +/** + * Exception for HTTP 403 Forbidden + */ +export class ForbiddenException extends Error { + status = 403; + + constructor(message: string = 'Forbidden') { + super(message); + } +} + +/** + * Exception for HTTP 404 Not Found + */ +export class NotFoundException extends Error { + public status = 404; + + constructor(error: string) { + super(error); + } +} diff --git a/backend/src/interfaces/answer.ts b/backend/src/interfaces/answer.ts new file mode 100644 index 00000000..493fd3c0 --- /dev/null +++ b/backend/src/interfaces/answer.ts @@ -0,0 +1,38 @@ +import { mapToUserDTO, UserDTO } from './user.js'; +import { mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId } from './question.js'; +import { Answer } from '../entities/questions/answer.entity.js'; + +export interface AnswerDTO { + author: UserDTO; + toQuestion: QuestionDTO; + sequenceNumber: number; + timestamp: string; + content: string; +} + +/** + * Convert a Question entity to a DTO format. + */ +export function mapToAnswerDTO(answer: Answer): AnswerDTO { + return { + author: mapToUserDTO(answer.author), + toQuestion: mapToQuestionDTO(answer.toQuestion), + sequenceNumber: answer.sequenceNumber!, + timestamp: answer.timestamp.toISOString(), + content: answer.content, + }; +} + +export interface AnswerId { + author: string; + toQuestion: QuestionId; + sequenceNumber: number; +} + +export function mapToAnswerId(answer: AnswerDTO): AnswerId { + return { + author: answer.author.username, + toQuestion: mapToQuestionId(answer.toQuestion), + sequenceNumber: answer.sequenceNumber, + }; +} diff --git a/backend/src/interfaces/assignment.ts b/backend/src/interfaces/assignment.ts new file mode 100644 index 00000000..8f6120b6 --- /dev/null +++ b/backend/src/interfaces/assignment.ts @@ -0,0 +1,52 @@ +import { FALLBACK_LANG } from '../config.js'; +import { Assignment } from '../entities/assignments/assignment.entity.js'; +import { Class } from '../entities/classes/class.entity.js'; +import { languageMap } from '../entities/content/language.js'; +import { GroupDTO, mapToGroupDTO } from './group.js'; + +export interface AssignmentDTO { + id: number; + class: string; // Id of class 'within' + title: string; + description: string; + learningPath: string; + language: string; + groups?: GroupDTO[] | string[]; // TODO +} + +export function mapToAssignmentDTOId(assignment: Assignment): AssignmentDTO { + return { + id: assignment.id!, + class: assignment.within.classId!, + title: assignment.title, + description: assignment.description, + learningPath: assignment.learningPathHruid, + language: assignment.learningPathLanguage, + // Groups: assignment.groups.map(group => group.groupNumber), + }; +} + +export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO { + return { + id: assignment.id!, + class: assignment.within.classId!, + title: assignment.title, + description: assignment.description, + learningPath: assignment.learningPathHruid, + language: assignment.learningPathLanguage, + // Groups: assignment.groups.map(mapToGroupDTO), + }; +} + +export function mapToAssignment(assignmentData: AssignmentDTO, cls: Class): Assignment { + const assignment = new Assignment(); + assignment.title = assignmentData.title; + assignment.description = assignmentData.description; + assignment.learningPathHruid = assignmentData.learningPath; + assignment.learningPathLanguage = languageMap[assignmentData.language] || FALLBACK_LANG; + assignment.within = cls; + + console.log(assignment); + + return assignment; +} diff --git a/backend/src/interfaces/class.ts b/backend/src/interfaces/class.ts new file mode 100644 index 00000000..371e3cae --- /dev/null +++ b/backend/src/interfaces/class.ts @@ -0,0 +1,37 @@ +import { Collection } from '@mikro-orm/core'; +import { Class } from '../entities/classes/class.entity.js'; +import { Student } from '../entities/users/student.entity.js'; +import { Teacher } from '../entities/users/teacher.entity.js'; + +export interface ClassDTO { + id: string; + displayName: string; + teachers: string[]; + students: string[]; + joinRequests: string[]; + endpoints?: { + self: string; + invitations: string; + assignments: string; + students: string; + }; +} + +export function mapToClassDTO(cls: Class): ClassDTO { + return { + id: cls.classId!, + displayName: cls.displayName, + teachers: cls.teachers.map((teacher) => teacher.username), + students: cls.students.map((student) => student.username), + joinRequests: [], // TODO + }; +} + +export function mapToClass(classData: ClassDTO, students: Collection, teachers: Collection): Class { + const cls = new Class(); + cls.displayName = classData.displayName; + cls.students = students; + cls.teachers = teachers; + + return cls; +} diff --git a/backend/src/interfaces/group.ts b/backend/src/interfaces/group.ts new file mode 100644 index 00000000..a25c5b8e --- /dev/null +++ b/backend/src/interfaces/group.ts @@ -0,0 +1,25 @@ +import { Group } from '../entities/assignments/group.entity.js'; +import { AssignmentDTO, mapToAssignmentDTO } from './assignment.js'; +import { mapToStudentDTO, StudentDTO } from './student.js'; + +export interface GroupDTO { + assignment: number | AssignmentDTO; + groupNumber: number; + members: string[] | StudentDTO[]; +} + +export function mapToGroupDTO(group: Group): GroupDTO { + return { + assignment: mapToAssignmentDTO(group.assignment), // ERROR: , group.assignment.within), + groupNumber: group.groupNumber!, + members: group.members.map(mapToStudentDTO), + }; +} + +export function mapToGroupDTOId(group: Group): GroupDTO { + return { + assignment: group.assignment.id!, + groupNumber: group.groupNumber!, + members: group.members.map((member) => member.username), + }; +} diff --git a/backend/src/interfaces/learning-content.ts b/backend/src/interfaces/learning-content.ts new file mode 100644 index 00000000..51474917 --- /dev/null +++ b/backend/src/interfaces/learning-content.ts @@ -0,0 +1,112 @@ +import { Language } from '../entities/content/language'; + +export interface Transition { + default: boolean; + _id: string; + next: { + _id: string; + hruid: string; + version: number; + language: string; + }; +} + +export interface LearningObjectIdentifier { + hruid: string; + language: Language; + version?: number; +} + +export interface LearningObjectNode { + _id: string; + learningobject_hruid: string; + version: number; + language: Language; + start_node?: boolean; + transitions: Transition[]; + created_at: string; + updatedAt: string; + done?: boolean; // True if a submission exists for this node by the user for whom the learning path is customized. +} + +export interface LearningPath { + _id: string; + language: string; + hruid: string; + title: string; + description: string; + image?: string; // Image might be missing, so it's optional + num_nodes: number; + num_nodes_left: number; + nodes: LearningObjectNode[]; + keywords: string; + target_ages: number[]; + min_age: number; + max_age: number; + __order: number; +} + +export interface LearningPathIdentifier { + hruid: string; + language: Language; +} + +export interface EducationalGoal { + source: string; + id: string; +} + +export interface ReturnValue { + callback_url: string; + callback_schema: Record; +} + +export interface LearningObjectMetadata { + _id: string; + uuid: string; + hruid: string; + version: number; + language: Language; + title: string; + description: string; + difficulty: number; + estimated_time: number; + available: boolean; + teacher_exclusive: boolean; + educational_goals: EducationalGoal[]; + keywords: string[]; + target_ages: number[]; + content_type: string; // Markdown, image, etc. + content_location?: string; + skos_concepts?: string[]; + return_value?: ReturnValue; +} + +export interface FilteredLearningObject { + key: string; + _id: string; + uuid: string; + version: number; + title: string; + htmlUrl: string; + language: Language; + difficulty: number; + estimatedTime?: number; + available: boolean; + teacherExclusive: boolean; + educationalGoals: EducationalGoal[]; + keywords: string[]; + description: string; + targetAges: number[]; + contentType: string; + contentLocation?: string; + skosConcepts?: string[]; + returnValue?: ReturnValue; +} + +export interface LearningPathResponse { + success: boolean; + source: string; + data: LearningPath[] | null; + message?: string; +} diff --git a/backend/src/interfaces/list.ts b/backend/src/interfaces/list.ts new file mode 100644 index 00000000..6892fb9d --- /dev/null +++ b/backend/src/interfaces/list.ts @@ -0,0 +1,5 @@ +// TODO: implement something like this but with named endpoints +export interface List { + items: T[]; + endpoints?: string[]; +} diff --git a/backend/src/interfaces/question.ts b/backend/src/interfaces/question.ts new file mode 100644 index 00000000..8cca42f6 --- /dev/null +++ b/backend/src/interfaces/question.ts @@ -0,0 +1,44 @@ +import { Question } from '../entities/questions/question.entity.js'; +import { UserDTO } from './user.js'; +import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; +import { mapToStudentDTO, StudentDTO } from './student.js'; +import { TeacherDTO } from './teacher.js'; + +export interface QuestionDTO { + learningObjectIdentifier: LearningObjectIdentifier; + sequenceNumber?: number; + author: StudentDTO; + timestamp?: string; + content: string; +} + +/** + * Convert a Question entity to a DTO format. + */ +export function mapToQuestionDTO(question: Question): QuestionDTO { + const learningObjectIdentifier = { + hruid: question.learningObjectHruid, + language: question.learningObjectLanguage, + version: question.learningObjectVersion, + }; + + return { + learningObjectIdentifier, + sequenceNumber: question.sequenceNumber!, + author: mapToStudentDTO(question.author), + timestamp: question.timestamp.toISOString(), + content: question.content, + }; +} + +export interface QuestionId { + learningObjectIdentifier: LearningObjectIdentifier; + sequenceNumber: number; +} + +export function mapToQuestionId(question: QuestionDTO): QuestionId { + return { + learningObjectIdentifier: question.learningObjectIdentifier, + sequenceNumber: question.sequenceNumber!, + }; +} diff --git a/backend/src/interfaces/student.ts b/backend/src/interfaces/student.ts new file mode 100644 index 00000000..079b355b --- /dev/null +++ b/backend/src/interfaces/student.ts @@ -0,0 +1,29 @@ +import { Student } from '../entities/users/student.entity.js'; + +export interface StudentDTO { + id: string; + username: string; + firstName: string; + lastName: string; + endpoints?: { + classes: string; + questions: string; + invitations: string; + groups: string; + }; +} + +export function mapToStudentDTO(student: Student): StudentDTO { + return { + id: student.username, + username: student.username, + firstName: student.firstName, + lastName: student.lastName, + }; +} + +export function mapToStudent(studentData: StudentDTO): Student { + const student = new Student(studentData.username, studentData.firstName, studentData.lastName); + + return student; +} diff --git a/backend/src/interfaces/submission.ts b/backend/src/interfaces/submission.ts new file mode 100644 index 00000000..fbaf520d --- /dev/null +++ b/backend/src/interfaces/submission.ts @@ -0,0 +1,47 @@ +import { Submission } from '../entities/assignments/submission.entity.js'; +import { Language } from '../entities/content/language.js'; +import { GroupDTO, mapToGroupDTO } from './group.js'; +import { mapToStudent, mapToStudentDTO, StudentDTO } from './student.js'; +import { mapToUser } from './user'; +import { Student } from '../entities/users/student.entity'; + +export interface SubmissionDTO { + learningObjectHruid: string; + learningObjectLanguage: Language; + learningObjectVersion: number; + + submissionNumber?: number; + submitter: StudentDTO; + time?: Date; + group?: GroupDTO; + content: string; +} + +export function mapToSubmissionDTO(submission: Submission): SubmissionDTO { + return { + learningObjectHruid: submission.learningObjectHruid, + learningObjectLanguage: submission.learningObjectLanguage, + learningObjectVersion: submission.learningObjectVersion, + + submissionNumber: submission.submissionNumber, + submitter: mapToStudentDTO(submission.submitter), + time: submission.submissionTime, + group: submission.onBehalfOf ? mapToGroupDTO(submission.onBehalfOf) : undefined, + content: submission.content, + }; +} + +export function mapToSubmission(submissionDTO: SubmissionDTO): Submission { + const submission = new Submission(); + submission.learningObjectHruid = submissionDTO.learningObjectHruid; + submission.learningObjectLanguage = submissionDTO.learningObjectLanguage; + submission.learningObjectVersion = submissionDTO.learningObjectVersion; + // Submission.submissionNumber = submissionDTO.submissionNumber; + submission.submitter = mapToStudent(submissionDTO.submitter); + // Submission.submissionTime = submissionDTO.time; + // Submission.onBehalfOf = submissionDTO.group!; + // TODO fix group + submission.content = submissionDTO.content; + + return submission; +} diff --git a/backend/src/interfaces/teacher-invitation.ts b/backend/src/interfaces/teacher-invitation.ts new file mode 100644 index 00000000..cddef566 --- /dev/null +++ b/backend/src/interfaces/teacher-invitation.ts @@ -0,0 +1,25 @@ +import { TeacherInvitation } from '../entities/classes/teacher-invitation.entity.js'; +import { ClassDTO, mapToClassDTO } from './class.js'; +import { mapToUserDTO, UserDTO } from './user.js'; + +export interface TeacherInvitationDTO { + sender: string | UserDTO; + receiver: string | UserDTO; + class: string | ClassDTO; +} + +export function mapToTeacherInvitationDTO(invitation: TeacherInvitation): TeacherInvitationDTO { + return { + sender: mapToUserDTO(invitation.sender), + receiver: mapToUserDTO(invitation.receiver), + class: mapToClassDTO(invitation.class), + }; +} + +export function mapToTeacherInvitationDTOIds(invitation: TeacherInvitation): TeacherInvitationDTO { + return { + sender: invitation.sender.username, + receiver: invitation.receiver.username, + class: invitation.class.classId!, + }; +} diff --git a/backend/src/interfaces/teacher.ts b/backend/src/interfaces/teacher.ts new file mode 100644 index 00000000..4dd6adb4 --- /dev/null +++ b/backend/src/interfaces/teacher.ts @@ -0,0 +1,29 @@ +import { Teacher } from '../entities/users/teacher.entity.js'; + +export interface TeacherDTO { + id: string; + username: string; + firstName: string; + lastName: string; + endpoints?: { + classes: string; + questions: string; + invitations: string; + groups: string; + }; +} + +export function mapToTeacherDTO(teacher: Teacher): TeacherDTO { + return { + id: teacher.username, + username: teacher.username, + firstName: teacher.firstName, + lastName: teacher.lastName, + }; +} + +export function mapToTeacher(TeacherData: TeacherDTO): Teacher { + const teacher = new Teacher(TeacherData.username, TeacherData.firstName, TeacherData.lastName); + + return teacher; +} diff --git a/backend/src/interfaces/user.ts b/backend/src/interfaces/user.ts new file mode 100644 index 00000000..58f0dd5a --- /dev/null +++ b/backend/src/interfaces/user.ts @@ -0,0 +1,30 @@ +import { User } from '../entities/users/user.entity.js'; + +export interface UserDTO { + id?: string; + username: string; + firstName: string; + lastName: string; + endpoints?: { + self: string; + classes: string; + questions: string; + invitations: string; + }; +} + +export function mapToUserDTO(user: User): UserDTO { + return { + id: user.username, + username: user.username, + firstName: user.firstName, + lastName: user.lastName, + }; +} + +export function mapToUser(userData: UserDTO, userInstance: T): T { + userInstance.username = userData.username; + userInstance.firstName = userData.firstName; + userInstance.lastName = userData.lastName; + return userInstance; +} diff --git a/backend/src/logging/initalize.ts b/backend/src/logging/initalize.ts new file mode 100644 index 00000000..1ff761c9 --- /dev/null +++ b/backend/src/logging/initalize.ts @@ -0,0 +1,53 @@ +import { createLogger, format, Logger as WinstonLogger, transports } from 'winston'; +import LokiTransport from 'winston-loki'; +import { LokiLabels } from 'loki-logger-ts'; +import { LOG_LEVEL, LOKI_HOST } from '../config.js'; + +export class Logger extends WinstonLogger { + constructor() { + super(); + } +} + +const Labels: LokiLabels = { + source: 'Dwengo-Backend', + service: 'API', + host: 'localhost', +}; + +let logger: Logger; + +function initializeLogger(): Logger { + if (logger !== undefined) { + return logger; + } + + const lokiTransport: LokiTransport = new LokiTransport({ + host: LOKI_HOST, + labels: Labels, + level: LOG_LEVEL, + json: true, + format: format.combine(format.timestamp(), format.json()), + onConnectionError: (err) => { + // eslint-disable-next-line no-console + console.error(`Connection error: ${err}`); + }, + }); + + const consoleTransport = new transports.Console({ + level: LOG_LEVEL, + format: format.combine(format.cli(), format.colorize()), + }); + + logger = createLogger({ + transports: [lokiTransport, consoleTransport], + }); + + logger.debug(`Logger initialized with level ${LOG_LEVEL}, Loki host ${LOKI_HOST}`); + return logger; +} + +export function getLogger(): Logger { + logger ||= initializeLogger(); + return logger; +} diff --git a/backend/src/logging/mikroOrmLogger.ts b/backend/src/logging/mikroOrmLogger.ts new file mode 100644 index 00000000..25bbac13 --- /dev/null +++ b/backend/src/logging/mikroOrmLogger.ts @@ -0,0 +1,69 @@ +import { DefaultLogger, LogContext, LoggerNamespace } from '@mikro-orm/core'; +import { getLogger, Logger } from './initalize.js'; +import { LokiLabels } from 'loki-logger-ts'; + +export class MikroOrmLogger extends DefaultLogger { + private logger: Logger = getLogger(); + + log(namespace: LoggerNamespace, message: string, context?: LogContext) { + if (!this.isEnabled(namespace, context)) { + return; + } + + switch (namespace) { + case 'query': + this.logger.debug(this.createMessage(namespace, message, context)); + break; + case 'query-params': + // TODO Which log level should this be? + this.logger.info(this.createMessage(namespace, message, context)); + break; + case 'schema': + this.logger.info(this.createMessage(namespace, message, context)); + break; + case 'discovery': + this.logger.debug(this.createMessage(namespace, message, context)); + break; + case 'info': + this.logger.info(this.createMessage(namespace, message, context)); + break; + case 'deprecated': + this.logger.warn(this.createMessage(namespace, message, context)); + break; + default: + switch (context?.level) { + case 'info': + this.logger.info(this.createMessage(namespace, message, context)); + break; + case 'warning': + this.logger.warn(message); + break; + case 'error': + this.logger.error(message); + break; + default: + this.logger.debug(message); + break; + } + } + } + + private createMessage(namespace: LoggerNamespace, messageArg: string, context?: LogContext) { + const labels: LokiLabels = { + service: 'ORM', + }; + + let message: string; + if (context?.label) { + message = `[${namespace}] (${context?.label}) ${messageArg}`; + } else { + message = `[${namespace}] ${messageArg}`; + } + + return { + message: message, + labels: labels, + context: context, + }; + } +} diff --git a/backend/src/logging/responseTimeLogger.ts b/backend/src/logging/responseTimeLogger.ts new file mode 100644 index 00000000..c1bb1e33 --- /dev/null +++ b/backend/src/logging/responseTimeLogger.ts @@ -0,0 +1,21 @@ +import { getLogger, Logger } from './initalize.js'; +import { Request, Response } from 'express'; + +export function responseTimeLogger(req: Request, res: Response, time: number) { + const logger: Logger = getLogger(); + + const method = req.method; + const url = req.url; + const status = res.statusCode; + + logger.info({ + message: 'Request completed', + method: method, + url: url, + status: status, + responseTime: Number(time), + labels: { + type: 'responseTime', + }, + }); +} diff --git a/backend/src/middleware/auth/auth.ts b/backend/src/middleware/auth/auth.ts new file mode 100644 index 00000000..5ff5a53c --- /dev/null +++ b/backend/src/middleware/auth/auth.ts @@ -0,0 +1,141 @@ +import { EnvVars, getEnvVar } from '../../util/envvars.js'; +import { expressjwt } from 'express-jwt'; +import { JwtPayload } from 'jsonwebtoken'; +import jwksClient from 'jwks-rsa'; +import * as express from 'express'; +import * as jwt from 'jsonwebtoken'; +import { AuthenticatedRequest } from './authenticated-request.js'; +import { AuthenticationInfo } from './authentication-info.js'; +import { ForbiddenException, UnauthorizedException } from '../../exceptions.js'; + +const JWKS_CACHE = true; +const JWKS_RATE_LIMIT = true; +const REQUEST_PROPERTY_FOR_JWT_PAYLOAD = 'jwtPayload'; +const JWT_ALGORITHM = 'RS256'; // Not configurable via env vars since supporting other algorithms would +// Require additional libraries to be added. + +const JWT_PROPERTY_NAMES = { + username: 'preferred_username', + firstName: 'given_name', + lastName: 'family_name', + name: 'name', + email: 'email', +}; + +function createJwksClient(uri: string): jwksClient.JwksClient { + return jwksClient({ + cache: JWKS_CACHE, + rateLimit: JWKS_RATE_LIMIT, + jwksUri: uri, + }); +} + +const idpConfigs = { + student: { + issuer: getEnvVar(EnvVars.IdpStudentUrl), + jwksClient: createJwksClient(getEnvVar(EnvVars.IdpStudentJwksEndpoint)), + }, + teacher: { + issuer: getEnvVar(EnvVars.IdpTeacherUrl), + jwksClient: createJwksClient(getEnvVar(EnvVars.IdpTeacherJwksEndpoint)), + }, +}; + +/** + * Express middleware which verifies the JWT Bearer token if one is given in the request. + */ +const verifyJwtToken = expressjwt({ + secret: async (_: express.Request, token: jwt.Jwt | undefined) => { + if (!token?.payload || !(token.payload as JwtPayload).iss) { + throw new Error('Invalid token'); + } + + const issuer = (token.payload as JwtPayload).iss; + + const idpConfig = Object.values(idpConfigs).find((config) => config.issuer === issuer); + if (!idpConfig) { + throw new Error('Issuer not accepted.'); + } + + const signingKey = await idpConfig.jwksClient.getSigningKey(token.header.kid); + if (!signingKey) { + throw new Error('Signing key not found.'); + } + return signingKey.getPublicKey(); + }, + audience: getEnvVar(EnvVars.IdpAudience), + algorithms: [JWT_ALGORITHM], + credentialsRequired: false, + requestProperty: REQUEST_PROPERTY_FOR_JWT_PAYLOAD, +}); + +/** + * Get an object with information about the authenticated user from a given authenticated request. + */ +function getAuthenticationInfo(req: AuthenticatedRequest): AuthenticationInfo | undefined { + if (!req.jwtPayload) { + return; + } + const issuer = req.jwtPayload.iss; + let accountType: 'student' | 'teacher'; + + if (issuer === idpConfigs.student.issuer) { + accountType = 'student'; + } else if (issuer === idpConfigs.teacher.issuer) { + accountType = 'teacher'; + } else { + return; + } + return { + accountType: accountType, + username: req.jwtPayload[JWT_PROPERTY_NAMES.username]!, + name: req.jwtPayload[JWT_PROPERTY_NAMES.name], + firstName: req.jwtPayload[JWT_PROPERTY_NAMES.firstName], + lastName: req.jwtPayload[JWT_PROPERTY_NAMES.lastName], + email: req.jwtPayload[JWT_PROPERTY_NAMES.email], + }; +} + +/** + * Add the AuthenticationInfo object with the information about the current authentication to the request in order + * to avoid that the routers have to deal with the JWT token. + */ +const addAuthenticationInfo = (req: AuthenticatedRequest, res: express.Response, next: express.NextFunction) => { + req.auth = getAuthenticationInfo(req); + next(); +}; + +export const authenticateUser = [verifyJwtToken, addAuthenticationInfo]; + +/** + * Middleware which rejects unauthenticated users (with HTTP 401) and authenticated users which do not fulfill + * the given access condition. + * @param accessCondition Predicate over the current AuthenticationInfo. Access is only granted when this evaluates + * to true. + */ +export const authorize = + (accessCondition: (auth: AuthenticationInfo) => boolean) => + (req: AuthenticatedRequest, res: express.Response, next: express.NextFunction): void => { + if (!req.auth) { + throw new UnauthorizedException(); + } else if (!accessCondition(req.auth)) { + throw new ForbiddenException(); + } else { + next(); + } + }; + +/** + * Middleware which rejects all unauthenticated users, but accepts all authenticated users. + */ +export const authenticatedOnly = authorize((_) => true); + +/** + * Middleware which rejects requests from unauthenticated users or users that aren't students. + */ +export const studentsOnly = authorize((auth) => auth.accountType === 'student'); + +/** + * Middleware which rejects requests from unauthenticated users or users that aren't teachers. + */ +export const teachersOnly = authorize((auth) => auth.accountType === 'teacher'); diff --git a/backend/src/middleware/auth/authenticated-request.d.ts b/backend/src/middleware/auth/authenticated-request.d.ts new file mode 100644 index 00000000..9737fa7e --- /dev/null +++ b/backend/src/middleware/auth/authenticated-request.d.ts @@ -0,0 +1,9 @@ +import { Request } from 'express'; +import { JwtPayload } from 'jsonwebtoken'; +import { AuthenticationInfo } from './authentication-info.js'; + +export interface AuthenticatedRequest extends Request { + // Properties are optional since the user is not necessarily authenticated. + jwtPayload?: JwtPayload; + auth?: AuthenticationInfo; +} diff --git a/backend/src/middleware/auth/authentication-info.d.ts b/backend/src/middleware/auth/authentication-info.d.ts new file mode 100644 index 00000000..4b060dfa --- /dev/null +++ b/backend/src/middleware/auth/authentication-info.d.ts @@ -0,0 +1,11 @@ +/** + * Object with information about the user who is currently logged in. + */ +export type AuthenticationInfo = { + accountType: 'student' | 'teacher'; + username: string; + name?: string; + firstName?: string; + lastName?: string; + email?: string; +}; diff --git a/backend/src/middleware/cors.ts b/backend/src/middleware/cors.ts new file mode 100644 index 00000000..3d2c9be0 --- /dev/null +++ b/backend/src/middleware/cors.ts @@ -0,0 +1,7 @@ +import cors from 'cors'; +import { EnvVars, getEnvVar } from '../util/envvars.js'; + +export default cors({ + origin: getEnvVar(EnvVars.CorsAllowedOrigins).split(','), + allowedHeaders: getEnvVar(EnvVars.CorsAllowedHeaders).split(','), +}); diff --git a/backend/src/mikro-orm.config.ts b/backend/src/mikro-orm.config.ts new file mode 100644 index 00000000..c9cf6ed9 --- /dev/null +++ b/backend/src/mikro-orm.config.ts @@ -0,0 +1,77 @@ +import { LoggerOptions, Options } from '@mikro-orm/core'; +import { PostgreSqlDriver } from '@mikro-orm/postgresql'; +import { EnvVars, getEnvVar, getNumericEnvVar } from './util/envvars.js'; +import { SqliteDriver } from '@mikro-orm/sqlite'; +import { MikroOrmLogger } from './logging/mikroOrmLogger.js'; +import { LOG_LEVEL } from './config.js'; + +// Import alle entity-bestanden handmatig +import { User } from './entities/users/user.entity.js'; +import { Student } from './entities/users/student.entity.js'; +import { Teacher } from './entities/users/teacher.entity.js'; + +import { Assignment } from './entities/assignments/assignment.entity.js'; +import { Group } from './entities/assignments/group.entity.js'; +import { Submission } from './entities/assignments/submission.entity.js'; + +import { Class } from './entities/classes/class.entity.js'; +import { ClassJoinRequest } from './entities/classes/class-join-request.entity.js'; +import { TeacherInvitation } from './entities/classes/teacher-invitation.entity.js'; + +import { Attachment } from './entities/content/attachment.entity.js'; +import { LearningObject } from './entities/content/learning-object.entity.js'; +import { LearningPath } from './entities/content/learning-path.entity.js'; + +import { Answer } from './entities/questions/answer.entity.js'; +import { Question } from './entities/questions/question.entity.js'; +import { SqliteAutoincrementSubscriber } from './sqlite-autoincrement-workaround.js'; + +const entities = [ + User, + Student, + Teacher, + Assignment, + Group, + Submission, + Class, + ClassJoinRequest, + TeacherInvitation, + Attachment, + LearningObject, + LearningPath, + Answer, + Question, +]; + +function config(testingMode: boolean = false): Options { + if (testingMode) { + return { + driver: SqliteDriver, + dbName: getEnvVar(EnvVars.DbName), + subscribers: [new SqliteAutoincrementSubscriber()], + entities: entities, + // EntitiesTs: entitiesTs, + + // Workaround: vitest: `TypeError: Unknown file extension ".ts"` (ERR_UNKNOWN_FILE_EXTENSION) + // (see https://mikro-orm.io/docs/guide/project-setup#testing-the-endpoint) + dynamicImportProvider: (id) => import(id), + }; + } + + return { + driver: PostgreSqlDriver, + host: getEnvVar(EnvVars.DbHost), + port: getNumericEnvVar(EnvVars.DbPort), + dbName: getEnvVar(EnvVars.DbName), + user: getEnvVar(EnvVars.DbUsername), + password: getEnvVar(EnvVars.DbPassword), + entities: entities, + // EntitiesTs: entitiesTs, + + // Logging + debug: LOG_LEVEL === 'debug', + loggerFactory: (options: LoggerOptions) => new MikroOrmLogger(options), + }; +} + +export default config; diff --git a/backend/src/orm.ts b/backend/src/orm.ts new file mode 100644 index 00000000..93feea7a --- /dev/null +++ b/backend/src/orm.ts @@ -0,0 +1,34 @@ +import { EntityManager, MikroORM } from '@mikro-orm/core'; +import config from './mikro-orm.config.js'; +import { EnvVars, getEnvVar } from './util/envvars.js'; +import { getLogger, Logger } from './logging/initalize.js'; + +let orm: MikroORM | undefined; +export async function initORM(testingMode: boolean = false) { + const logger: Logger = getLogger(); + + logger.info('Initializing ORM'); + logger.debug('MikroORM config is', config); + + orm = await MikroORM.init(config(testingMode)); + // Update the database scheme if necessary and enabled. + if (getEnvVar(EnvVars.DbUpdate)) { + await orm.schema.updateSchema(); + } else { + const diff = await orm.schema.getUpdateSchemaSQL(); + if (diff) { + throw Error( + 'The database structure needs to be updated in order to fit the new database structure ' + + 'of the app. In order to do so automatically, set the environment variable DWENGO_DB_UPDATE to true. ' + + 'The following queries will then be executed:\n' + + diff + ); + } + } +} +export function forkEntityManager(): EntityManager { + if (!orm) { + throw Error('Accessing the Entity Manager before the ORM is fully initialized.'); + } + return orm.em.fork(); +} diff --git a/backend/src/routes/assignments.ts b/backend/src/routes/assignments.ts new file mode 100644 index 00000000..a733d093 --- /dev/null +++ b/backend/src/routes/assignments.ts @@ -0,0 +1,30 @@ +import express from 'express'; +import { + createAssignmentHandler, + getAllAssignmentsHandler, + getAssignmentHandler, + getAssignmentsSubmissionsHandler, +} from '../controllers/assignments.js'; +import groupRouter from './groups.js'; + +const router = express.Router({ mergeParams: true }); + +// Root endpoint used to search objects +router.get('/', getAllAssignmentsHandler); + +router.post('/', createAssignmentHandler); + +// Information about an assignment with id 'id' +router.get('/:id', getAssignmentHandler); + +router.get('/:id/submissions', getAssignmentsSubmissionsHandler); + +router.get('/:id/questions', (req, res) => { + res.json({ + questions: ['0'], + }); +}); + +router.use('/:assignmentid/groups', groupRouter); + +export default router; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 00000000..778e51fd --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,26 @@ +import express from 'express'; +import { getFrontendAuthConfig } from '../controllers/auth.js'; +import { authenticatedOnly, studentsOnly, teachersOnly } from '../middleware/auth/auth.js'; +const router = express.Router(); + +// Returns auth configuration for frontend +router.get('/config', (req, res) => { + res.json(getFrontendAuthConfig()); +}); + +router.get('/testAuthenticatedOnly', authenticatedOnly, (req, res) => { + /* #swagger.security = [{ "student": [ ] }, { "teacher": [ ] }] */ + res.json({ message: 'If you see this, you should be authenticated!' }); +}); + +router.get('/testStudentsOnly', studentsOnly, (req, res) => { + /* #swagger.security = [{ "student": [ ] }] */ + res.json({ message: 'If you see this, you should be a student!' }); +}); + +router.get('/testTeachersOnly', teachersOnly, (req, res) => { + /* #swagger.security = [{ "teacher": [ ] }] */ + res.json({ message: 'If you see this, you should be a teacher!' }); +}); + +export default router; diff --git a/backend/src/routes/classes.ts b/backend/src/routes/classes.ts new file mode 100644 index 00000000..e0972988 --- /dev/null +++ b/backend/src/routes/classes.ts @@ -0,0 +1,27 @@ +import express from 'express'; +import { + createClassHandler, + getAllClassesHandler, + getClassHandler, + getClassStudentsHandler, + getTeacherInvitationsHandler, +} from '../controllers/classes.js'; +import assignmentRouter from './assignments.js'; + +const router = express.Router(); + +// Root endpoint used to search objects +router.get('/', getAllClassesHandler); + +router.post('/', createClassHandler); + +// Information about an class with id 'id' +router.get('/:id', getClassHandler); + +router.get('/:id/teacher-invitations', getTeacherInvitationsHandler); + +router.get('/:id/students', getClassStudentsHandler); + +router.use('/:classid/assignments', assignmentRouter); + +export default router; diff --git a/backend/src/routes/groups.ts b/backend/src/routes/groups.ts new file mode 100644 index 00000000..0c9692b0 --- /dev/null +++ b/backend/src/routes/groups.ts @@ -0,0 +1,23 @@ +import express from 'express'; +import { createGroupHandler, getAllGroupsHandler, getGroupHandler, getGroupSubmissionsHandler } from '../controllers/groups.js'; + +const router = express.Router({ mergeParams: true }); + +// Root endpoint used to search objects +router.get('/', getAllGroupsHandler); + +router.post('/', createGroupHandler); + +// Information about a group (members, ... [TODO DOC]) +router.get('/:groupid', getGroupHandler); + +router.get('/:groupid', getGroupSubmissionsHandler); + +// The list of questions a group has made +router.get('/:id/questions', (req, res) => { + res.json({ + questions: ['0'], + }); +}); + +export default router; diff --git a/backend/src/routes/learning-objects.ts b/backend/src/routes/learning-objects.ts new file mode 100644 index 00000000..7532765b --- /dev/null +++ b/backend/src/routes/learning-objects.ts @@ -0,0 +1,43 @@ +import express from 'express'; +import { getAllLearningObjects, getAttachment, getLearningObject, getLearningObjectHTML } from '../controllers/learning-objects.js'; + +import submissionRoutes from './submissions.js'; +import questionRoutes from './questions.js'; + +const router = express.Router(); + +// DWENGO learning objects + +// Queries: hruid(path), full, language +// Route to fetch list of learning objects based on hruid of learning path + +// Route 1: list of object hruids +// Example 1: http://localhost:3000/learningObject?hruid=un_artificiele_intelligentie + +// Route 2: list of object data +// Example 2: http://localhost:3000/learningObject?full=true&hruid=un_artificiele_intelligentie +router.get('/', getAllLearningObjects); + +// Parameter: hruid of learning object +// Query: language +// Route to fetch data of one learning object based on its hruid +// Example: http://localhost:3000/learningObject/un_ai7 +router.get('/:hruid', getLearningObject); + +router.use('/:hruid/submissions', submissionRoutes); + +router.use('/:hruid/:version/questions', questionRoutes); + +// Parameter: hruid of learning object +// Query: language, version (optional) +// Route to fetch the HTML rendering of one learning object based on its hruid. +// Example: http://localhost:3000/learningObject/un_ai7/html +router.get('/:hruid/html', getLearningObjectHTML); + +// Parameter: hruid of learning object, name of attachment. +// Query: language, version (optional). +// Route to get the raw data of the attachment for one learning object based on its hruid. +// Example: http://localhost:3000/learningObject/u_test/attachment/testimage.png +router.get('/:hruid/html/:attachmentName', getAttachment); + +export default router; diff --git a/backend/src/routes/learning-paths.ts b/backend/src/routes/learning-paths.ts new file mode 100644 index 00000000..efe17312 --- /dev/null +++ b/backend/src/routes/learning-paths.ts @@ -0,0 +1,27 @@ +import express from 'express'; +import { getLearningPaths } from '../controllers/learning-paths.js'; + +const router = express.Router(); + +// DWENGO learning paths + +// Route 1: no query +// Fetch all learning paths +// Example 1: http://localhost:3000/learningPath + +// Unified route for fetching learning paths +// Route 2: Query: hruid (list), language +// Fetch learning paths based on hruid list +// Example 2: http://localhost:3000/learningPath?hruid=pn_werking&hruid=art1 + +// Query: search, language +// Route to fetch learning paths based on a searchterm +// Example 3: http://localhost:3000/learningPath?search=robot + +// Query: theme, anguage +// Route to fetch learning paths based on a theme +// Example: http://localhost:3000/learningPath?theme=kiks + +router.get('/', getLearningPaths); + +export default router; diff --git a/backend/src/routes/questions.ts b/backend/src/routes/questions.ts new file mode 100644 index 00000000..31a71f3b --- /dev/null +++ b/backend/src/routes/questions.ts @@ -0,0 +1,25 @@ +import express from 'express'; +import { + createQuestionHandler, + deleteQuestionHandler, + getAllQuestionsHandler, + getQuestionAnswersHandler, + getQuestionHandler, +} from '../controllers/questions.js'; +const router = express.Router({ mergeParams: true }); + +// Query language + +// Root endpoint used to search objects +router.get('/', getAllQuestionsHandler); + +router.post('/', createQuestionHandler); + +router.delete('/:seq', deleteQuestionHandler); + +// Information about a question with id +router.get('/:seq', getQuestionHandler); + +router.get('/answers/:seq', getQuestionAnswersHandler); + +export default router; diff --git a/backend/src/routes/router.ts b/backend/src/routes/router.ts new file mode 100644 index 00000000..ef24000e --- /dev/null +++ b/backend/src/routes/router.ts @@ -0,0 +1,35 @@ +import { Response, Router } from 'express'; +import studentRouter from './student.js'; +import groupRouter from './group.js'; +import assignmentRouter from './assignment.js'; +import submissionRouter from './submission.js'; +import classRouter from './class.js'; +import questionRouter from './question.js'; +import authRouter from './auth.js'; +import themeRoutes from './themes.js'; +import learningPathRoutes from './learning-paths.js'; +import learningObjectRoutes from './learning-objects.js'; +import { getLogger, Logger } from '../logging/initalize.js'; + +const router = Router(); +const logger: Logger = getLogger(); + +router.get('/', (_, res: Response) => { + logger.debug('GET /'); + res.json({ + message: 'Hello Dwengo!🚀', + }); +}); + +router.use('/student', studentRouter /* #swagger.tags = ['Student'] */); +router.use('/group', groupRouter /* #swagger.tags = ['Group'] */); +router.use('/assignment', assignmentRouter /* #swagger.tags = ['Assignment'] */); +router.use('/submission', submissionRouter /* #swagger.tags = ['Submission'] */); +router.use('/class', classRouter /* #swagger.tags = ['Class'] */); +router.use('/question', questionRouter /* #swagger.tags = ['Question'] */); +router.use('/auth', authRouter /* #swagger.tags = ['Auth'] */); +router.use('/theme', themeRoutes /* #swagger.tags = ['Theme'] */); +router.use('/learningPath', learningPathRoutes /* #swagger.tags = ['Learning Path'] */); +router.use('/learningObject', learningObjectRoutes /* #swagger.tags = ['Learning Object'] */); + +export default router; diff --git a/backend/src/routes/students.ts b/backend/src/routes/students.ts new file mode 100644 index 00000000..7ed7a666 --- /dev/null +++ b/backend/src/routes/students.ts @@ -0,0 +1,46 @@ +import express from 'express'; +import { + createStudentHandler, + deleteStudentHandler, + getAllStudentsHandler, + getStudentAssignmentsHandler, + getStudentClassesHandler, + getStudentGroupsHandler, + getStudentHandler, + getStudentSubmissionsHandler, +} from '../controllers/students.js'; +import { getStudentGroups } from '../services/students.js'; +const router = express.Router(); + +// Root endpoint used to search objects +router.get('/', getAllStudentsHandler); + +router.post('/', createStudentHandler); + +router.delete('/', deleteStudentHandler); + +router.delete('/:username', deleteStudentHandler); + +// Information about a student's profile +router.get('/:username', getStudentHandler); + +// The list of classes a student is in +router.get('/:id/classes', getStudentClassesHandler); + +// The list of submissions a student has made +router.get('/:id/submissions', getStudentSubmissionsHandler); + +// The list of assignments a student has +router.get('/:id/assignments', getStudentAssignmentsHandler); + +// The list of groups a student is in +router.get('/:id/groups', getStudentGroupsHandler); + +// A list of questions a user has created +router.get('/:id/questions', (req, res) => { + res.json({ + questions: ['0'], + }); +}); + +export default router; diff --git a/backend/src/routes/submissions.ts b/backend/src/routes/submissions.ts new file mode 100644 index 00000000..4db93027 --- /dev/null +++ b/backend/src/routes/submissions.ts @@ -0,0 +1,19 @@ +import express from 'express'; +import { createSubmissionHandler, deleteSubmissionHandler, getSubmissionHandler } from '../controllers/submissions.js'; +const router = express.Router({ mergeParams: true }); + +// Root endpoint used to search objects +router.get('/', (req, res) => { + res.json({ + submissions: ['0', '1'], + }); +}); + +router.post('/:id', createSubmissionHandler); + +// Information about an submission with id 'id' +router.get('/:id', getSubmissionHandler); + +router.delete('/:id', deleteSubmissionHandler); + +export default router; diff --git a/backend/src/routes/teachers.ts b/backend/src/routes/teachers.ts new file mode 100644 index 00000000..c04e1575 --- /dev/null +++ b/backend/src/routes/teachers.ts @@ -0,0 +1,37 @@ +import express from 'express'; +import { + createTeacherHandler, + deleteTeacherHandler, + getAllTeachersHandler, + getTeacherClassHandler, + getTeacherHandler, + getTeacherQuestionHandler, + getTeacherStudentHandler, +} from '../controllers/teachers.js'; +const router = express.Router(); + +// Root endpoint used to search objects +router.get('/', getAllTeachersHandler); + +router.post('/', createTeacherHandler); + +router.delete('/', deleteTeacherHandler); + +router.get('/:username', getTeacherHandler); + +router.delete('/:username', deleteTeacherHandler); + +router.get('/:username/classes', getTeacherClassHandler); + +router.get('/:username/students', getTeacherStudentHandler); + +router.get('/:username/questions', getTeacherQuestionHandler); + +// Invitations to other classes a teacher received +router.get('/:id/invitations', (req, res) => { + res.json({ + invitations: ['0'], + }); +}); + +export default router; diff --git a/backend/src/routes/themes.ts b/backend/src/routes/themes.ts new file mode 100644 index 00000000..388b3e38 --- /dev/null +++ b/backend/src/routes/themes.ts @@ -0,0 +1,14 @@ +import express from 'express'; +import { getThemes, getThemeByTitle } from '../controllers/themes.js'; + +const router = express.Router(); + +// Query: language +// Route to fetch list of {key, title, description, image} themes in their respective language +router.get('/', getThemes); + +// Arg: theme (key) +// Route to fetch list of hruids based on theme +router.get('/:theme', getThemeByTitle); + +export default router; diff --git a/backend/src/services/assignments.ts b/backend/src/services/assignments.ts new file mode 100644 index 00000000..be121810 --- /dev/null +++ b/backend/src/services/assignments.ts @@ -0,0 +1,85 @@ +import { getAssignmentRepository, getClassRepository, getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; +import { Assignment } from '../entities/assignments/assignment.entity.js'; +import { AssignmentDTO, mapToAssignment, mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; +import { mapToSubmissionDTO, SubmissionDTO } from '../interfaces/submission.js'; + +export async function getAllAssignments(classid: string, full: boolean): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classid); + + if (!cls) { + return []; + } + + const assignmentRepository = getAssignmentRepository(); + const assignments = await assignmentRepository.findAllAssignmentsInClass(cls); + + if (full) { + return assignments.map(mapToAssignmentDTO); + } + + return assignments.map(mapToAssignmentDTOId); +} + +export async function createAssignment(classid: string, assignmentData: AssignmentDTO): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classid); + + if (!cls) { + return null; + } + + const assignment = mapToAssignment(assignmentData, cls); + const assignmentRepository = getAssignmentRepository(); + + try { + const newAssignment = assignmentRepository.create(assignment); + await assignmentRepository.save(newAssignment); + + return newAssignment; + } catch (e) { + return null; + } +} + +export async function getAssignment(classid: string, id: number): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classid); + + if (!cls) { + return null; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, id); + + if (!assignment) { + return null; + } + + return mapToAssignmentDTO(assignment); +} + +export async function getAssignmentsSubmissions(classid: string, assignmentNumber: number): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classid); + + if (!cls) { + return []; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + return []; + } + + const groupRepository = getGroupRepository(); + const groups = await groupRepository.findAllGroupsForAssignment(assignment); + + const submissionRepository = getSubmissionRepository(); + const submissions = (await Promise.all(groups.map((group) => submissionRepository.findAllSubmissionsForGroup(group)))).flat(); + + return submissions.map(mapToSubmissionDTO); +} diff --git a/backend/src/services/class.ts b/backend/src/services/class.ts new file mode 100644 index 00000000..117bffec --- /dev/null +++ b/backend/src/services/class.ts @@ -0,0 +1,99 @@ +import { getClassRepository, getStudentRepository, getTeacherInvitationRepository, getTeacherRepository } from '../data/repositories.js'; +import { Class } from '../entities/classes/class.entity.js'; +import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; +import { mapToStudentDTO, StudentDTO } from '../interfaces/student.js'; +import { mapToTeacherInvitationDTO, mapToTeacherInvitationDTOIds, TeacherInvitationDTO } from '../interfaces/teacher-invitation.js'; +import { getLogger } from '../logging/initalize'; + +const logger = getLogger(); + +export async function getAllClasses(full: boolean): Promise { + const classRepository = getClassRepository(); + const classes = await classRepository.find({}, { populate: ['students', 'teachers'] }); + + if (!classes) { + return []; + } + + if (full) { + return classes.map(mapToClassDTO); + } + return classes.map((cls) => cls.classId!); +} + +export async function createClass(classData: ClassDTO): Promise { + const teacherRepository = getTeacherRepository(); + const teacherUsernames = classData.teachers || []; + const teachers = (await Promise.all(teacherUsernames.map((id) => teacherRepository.findByUsername(id)))).filter((teacher) => teacher != null); + + const studentRepository = getStudentRepository(); + const studentUsernames = classData.students || []; + const students = (await Promise.all(studentUsernames.map((id) => studentRepository.findByUsername(id)))).filter((student) => student != null); + + //Const cls = mapToClass(classData, teachers, students); + + const classRepository = getClassRepository(); + + try { + const newClass = classRepository.create({ + displayName: classData.displayName, + teachers: teachers, + students: students, + }); + await classRepository.save(newClass); + + return newClass; + } catch (e) { + logger.error(e); + return null; + } +} + +export async function getClass(classId: string): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return null; + } + + return mapToClassDTO(cls); +} + +async function fetchClassStudents(classId: string): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return []; + } + + return cls.students.map(mapToStudentDTO); +} + +export async function getClassStudents(classId: string): Promise { + return await fetchClassStudents(classId); +} + +export async function getClassStudentsIds(classId: string): Promise { + const students: StudentDTO[] = await fetchClassStudents(classId); + return students.map((student) => student.username); +} + +export async function getClassTeacherInvitations(classId: string, full: boolean): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return []; + } + + const teacherInvitationRepository = getTeacherInvitationRepository(); + const invitations = await teacherInvitationRepository.findAllInvitationsForClass(cls); + + if (full) { + return invitations.map(mapToTeacherInvitationDTO); + } + + return invitations.map(mapToTeacherInvitationDTOIds); +} diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts new file mode 100644 index 00000000..91091703 --- /dev/null +++ b/backend/src/services/groups.ts @@ -0,0 +1,132 @@ +import { GroupRepository } from '../data/assignments/group-repository.js'; +import { + getAssignmentRepository, + getClassRepository, + getGroupRepository, + getStudentRepository, + getSubmissionRepository, +} from '../data/repositories.js'; +import { Group } from '../entities/assignments/group.entity.js'; +import { GroupDTO, mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; +import { mapToSubmissionDTO, SubmissionDTO } from '../interfaces/submission.js'; + +export async function getGroup(classId: string, assignmentNumber: number, groupNumber: number, full: boolean): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return null; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + return null; + } + + const groupRepository = getGroupRepository(); + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, groupNumber); + + if (!group) { + return null; + } + + if (full) { + return mapToGroupDTO(group); + } + + return mapToGroupDTOId(group); +} + +export async function createGroup(groupData: GroupDTO, classid: string, assignmentNumber: number): Promise { + const studentRepository = getStudentRepository(); + + const memberUsernames = (groupData.members as string[]) || []; // TODO check if groupdata.members is a list + const members = (await Promise.all([...memberUsernames].map((id) => studentRepository.findByUsername(id)))).filter((student) => student != null); + + console.log(members); + + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classid); + + if (!cls) { + return null; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + return null; + } + + const groupRepository = getGroupRepository(); + try { + const newGroup = groupRepository.create({ + assignment: assignment, + members: members, + }); + await groupRepository.save(newGroup); + + return newGroup; + } catch (e) { + console.log(e); + return null; + } +} + +export async function getAllGroups(classId: string, assignmentNumber: number, full: boolean): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return []; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + return []; + } + + const groupRepository = getGroupRepository(); + const groups = await groupRepository.findAllGroupsForAssignment(assignment); + + if (full) { + console.log('full'); + console.log(groups); + return groups.map(mapToGroupDTO); + } + + return groups.map(mapToGroupDTOId); +} + +export async function getGroupSubmissions(classId: string, assignmentNumber: number, groupNumber: number): Promise { + const classRepository = getClassRepository(); + const cls = await classRepository.findById(classId); + + if (!cls) { + return []; + } + + const assignmentRepository = getAssignmentRepository(); + const assignment = await assignmentRepository.findByClassAndId(cls, assignmentNumber); + + if (!assignment) { + return []; + } + + const groupRepository = getGroupRepository(); + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment, groupNumber); + + if (!group) { + return []; + } + + const submissionRepository = getSubmissionRepository(); + const submissions = await submissionRepository.findAllSubmissionsForGroup(group); + + return submissions.map(mapToSubmissionDTO); +} diff --git a/backend/src/services/learning-objects.ts b/backend/src/services/learning-objects.ts new file mode 100644 index 00000000..fb579471 --- /dev/null +++ b/backend/src/services/learning-objects.ts @@ -0,0 +1,90 @@ +import { DWENGO_API_BASE } from '../config.js'; +import { fetchWithLogging } from '../util/api-helper.js'; +import { FilteredLearningObject, LearningObjectMetadata, LearningObjectNode, LearningPathResponse } from '../interfaces/learning-content.js'; + +function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLearningObject { + return { + key: data.hruid, // Hruid learningObject (not path) + _id: data._id, + uuid: data.uuid, + version: data.version, + title: data.title, + htmlUrl, // Url to fetch html content + language: data.language, + difficulty: data.difficulty, + estimatedTime: data.estimated_time, + available: data.available, + teacherExclusive: data.teacher_exclusive, + educationalGoals: data.educational_goals, // List with learningObjects + keywords: data.keywords, // For search + description: data.description, // For search (not an actual description) + targetAges: data.target_ages, + contentType: data.content_type, // Markdown, image, audio, etc. + contentLocation: data.content_location, // If content type extern + skosConcepts: data.skos_concepts, + returnValue: data.return_value, // Callback response information + }; +} + +/** + * Fetches a single learning object by its HRUID + */ +export async function getLearningObjectById(hruid: string, language: string): Promise { + const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`; + const metadata = await fetchWithLogging( + metadataUrl, + `Metadata for Learning Object HRUID "${hruid}" (language ${language})` + ); + + if (!metadata) { + console.error(`⚠️ WARNING: Learning object "${hruid}" not found.`); + return null; + } + + const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw?hruid=${hruid}&language=${language}`; + return filterData(metadata, htmlUrl); +} + +/** + * Generic function to fetch learning objects (full data or just HRUIDs) + */ +async function fetchLearningObjects(hruid: string, full: boolean, language: string): Promise { + try { + const learningPathResponse: LearningPathResponse = await fetchLearningPaths([hruid], language, `Learning path for HRUID "${hruid}"`); + + if (!learningPathResponse.success || !learningPathResponse.data?.length) { + console.error(`⚠️ WARNING: Learning path "${hruid}" exists but contains no learning objects.`); + return []; + } + + const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes; + + if (!full) { + return nodes.map((node) => node.learningobject_hruid); + } + + return await Promise.all(nodes.map(async (node) => getLearningObjectById(node.learningobject_hruid, language))).then((objects) => + objects.filter((obj): obj is FilteredLearningObject => obj !== null) + ); + } catch (error) { + console.error('❌ Error fetching learning objects:', error); + return []; + } +} + +/** + * Fetch full learning object data (metadata) + */ +export async function getLearningObjectsFromPath(hruid: string, language: string): Promise { + return (await fetchLearningObjects(hruid, true, language)) as FilteredLearningObject[]; +} + +/** + * Fetch only learning object HRUIDs + */ +export async function getLearningObjectIdsFromPath(hruid: string, language: string): Promise { + return (await fetchLearningObjects(hruid, false, language)) as string[]; +} +function fetchLearningPaths(arg0: string[], language: string, arg2: string): LearningPathResponse | PromiseLike { + throw new Error('Function not implemented.'); +} diff --git a/backend/src/services/learning-objects/attachment-service.ts b/backend/src/services/learning-objects/attachment-service.ts new file mode 100644 index 00000000..aacc7187 --- /dev/null +++ b/backend/src/services/learning-objects/attachment-service.ts @@ -0,0 +1,23 @@ +import { getAttachmentRepository } from '../../data/repositories.js'; +import { Attachment } from '../../entities/content/attachment.entity.js'; +import { LearningObjectIdentifier } from '../../interfaces/learning-content.js'; + +const attachmentService = { + getAttachment(learningObjectId: LearningObjectIdentifier, attachmentName: string): Promise { + const attachmentRepo = getAttachmentRepository(); + + if (learningObjectId.version) { + return attachmentRepo.findByLearningObjectIdAndName( + { + hruid: learningObjectId.hruid, + language: learningObjectId.language, + version: learningObjectId.version, + }, + attachmentName + ); + } + return attachmentRepo.findByMostRecentVersionOfLearningObjectAndName(learningObjectId.hruid, learningObjectId.language, attachmentName); + }, +}; + +export default attachmentService; diff --git a/backend/src/services/learning-objects/database-learning-object-provider.ts b/backend/src/services/learning-objects/database-learning-object-provider.ts new file mode 100644 index 00000000..bab0b9b1 --- /dev/null +++ b/backend/src/services/learning-objects/database-learning-object-provider.ts @@ -0,0 +1,115 @@ +import { LearningObjectProvider } from './learning-object-provider.js'; +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../data/repositories.js'; +import { Language } from '../../entities/content/language.js'; +import { LearningObject } from '../../entities/content/learning-object.entity.js'; +import { getUrlStringForLearningObject } from '../../util/links.js'; +import processingService from './processing/processing-service.js'; +import { NotFoundError } from '@mikro-orm/core'; +import learningObjectService from './learning-object-service.js'; +import { getLogger, Logger } from '../../logging/initalize.js'; + +const logger: Logger = getLogger(); + +function convertLearningObject(learningObject: LearningObject | null): FilteredLearningObject | null { + if (!learningObject) { + return null; + } + return { + key: learningObject.hruid, + _id: learningObject.uuid, // For backwards compatibility with the original Dwengo API, we also populate the _id field. + uuid: learningObject.uuid, + language: learningObject.language, + version: learningObject.version, + title: learningObject.title, + description: learningObject.description, + htmlUrl: getUrlStringForLearningObject(learningObject), + available: learningObject.available, + contentType: learningObject.contentType, + contentLocation: learningObject.contentLocation, + difficulty: learningObject.difficulty || 1, + estimatedTime: learningObject.estimatedTime, + keywords: learningObject.keywords, + educationalGoals: learningObject.educationalGoals, + returnValue: { + callback_url: learningObject.returnValue.callbackUrl, + callback_schema: JSON.parse(learningObject.returnValue.callbackSchema), + }, + skosConcepts: learningObject.skosConcepts, + targetAges: learningObject.targetAges || [], + teacherExclusive: learningObject.teacherExclusive, + }; +} + +function findLearningObjectEntityById(id: LearningObjectIdentifier): Promise { + const learningObjectRepo = getLearningObjectRepository(); + + return learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language); +} + +/** + * Service providing access to data about learning objects from the database + */ +const databaseLearningObjectProvider: LearningObjectProvider = { + /** + * Fetches a single learning object by its HRUID + */ + async getLearningObjectById(id: LearningObjectIdentifier): Promise { + const learningObject = await findLearningObjectEntityById(id); + return convertLearningObject(learningObject); + }, + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). + */ + async getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + const learningObjectRepo = getLearningObjectRepository(); + + const learningObject = await learningObjectRepo.findLatestByHruidAndLanguage(id.hruid, id.language as Language); + if (!learningObject) { + return null; + } + return await processingService.render(learningObject, (id) => findLearningObjectEntityById(id)); + }, + + /** + * Fetch the HRUIDs of all learning objects on this path. + */ + async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { + const learningPathRepo = getLearningPathRepository(); + + const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language); + if (!learningPath) { + throw new NotFoundError('The learning path with the given ID could not be found.'); + } + return learningPath.nodes.map((it) => it.learningObjectHruid); // TODO: Determine this based on the submissions of the user. + }, + + /** + * Fetch the full metadata of all learning objects on this path. + */ + async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise { + const learningPathRepo = getLearningPathRepository(); + + const learningPath = await learningPathRepo.findByHruidAndLanguage(id.hruid, id.language); + if (!learningPath) { + throw new NotFoundError('The learning path with the given ID could not be found.'); + } + const learningObjects = await Promise.all( + learningPath.nodes.map((it) => { + const learningObject = learningObjectService.getLearningObjectById({ + hruid: it.learningObjectHruid, + language: it.language, + version: it.version, + }); + if (learningObject === null) { + logger.warn(`WARN: Learning object corresponding with node ${it} not found!`); + } + return learningObject; + }) + ); + return learningObjects.filter((it) => it !== null); + }, +}; + +export default databaseLearningObjectProvider; diff --git a/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts new file mode 100644 index 00000000..dfee329d --- /dev/null +++ b/backend/src/services/learning-objects/dwengo-api-learning-object-provider.ts @@ -0,0 +1,138 @@ +import { DWENGO_API_BASE } from '../../config.js'; +import { fetchWithLogging } from '../../util/api-helper.js'; +import { + FilteredLearningObject, + LearningObjectIdentifier, + LearningObjectMetadata, + LearningObjectNode, + LearningPathIdentifier, + LearningPathResponse, +} from '../../interfaces/learning-content.js'; +import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js'; +import { LearningObjectProvider } from './learning-object-provider.js'; +import { getLogger, Logger } from '../../logging/initalize.js'; + +const logger: Logger = getLogger(); + +/** + * Helper function to convert the learning object metadata retrieved from the API to a FilteredLearningObject which + * our API should return. + * @param data + */ +function filterData(data: LearningObjectMetadata): FilteredLearningObject { + return { + key: data.hruid, // Hruid learningObject (not path) + _id: data._id, + uuid: data.uuid, + version: data.version, + title: data.title, + htmlUrl: `/learningObject/${data.hruid}/html?language=${data.language}&version=${data.version}`, // Url to fetch html content + language: data.language, + difficulty: data.difficulty, + estimatedTime: data.estimated_time, + available: data.available, + teacherExclusive: data.teacher_exclusive, + educationalGoals: data.educational_goals, // List with learningObjects + keywords: data.keywords, // For search + description: data.description, // For search (not an actual description) + targetAges: data.target_ages, + contentType: data.content_type, // Markdown, image, audio, etc. + contentLocation: data.content_location, // If content type extern + skosConcepts: data.skos_concepts, + returnValue: data.return_value, // Callback response information + }; +} + +/** + * Generic helper function to fetch all learning objects from a given path (full data or just HRUIDs) + */ +async function fetchLearningObjects(learningPathId: LearningPathIdentifier, full: boolean): Promise { + try { + const learningPathResponse: LearningPathResponse = await dwengoApiLearningPathProvider.fetchLearningPaths( + [learningPathId.hruid], + learningPathId.language, + `Learning path for HRUID "${learningPathId.hruid}"` + ); + + if (!learningPathResponse.success || !learningPathResponse.data?.length) { + logger.warn(`⚠️ WARNING: Learning path "${learningPathId.hruid}" exists but contains no learning objects.`); + return []; + } + + const nodes: LearningObjectNode[] = learningPathResponse.data[0].nodes; + + if (!full) { + return nodes.map((node) => node.learningobject_hruid); + } + + const objects = await Promise.all( + nodes.map(async (node) => + dwengoApiLearningObjectProvider.getLearningObjectById({ + hruid: node.learningobject_hruid, + language: learningPathId.language, + }) + ) + ); + return objects.filter((obj): obj is FilteredLearningObject => obj !== null); + } catch (error) { + logger.error('❌ Error fetching learning objects:', error); + return []; + } +} + +const dwengoApiLearningObjectProvider: LearningObjectProvider = { + /** + * Fetches a single learning object by its HRUID + */ + async getLearningObjectById(id: LearningObjectIdentifier): Promise { + const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`; + const metadata = await fetchWithLogging( + metadataUrl, + `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, + { + params: id, + } + ); + + if (!metadata || typeof metadata !== 'object') { + logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`); + return null; + } + + return filterData(metadata); + }, + + /** + * Fetch full learning object data (metadata) + */ + async getLearningObjectsFromPath(id: LearningPathIdentifier): Promise { + return (await fetchLearningObjects(id, true)) as FilteredLearningObject[]; + }, + + /** + * Fetch only learning object HRUIDs + */ + async getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { + return (await fetchLearningObjects(id, false)) as string[]; + }, + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). For learning objects + * from the Dwengo API, this means passing through the HTML rendering from there. + */ + async getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`; + const html = await fetchWithLogging(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { + params: id, + }); + + if (!html) { + logger.warn(`⚠️ WARNING: Learning object "${id.hruid}" not found.`); + return null; + } + + return html; + }, +}; + +export default dwengoApiLearningObjectProvider; diff --git a/backend/src/services/learning-objects/learning-object-provider.ts b/backend/src/services/learning-objects/learning-object-provider.ts new file mode 100644 index 00000000..81b4d228 --- /dev/null +++ b/backend/src/services/learning-objects/learning-object-provider.ts @@ -0,0 +1,23 @@ +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; + +export interface LearningObjectProvider { + /** + * Fetches a single learning object by its HRUID + */ + getLearningObjectById(id: LearningObjectIdentifier): Promise; + + /** + * Fetch full learning object data (metadata) + */ + getLearningObjectsFromPath(id: LearningPathIdentifier): Promise; + + /** + * Fetch only learning object HRUIDs + */ + getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise; + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). + */ + getLearningObjectHTML(id: LearningObjectIdentifier): Promise; +} diff --git a/backend/src/services/learning-objects/learning-object-service.ts b/backend/src/services/learning-objects/learning-object-service.ts new file mode 100644 index 00000000..8289660b --- /dev/null +++ b/backend/src/services/learning-objects/learning-object-service.ts @@ -0,0 +1,47 @@ +import { FilteredLearningObject, LearningObjectIdentifier, LearningPathIdentifier } from '../../interfaces/learning-content.js'; +import dwengoApiLearningObjectProvider from './dwengo-api-learning-object-provider.js'; +import { LearningObjectProvider } from './learning-object-provider.js'; +import { EnvVars, getEnvVar } from '../../util/envvars.js'; +import databaseLearningObjectProvider from './database-learning-object-provider.js'; + +function getProvider(id: LearningObjectIdentifier): LearningObjectProvider { + if (id.hruid.startsWith(getEnvVar(EnvVars.UserContentPrefix))) { + return databaseLearningObjectProvider; + } + return dwengoApiLearningObjectProvider; +} + +/** + * Service providing access to data about learning objects from the appropriate data source (database or Dwengo-api) + */ +const learningObjectService = { + /** + * Fetches a single learning object by its HRUID + */ + getLearningObjectById(id: LearningObjectIdentifier): Promise { + return getProvider(id).getLearningObjectById(id); + }, + + /** + * Fetch full learning object data (metadata) + */ + getLearningObjectsFromPath(id: LearningPathIdentifier): Promise { + return getProvider(id).getLearningObjectsFromPath(id); + }, + + /** + * Fetch only learning object HRUIDs + */ + getLearningObjectIdsFromPath(id: LearningPathIdentifier): Promise { + return getProvider(id).getLearningObjectIdsFromPath(id); + }, + + /** + * Obtain a HTML-rendering of the learning object with the given identifier (as a string). + */ + getLearningObjectHTML(id: LearningObjectIdentifier): Promise { + return getProvider(id).getLearningObjectHTML(id); + }, +}; + +export default learningObjectService; diff --git a/backend/src/services/learning-objects/processing/audio/audio-processor.ts b/backend/src/services/learning-objects/processing/audio/audio-processor.ts new file mode 100644 index 00000000..592669d5 --- /dev/null +++ b/backend/src/services/learning-objects/processing/audio/audio-processor.ts @@ -0,0 +1,25 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/audio/audio_processor.js + * + * WARNING: The support for audio learning objects is currently still experimental. + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { type } from 'node:os'; +import { DwengoContentType } from '../content-type.js'; +import { StringProcessor } from '../string-processor.js'; + +class AudioProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.AUDIO_MPEG); + } + + protected renderFn(audioUrl: string): string { + return DOMPurify.sanitize(``); + } +} + +export default AudioProcessor; diff --git a/backend/src/services/learning-objects/processing/content-type.ts b/backend/src/services/learning-objects/processing/content-type.ts new file mode 100644 index 00000000..2ea44246 --- /dev/null +++ b/backend/src/services/learning-objects/processing/content-type.ts @@ -0,0 +1,18 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/content_type.js + */ + +enum DwengoContentType { + TEXT_PLAIN = 'text/plain', + TEXT_MARKDOWN = 'text/markdown', + IMAGE_BLOCK = 'image/image-block', + IMAGE_INLINE = 'image/image', + AUDIO_MPEG = 'audio/mpeg', + APPLICATION_PDF = 'application/pdf', + EXTERN = 'extern', + BLOCKLY = 'blockly', + GIFT = 'text/gift', + CT_SCHEMA = 'text/ct-schema', +} + +export { DwengoContentType }; diff --git a/backend/src/services/learning-objects/processing/extern/extern-processor.ts b/backend/src/services/learning-objects/processing/extern/extern-processor.ts new file mode 100644 index 00000000..453e998b --- /dev/null +++ b/backend/src/services/learning-objects/processing/extern/extern-processor.ts @@ -0,0 +1,40 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/extern/extern_processor.js + * + * WARNING: The support for external content is currently still experimental. + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { ProcessingError } from '../processing-error.js'; +import { isValidHttpUrl } from '../../../../util/links.js'; +import { DwengoContentType } from '../content-type.js'; +import { StringProcessor } from '../string-processor.js'; + +class ExternProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.EXTERN); + } + + override renderFn(externURL: string) { + if (!isValidHttpUrl(externURL)) { + throw new ProcessingError('The url is not valid: ' + externURL); + } + + // If a seperate youtube-processor would be added, this code would need to move to that processor + // Converts youtube urls to youtube-embed urls + const match = /(.*youtube.com\/)watch\?v=(.*)/.exec(externURL); + if (match) { + externURL = match[1] + 'embed/' + match[2]; + } + + return DOMPurify.sanitize( + ` +
+ +
`, + { ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] } + ); + } +} + +export default ExternProcessor; diff --git a/backend/src/services/learning-objects/processing/gift/gift-processor.ts b/backend/src/services/learning-objects/processing/gift/gift-processor.ts new file mode 100644 index 00000000..5396236a --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/gift-processor.ts @@ -0,0 +1,61 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/gift/gift_processor.js + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { GIFTQuestion, parse } from 'gift-pegjs'; +import { DwengoContentType } from '../content-type.js'; +import { GIFTQuestionRenderer } from './question-renderers/gift-question-renderer.js'; +import { MultipleChoiceQuestionRenderer } from './question-renderers/multiple-choice-question-renderer.js'; +import { CategoryQuestionRenderer } from './question-renderers/category-question-renderer.js'; +import { DescriptionQuestionRenderer } from './question-renderers/description-question-renderer.js'; +import { EssayQuestionRenderer } from './question-renderers/essay-question-renderer.js'; +import { MatchingQuestionRenderer } from './question-renderers/matching-question-renderer.js'; +import { NumericalQuestionRenderer } from './question-renderers/numerical-question-renderer.js'; +import { ShortQuestionRenderer } from './question-renderers/short-question-renderer.js'; +import { TrueFalseQuestionRenderer } from './question-renderers/true-false-question-renderer.js'; +import { StringProcessor } from '../string-processor.js'; + +class GiftProcessor extends StringProcessor { + private renderers: RendererMap = { + Category: new CategoryQuestionRenderer(), + Description: new DescriptionQuestionRenderer(), + Essay: new EssayQuestionRenderer(), + Matching: new MatchingQuestionRenderer(), + Numerical: new NumericalQuestionRenderer(), + Short: new ShortQuestionRenderer(), + TF: new TrueFalseQuestionRenderer(), + MC: new MultipleChoiceQuestionRenderer(), + }; + + constructor() { + super(DwengoContentType.GIFT); + } + + override renderFn(giftString: string) { + const quizQuestions: GIFTQuestion[] = parse(giftString); + + let html = "
\n"; + let i = 1; + for (const question of quizQuestions) { + html += `
\n`; + html += ' ' + this.renderQuestion(question, i).replaceAll(/\n(.+)/g, '\n $1'); // Replace for indentation. + html += `
\n`; + i++; + } + html += '
\n'; + + return DOMPurify.sanitize(html); + } + + private renderQuestion(question: T, questionNumber: number): string { + const renderer = this.renderers[question.type] as GIFTQuestionRenderer; + return renderer.render(question, questionNumber); + } +} + +type RendererMap = { + [K in GIFTQuestion['type']]: GIFTQuestionRenderer>; +}; + +export default GiftProcessor; diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/category-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/category-question-renderer.ts new file mode 100644 index 00000000..507e5ada --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/category-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Category } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class CategoryQuestionRenderer extends GIFTQuestionRenderer { + render(question: Category, questionNumber: number): string { + throw new ProcessingError("The question type 'Category' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/description-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/description-question-renderer.ts new file mode 100644 index 00000000..0238d76a --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/description-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Description } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class DescriptionQuestionRenderer extends GIFTQuestionRenderer { + render(question: Description, questionNumber: number): string { + throw new ProcessingError("The question type 'Description' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/essay-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/essay-question-renderer.ts new file mode 100644 index 00000000..987fbcaf --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/essay-question-renderer.ts @@ -0,0 +1,16 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Essay } from 'gift-pegjs'; + +export class EssayQuestionRenderer extends GIFTQuestionRenderer { + render(question: Essay, questionNumber: number): string { + let renderedHtml = ''; + if (question.title) { + renderedHtml += `

${question.title}

\n`; + } + if (question.stem) { + renderedHtml += `

${question.stem.text}

\n`; + } + renderedHtml += `\n`; + return renderedHtml; + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/gift-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/gift-question-renderer.ts new file mode 100644 index 00000000..41ab5ba2 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/gift-question-renderer.ts @@ -0,0 +1,14 @@ +import { GIFTQuestion } from 'gift-pegjs'; + +/** + * Subclasses of this class are renderers which can render a specific type of GIFT questions to HTML. + */ +export abstract class GIFTQuestionRenderer { + /** + * Render the given question to HTML. + * @param question The question. + * @param questionNumber The index number of the question. + * @returns The question rendered as HTML. + */ + abstract render(question: T, questionNumber: number): string; +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/matching-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/matching-question-renderer.ts new file mode 100644 index 00000000..bb6e9737 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/matching-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Matching } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class MatchingQuestionRenderer extends GIFTQuestionRenderer { + render(question: Matching, questionNumber: number): string { + throw new ProcessingError("The question type 'Matching' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts new file mode 100644 index 00000000..39846c51 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/multiple-choice-question-renderer.ts @@ -0,0 +1,23 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { MultipleChoice } from 'gift-pegjs'; + +export class MultipleChoiceQuestionRenderer extends GIFTQuestionRenderer { + render(question: MultipleChoice, questionNumber: number): string { + let renderedHtml = ''; + if (question.title) { + renderedHtml += `

${question.title}

\n`; + } + if (question.stem) { + renderedHtml += `

${question.stem.text}

\n`; + } + let i = 0; + for (const choice of question.choices) { + renderedHtml += `
\n`; + renderedHtml += ` \n`; + renderedHtml += ` \n`; + renderedHtml += `
\n`; + i++; + } + return renderedHtml; + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/numerical-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/numerical-question-renderer.ts new file mode 100644 index 00000000..32fdb06e --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/numerical-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { Numerical } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class NumericalQuestionRenderer extends GIFTQuestionRenderer { + render(question: Numerical, questionNumber: number): string { + throw new ProcessingError("The question type 'Numerical' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/short-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/short-question-renderer.ts new file mode 100644 index 00000000..5a63531f --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/short-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { ShortAnswer } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class ShortQuestionRenderer extends GIFTQuestionRenderer { + render(question: ShortAnswer, questionNumber: number): string { + throw new ProcessingError("The question type 'ShortAnswer' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/gift/question-renderers/true-false-question-renderer.ts b/backend/src/services/learning-objects/processing/gift/question-renderers/true-false-question-renderer.ts new file mode 100644 index 00000000..98148130 --- /dev/null +++ b/backend/src/services/learning-objects/processing/gift/question-renderers/true-false-question-renderer.ts @@ -0,0 +1,9 @@ +import { GIFTQuestionRenderer } from './gift-question-renderer.js'; +import { TrueFalse } from 'gift-pegjs'; +import { ProcessingError } from '../../processing-error.js'; + +export class TrueFalseQuestionRenderer extends GIFTQuestionRenderer { + render(question: TrueFalse, questionNumber: number): string { + throw new ProcessingError("The question type 'TrueFalse' is not supported yet!"); + } +} diff --git a/backend/src/services/learning-objects/processing/image/block-image-processor.ts b/backend/src/services/learning-objects/processing/image/block-image-processor.ts new file mode 100644 index 00000000..f4f8a773 --- /dev/null +++ b/backend/src/services/learning-objects/processing/image/block-image-processor.ts @@ -0,0 +1,19 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/block_image_processor.js + */ + +import InlineImageProcessor from './inline-image-processor.js'; +import DOMPurify from 'isomorphic-dompurify'; + +class BlockImageProcessor extends InlineImageProcessor { + constructor() { + super(); + } + + override renderFn(imageUrl: string) { + const inlineHtml = super.render(imageUrl); + return DOMPurify.sanitize(`
${inlineHtml}
`); + } +} + +export default BlockImageProcessor; diff --git a/backend/src/services/learning-objects/processing/image/inline-image-processor.ts b/backend/src/services/learning-objects/processing/image/inline-image-processor.ts new file mode 100644 index 00000000..478ce326 --- /dev/null +++ b/backend/src/services/learning-objects/processing/image/inline-image-processor.ts @@ -0,0 +1,24 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/image/inline_image_processor.js + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { DwengoContentType } from '../content-type.js'; +import { ProcessingError } from '../processing-error.js'; +import { isValidHttpUrl } from '../../../../util/links.js'; +import { StringProcessor } from '../string-processor.js'; + +class InlineImageProcessor extends StringProcessor { + constructor(contentType: DwengoContentType = DwengoContentType.IMAGE_INLINE) { + super(contentType); + } + + override renderFn(imageUrl: string) { + if (!isValidHttpUrl(imageUrl)) { + throw new ProcessingError(`Image URL is invalid: ${imageUrl}`); + } + return DOMPurify.sanitize(``); + } +} + +export default InlineImageProcessor; diff --git a/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts b/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts new file mode 100644 index 00000000..d1c797be --- /dev/null +++ b/backend/src/services/learning-objects/processing/markdown/dwengo-marked-renderer.ts @@ -0,0 +1,109 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/learing_object_markdown_renderer.js [sic!] + */ +import PdfProcessor from '../pdf/pdf-processor.js'; +import AudioProcessor from '../audio/audio-processor.js'; +import ExternProcessor from '../extern/extern-processor.js'; +import InlineImageProcessor from '../image/inline-image-processor.js'; +import * as marked from 'marked'; +import { getUrlStringForLearningObjectHTML, isValidHttpUrl } from '../../../../util/links.js'; +import { ProcessingError } from '../processing-error.js'; +import { LearningObjectIdentifier } from '../../../../interfaces/learning-content.js'; +import { Language } from '../../../../entities/content/language.js'; + +import Image = marked.Tokens.Image; +import Heading = marked.Tokens.Heading; +import Link = marked.Tokens.Link; +import RendererObject = marked.RendererObject; + +const prefixes = { + learningObject: '@learning-object', + pdf: '@pdf', + audio: '@audio', + extern: '@extern', + video: '@youtube', + notebook: '@notebook', + blockly: '@blockly', +}; + +function extractLearningObjectIdFromHref(href: string): LearningObjectIdentifier { + const [hruid, language, version] = href.split(/\/(.+)/, 2)[1].split('/'); + return { + hruid, + language: language as Language, + version: parseInt(version), + }; +} + +/** + * An extension for the renderer of the Marked Markdown renderer which adds support for + * - a custom heading, + * - links to other learning objects, + * - embeddings of other learning objects. + */ +const dwengoMarkedRenderer: RendererObject = { + heading(heading: Heading): string { + const text = heading.text; + const level = heading.depth; + const escapedText = text.toLowerCase().replace(/[^\w]+/g, '-'); + + return ( + `\n` + + ` \n` + + ` \n` + + ` \n` + + ` ${text}\n` + + `\n` + ); + }, + + // When the syntax for a link is used => [text](href "title") + // Render a custom link when the prefix for a learning object is used. + link(link: Link): string { + const href = link.href; + const title = link.title || ''; + const text = marked.parseInline(link.text); // There could for example be an image in the link. + + if (href.startsWith(prefixes.learningObject)) { + // Link to learning-object + const learningObjectId = extractLearningObjectIdFromHref(href); + return `${text}`; + } + // Any other link + if (!isValidHttpUrl(href)) { + throw new ProcessingError('Link is not a valid HTTP URL!'); + } + // + return `${text}`; + }, + + // When the syntax for an image is used => ![text](href "title") + // Render a learning object, pdf, audio or video if a prefix is used. + image(img: Image): string { + const href = img.href; + if (href.startsWith(prefixes.learningObject)) { + // Embedded learning-object + const learningObjectId = extractLearningObjectIdFromHref(href); + return ` + + `; // Placeholder for the learning object since we cannot fetch its HTML here (this has to be a sync function!) + } else if (href.startsWith(prefixes.pdf)) { + // Embedded pdf + const proc = new PdfProcessor(); + return proc.render(href.split(/\/(.+)/, 2)[1]); + } else if (href.startsWith(prefixes.audio)) { + // Embedded audio + const proc = new AudioProcessor(); + return proc.render(href.split(/\/(.+)/, 2)[1]); + } else if (href.startsWith(prefixes.extern) || href.startsWith(prefixes.video) || href.startsWith(prefixes.notebook)) { + // Embedded youtube video or notebook (or other extern content) + const proc = new ExternProcessor(); + return proc.render(href.split(/\/(.+)/, 2)[1]); + } + // Embedded image + const proc = new InlineImageProcessor(); + return proc.render(href); + }, +}; + +export default dwengoMarkedRenderer; diff --git a/backend/src/services/learning-objects/processing/markdown/markdown-processor.ts b/backend/src/services/learning-objects/processing/markdown/markdown-processor.ts new file mode 100644 index 00000000..1de6b3d5 --- /dev/null +++ b/backend/src/services/learning-objects/processing/markdown/markdown-processor.ts @@ -0,0 +1,39 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/markdown/markdown_processor.js + */ + +import { marked } from 'marked'; +import InlineImageProcessor from '../image/inline-image-processor.js'; +import { DwengoContentType } from '../content-type.js'; +import dwengoMarkedRenderer from './dwengo-marked-renderer.js'; +import { StringProcessor } from '../string-processor.js'; +import { ProcessingError } from '../processing-error.js'; + +class MarkdownProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.TEXT_MARKDOWN); + } + + override renderFn(mdText: string) { + let html = ''; + try { + marked.use({ renderer: dwengoMarkedRenderer }); + html = marked(mdText, { async: false }); + html = this.replaceLinks(html); // Replace html image links path + } catch (e: any) { + throw new ProcessingError(e.message); + } + return html; + } + + replaceLinks(html: string) { + const proc = new InlineImageProcessor(); + html = html.replace( + //g, + (match: string, src: string, alt: string, altText: string, title: string, titleText: string) => proc.render(src) + ); + return html; + } +} + +export { MarkdownProcessor }; diff --git a/backend/src/services/learning-objects/processing/pdf/pdf-processor.ts b/backend/src/services/learning-objects/processing/pdf/pdf-processor.ts new file mode 100644 index 00000000..26cb4d94 --- /dev/null +++ b/backend/src/services/learning-objects/processing/pdf/pdf-processor.ts @@ -0,0 +1,32 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/pdf/pdf_processor.js + * + * WARNING: The support for PDF learning objects is currently still experimental. + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { DwengoContentType } from '../content-type.js'; +import { isValidHttpUrl } from '../../../../util/links.js'; +import { ProcessingError } from '../processing-error.js'; +import { StringProcessor } from '../string-processor.js'; + +class PdfProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.APPLICATION_PDF); + } + + override renderFn(pdfUrl: string) { + if (!isValidHttpUrl(pdfUrl)) { + throw new ProcessingError(`PDF URL is invalid: ${pdfUrl}`); + } + + return DOMPurify.sanitize( + ` + + `, + { ADD_TAGS: ['embed'] } + ); + } +} + +export default PdfProcessor; diff --git a/backend/src/services/learning-objects/processing/processing-error.ts b/backend/src/services/learning-objects/processing/processing-error.ts new file mode 100644 index 00000000..b51f2a3a --- /dev/null +++ b/backend/src/services/learning-objects/processing/processing-error.ts @@ -0,0 +1,5 @@ +export class ProcessingError extends Error { + constructor(error: string) { + super(error); + } +} diff --git a/backend/src/services/learning-objects/processing/processing-service.ts b/backend/src/services/learning-objects/processing/processing-service.ts new file mode 100644 index 00000000..a6c662cc --- /dev/null +++ b/backend/src/services/learning-objects/processing/processing-service.ts @@ -0,0 +1,83 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processing_proxy.js + */ + +import BlockImageProcessor from './image/block-image-processor.js'; +import InlineImageProcessor from './image/inline-image-processor.js'; +import { MarkdownProcessor } from './markdown/markdown-processor.js'; +import TextProcessor from './text/text-processor.js'; +import AudioProcessor from './audio/audio-processor.js'; +import PdfProcessor from './pdf/pdf-processor.js'; +import ExternProcessor from './extern/extern-processor.js'; +import GiftProcessor from './gift/gift-processor.js'; +import { LearningObject } from '../../../entities/content/learning-object.entity.js'; +import Processor from './processor.js'; +import { DwengoContentType } from './content-type.js'; +import { LearningObjectIdentifier } from '../../../interfaces/learning-content.js'; +import { Language } from '../../../entities/content/language.js'; +import { replaceAsync } from '../../../util/async.js'; + +const EMBEDDED_LEARNING_OBJECT_PLACEHOLDER = //g; +const LEARNING_OBJECT_DOES_NOT_EXIST = "
"; + +class ProcessingService { + private processors!: Map>; + + constructor() { + const processors = [ + new InlineImageProcessor(), + new BlockImageProcessor(), + new MarkdownProcessor(), + new TextProcessor(), + new AudioProcessor(), + new PdfProcessor(), + new ExternProcessor(), + new GiftProcessor(), + ]; + + this.processors = new Map(processors.map((processor) => [processor.contentType, processor])); + } + + /** + * Render the given learning object. + * @param learningObject The learning object to render + * @param fetchEmbeddedLearningObjects A function which takes a learning object identifier as an argument and + * returns the corresponding learning object. This is used to fetch learning + * objects embedded into this one. + * If this argument is omitted, embedded learning objects will be represented + * by placeholders. + * @returns Rendered HTML for this LearningObject as a string. + */ + async render( + learningObject: LearningObject, + fetchEmbeddedLearningObjects?: (loId: LearningObjectIdentifier) => Promise + ): Promise { + const html = this.processors.get(learningObject.contentType)!.renderLearningObject(learningObject); + if (fetchEmbeddedLearningObjects) { + // Replace all embedded learning objects. + return replaceAsync( + html, + EMBEDDED_LEARNING_OBJECT_PLACEHOLDER, + async (_, hruid: string, language: string, version: string): Promise => { + // Fetch the embedded learning object... + const learningObject = await fetchEmbeddedLearningObjects({ + hruid, + language: language as Language, + version: parseInt(version), + }); + + // If it does not exist, replace it by a placeholder. + if (!learningObject) { + return LEARNING_OBJECT_DOES_NOT_EXIST; + } + + // ... and render it. + return this.render(learningObject); + } + ); + } + return html; + } +} + +export default new ProcessingService(); diff --git a/backend/src/services/learning-objects/processing/processor.ts b/backend/src/services/learning-objects/processing/processor.ts new file mode 100644 index 00000000..a11c4416 --- /dev/null +++ b/backend/src/services/learning-objects/processing/processor.ts @@ -0,0 +1,61 @@ +import { LearningObject } from '../../../entities/content/learning-object.entity.js'; +import { ProcessingError } from './processing-error.js'; +import { DwengoContentType } from './content-type.js'; + +/** + * Abstract base class for all processors. + * Each processor is responsible for a specific format a learning object can be in, which i tcan render to HTML. + * + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/processor.js + */ +abstract class Processor { + protected constructor(public contentType: DwengoContentType) {} + + /** + * Render the given object. + * + * @param toRender Object which has to be rendered to HTML. This object has to be in the format for which this + * Processor is responsible. + * @return Rendered HTML-string + * @throws ProcessingError if the rendering fails. + */ + render(toRender: T): string { + return this.renderFn(toRender); + } + + /** + * Render a learning object with the content type for which this processor is responsible. + * @param toRender + */ + renderLearningObject(toRender: LearningObject): string { + if (toRender.contentType !== this.contentType) { + throw new ProcessingError( + `Unsupported content type: ${toRender.contentType}. + This processor is only responsible for content of type ${this.contentType}.` + ); + } + return this.renderLearningObjectFn(toRender); + } + + /** + * Function which actually renders the content. + * + * @param toRender Content to be rendered + * @return Rendered HTML as a string + * @protected + */ + protected abstract renderFn(toRender: T): string; + + /** + * Function which actually executes the rendering of a learning object. + * + * When implementing this function, we may assume that we are responsible for the content type of the learning + * object. + * + * @param toRender Learning object to render + * @protected + */ + protected abstract renderLearningObjectFn(toRender: LearningObject): string; +} + +export default Processor; diff --git a/backend/src/services/learning-objects/processing/string-processor.ts b/backend/src/services/learning-objects/processing/string-processor.ts new file mode 100644 index 00000000..f4734af9 --- /dev/null +++ b/backend/src/services/learning-objects/processing/string-processor.ts @@ -0,0 +1,19 @@ +import Processor from './processor.js'; +import { LearningObject } from '../../../entities/content/learning-object.entity.js'; + +export abstract class StringProcessor extends Processor { + /** + * Function which actually executes the rendering of a learning object. + * By default, this just means rendering the content in the content property of the learning object (interpreted + * as string) + * + * When implementing this function, we may assume that we are responsible for the content type of the learning + * object. + * + * @param toRender Learning object to render + * @protected + */ + protected renderLearningObjectFn(toRender: LearningObject): string { + return this.render(toRender.content.toString('ascii')); + } +} diff --git a/backend/src/services/learning-objects/processing/text/text-processor.ts b/backend/src/services/learning-objects/processing/text/text-processor.ts new file mode 100644 index 00000000..db6d37df --- /dev/null +++ b/backend/src/services/learning-objects/processing/text/text-processor.ts @@ -0,0 +1,20 @@ +/** + * Based on https://github.com/dwengovzw/Learning-Object-Repository/blob/main/app/processors/text/text_processor.js + */ + +import DOMPurify from 'isomorphic-dompurify'; +import { DwengoContentType } from '../content-type.js'; +import { StringProcessor } from '../string-processor.js'; + +class TextProcessor extends StringProcessor { + constructor() { + super(DwengoContentType.TEXT_PLAIN); + } + + override renderFn(text: string) { + // Sanitize plain text to prevent xss. + return DOMPurify.sanitize(text); + } +} + +export default TextProcessor; diff --git a/backend/src/services/learning-paths/database-learning-path-provider.ts b/backend/src/services/learning-paths/database-learning-path-provider.ts new file mode 100644 index 00000000..68986885 --- /dev/null +++ b/backend/src/services/learning-paths/database-learning-path-provider.ts @@ -0,0 +1,181 @@ +import { LearningPathProvider } from './learning-path-provider.js'; +import { FilteredLearningObject, LearningObjectNode, LearningPath, LearningPathResponse, Transition } from '../../interfaces/learning-content.js'; +import { LearningPath as LearningPathEntity } from '../../entities/content/learning-path.entity.js'; +import { getLearningPathRepository } from '../../data/repositories.js'; +import { Language } from '../../entities/content/language.js'; +import learningObjectService from '../learning-objects/learning-object-service.js'; +import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; +import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; +import { getLastSubmissionForCustomizationTarget, isTransitionPossible, PersonalizationTarget } from './learning-path-personalization-util.js'; + +/** + * Fetches the corresponding learning object for each of the nodes and creates a map that maps each node to its + * corresponding learning object. + * @param nodes The nodes to find the learning object for. + */ +async function getLearningObjectsForNodes(nodes: LearningPathNode[]): Promise> { + // Fetching the corresponding learning object for each of the nodes and creating a map that maps each node to + // Its corresponding learning object. + const nullableNodesToLearningObjects = new Map( + await Promise.all( + nodes.map((node) => + learningObjectService + .getLearningObjectById({ + hruid: node.learningObjectHruid, + version: node.version, + language: node.language, + }) + .then((learningObject) => <[LearningPathNode, FilteredLearningObject | null]>[node, learningObject]) + ) + ) + ); + if (Array.from(nullableNodesToLearningObjects.values()).some((it) => it === null)) { + throw new Error('At least one of the learning objects on this path could not be found.'); + } + return nullableNodesToLearningObjects as Map; +} + +/** + * Convert the given learning path entity to an object which conforms to the learning path content. + */ +async function convertLearningPath(learningPath: LearningPathEntity, order: number, personalizedFor?: PersonalizationTarget): Promise { + const nodesToLearningObjects: Map = await getLearningObjectsForNodes(learningPath.nodes); + + const targetAges = Array.from(nodesToLearningObjects.values()).flatMap((it) => it.targetAges || []); + + const keywords = Array.from(nodesToLearningObjects.values()).flatMap((it) => it.keywords || []); + + const image = learningPath.image ? learningPath.image.toString('base64') : undefined; + + const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); + + return { + _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API. + __order: order, + hruid: learningPath.hruid, + language: learningPath.language, + description: learningPath.description, + image: image, + title: learningPath.title, + nodes: convertedNodes, + num_nodes: learningPath.nodes.length, + num_nodes_left: convertedNodes.filter((it) => !it.done).length, + keywords: keywords.join(' '), + target_ages: targetAges, + max_age: Math.max(...targetAges), + min_age: Math.min(...targetAges), + }; +} + +/** + * Helper function converting pairs of learning path nodes (as represented in the database) and the corresponding + * learning objects into a list of learning path nodes as they should be represented in the API. + * @param nodesToLearningObjects + * @param personalizedFor + */ +async function convertNodes( + nodesToLearningObjects: Map, + personalizedFor?: PersonalizationTarget +): Promise { + const nodesPromise = Array.from(nodesToLearningObjects.entries()).map(async (entry) => { + const [node, learningObject] = entry; + const lastSubmission = personalizedFor ? await getLastSubmissionForCustomizationTarget(node, personalizedFor) : null; + return { + _id: learningObject.uuid, + language: learningObject.language, + start_node: node.startNode, + created_at: node.createdAt.toISOString(), + updatedAt: node.updatedAt.toISOString(), + learningobject_hruid: node.learningObjectHruid, + version: learningObject.version, + transitions: node.transitions + .filter( + (trans) => !personalizedFor || isTransitionPossible(trans, optionalJsonStringToObject(lastSubmission?.content)) // If we want a personalized learning path, remove all transitions that aren't possible. + ) + .map((trans, i) => convertTransition(trans, i, nodesToLearningObjects)), // Then convert all the transition + }; + }); + return await Promise.all(nodesPromise); +} + +/** + * Helper method to convert a json string to an object, or null if it is undefined. + */ +function optionalJsonStringToObject(jsonString?: string): object | null { + if (!jsonString) { + return null; + } + return JSON.parse(jsonString); +} + +/** + * Helper function which converts a transition in the database representation to a transition in the representation + * the Dwengo API uses. + * + * @param transition + * @param index + * @param nodesToLearningObjects + */ +function convertTransition( + transition: LearningPathTransition, + index: number, + nodesToLearningObjects: Map +): Transition { + const nextNode = nodesToLearningObjects.get(transition.next); + if (!nextNode) { + throw new Error(`Learning object ${transition.next.learningObjectHruid}/${transition.next.language}/${transition.next.version} not found!`); + } else { + return { + _id: '' + index, // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path. + default: false, // We don't work with default transitions but retain this for backwards compatibility. + next: { + _id: nextNode._id + index, // Construct a unique ID for the transition for backwards compatibility. + hruid: transition.next.learningObjectHruid, + language: nextNode.language, + version: nextNode.version, + }, + }; + } +} + +/** + * Service providing access to data about learning paths from the database. + */ +const databaseLearningPathProvider: LearningPathProvider = { + /** + * Fetch the learning paths with the given hruids from the database. + */ + async fetchLearningPaths( + hruids: string[], + language: Language, + source: string, + personalizedFor?: PersonalizationTarget + ): Promise { + const learningPathRepo = getLearningPathRepository(); + + const learningPaths = (await Promise.all(hruids.map((hruid) => learningPathRepo.findByHruidAndLanguage(hruid, language)))).filter( + (learningPath) => learningPath !== null + ); + const filteredLearningPaths = await Promise.all( + learningPaths.map((learningPath, index) => convertLearningPath(learningPath, index, personalizedFor)) + ); + + return { + success: filteredLearningPaths.length > 0, + data: await Promise.all(filteredLearningPaths), + source, + }; + }, + + /** + * Search learning paths in the database using the given search string. + */ + async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise { + const learningPathRepo = getLearningPathRepository(); + + const searchResults = await learningPathRepo.findByQueryStringAndLanguage(query, language); + return await Promise.all(searchResults.map((result, index) => convertLearningPath(result, index, personalizedFor))); + }, +}; + +export default databaseLearningPathProvider; diff --git a/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts new file mode 100644 index 00000000..a6093bb4 --- /dev/null +++ b/backend/src/services/learning-paths/dwengo-api-learning-path-provider.ts @@ -0,0 +1,50 @@ +import { fetchWithLogging } from '../../util/api-helper.js'; +import { DWENGO_API_BASE } from '../../config.js'; +import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; +import { LearningPathProvider } from './learning-path-provider.js'; +import { getLogger, Logger } from '../../logging/initalize.js'; + +const logger: Logger = getLogger(); + +const dwengoApiLearningPathProvider: LearningPathProvider = { + async fetchLearningPaths(hruids: string[], language: string, source: string): Promise { + if (hruids.length === 0) { + return { + success: false, + source, + data: null, + message: `No HRUIDs provided for ${source}.`, + }; + } + + const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`; + const params = { pathIdList: JSON.stringify({ hruids }), language }; + + const learningPaths = await fetchWithLogging(apiUrl, `Learning paths for ${source}`, { params }); + + if (!learningPaths || learningPaths.length === 0) { + logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`); + return { + success: false, + source, + data: [], + message: `No learning paths found for ${source}.`, + }; + } + + return { + success: true, + source, + data: learningPaths, + }; + }, + async searchLearningPaths(query: string, language: string): Promise { + const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; + const params = { all: query, language }; + + const searchResults = await fetchWithLogging(apiUrl, `Search learning paths with query "${query}"`, { params }); + return searchResults ?? []; + }, +}; + +export default dwengoApiLearningPathProvider; diff --git a/backend/src/services/learning-paths/learning-path-personalization-util.ts b/backend/src/services/learning-paths/learning-path-personalization-util.ts new file mode 100644 index 00000000..a9175d13 --- /dev/null +++ b/backend/src/services/learning-paths/learning-path-personalization-util.ts @@ -0,0 +1,90 @@ +import { LearningPathNode } from '../../entities/content/learning-path-node.entity.js'; +import { Student } from '../../entities/users/student.entity.js'; +import { Group } from '../../entities/assignments/group.entity.js'; +import { Submission } from '../../entities/assignments/submission.entity.js'; +import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../../data/repositories.js'; +import { LearningObjectIdentifier } from '../../entities/content/learning-object-identifier.js'; +import { LearningPathTransition } from '../../entities/content/learning-path-transition.entity.js'; +import { JSONPath } from 'jsonpath-plus'; + +export type PersonalizationTarget = { type: 'student'; student: Student } | { type: 'group'; group: Group }; + +/** + * Shortcut function to easily create a PersonalizationTarget object for a student by his/her username. + * @param username Username of the student we want to generate a personalized learning path for. + * If there is no student with this username, return undefined. + */ +export async function personalizedForStudent(username: string): Promise { + const student = await getStudentRepository().findByUsername(username); + if (student) { + return { + type: 'student', + student: student, + }; + } + return undefined; +} + +/** + * Shortcut function to easily create a PersonalizationTarget object for a group by class name, assignment number and + * group number. + * @param classId Id of the class in which this group was created + * @param assignmentNumber Number of the assignment for which this group was created + * @param groupNumber Number of the group for which we want to personalize the learning path. + */ +export async function personalizedForGroup( + classId: string, + assignmentNumber: number, + groupNumber: number +): Promise { + const clazz = await getClassRepository().findById(classId); + if (!clazz) { + return undefined; + } + const group = await getGroupRepository().findOne({ + assignment: { + within: clazz, + id: assignmentNumber, + }, + groupNumber: groupNumber, + }); + if (group) { + return { + type: 'group', + group: group, + }; + } + return undefined; +} + +/** + * Returns the last submission for the learning object associated with the given node and for the student or group + */ +export async function getLastSubmissionForCustomizationTarget(node: LearningPathNode, pathFor: PersonalizationTarget): Promise { + const submissionRepo = getSubmissionRepository(); + const learningObjectId: LearningObjectIdentifier = { + hruid: node.learningObjectHruid, + language: node.language, + version: node.version, + }; + if (pathFor.type === 'group') { + return await submissionRepo.findMostRecentSubmissionForGroup(learningObjectId, pathFor.group); + } + return await submissionRepo.findMostRecentSubmissionForStudent(learningObjectId, pathFor.student); +} + +/** + * Checks whether the condition of the given transaction is fulfilled by the given submission. + * @param transition + * @param submitted + */ +export function isTransitionPossible(transition: LearningPathTransition, submitted: object | null): boolean { + if (transition.condition === 'true' || !transition.condition) { + return true; // If the transition is unconditional, we can go on. + } + if (submitted === null) { + return false; // If the transition is not unconditional and there was no submission, the transition is not possible. + } + const match = JSONPath({ path: transition.condition, json: { submission: submitted } }); + return match.length === 1; +} diff --git a/backend/src/services/learning-paths/learning-path-provider.ts b/backend/src/services/learning-paths/learning-path-provider.ts new file mode 100644 index 00000000..5e2a09df --- /dev/null +++ b/backend/src/services/learning-paths/learning-path-provider.ts @@ -0,0 +1,18 @@ +import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; +import { Language } from '../../entities/content/language.js'; +import { PersonalizationTarget } from './learning-path-personalization-util.js'; + +/** + * Generic interface for a service which provides access to learning paths from a data source. + */ +export interface LearningPathProvider { + /** + * Fetch the learning paths with the given hruids from the data source. + */ + fetchLearningPaths(hruids: string[], language: Language, source: string, personalizedFor?: PersonalizationTarget): Promise; + + /** + * Search learning paths in the data source using the given search string. + */ + searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise; +} diff --git a/backend/src/services/learning-paths/learning-path-service.ts b/backend/src/services/learning-paths/learning-path-service.ts new file mode 100644 index 00000000..2fceb46c --- /dev/null +++ b/backend/src/services/learning-paths/learning-path-service.ts @@ -0,0 +1,57 @@ +import { LearningPath, LearningPathResponse } from '../../interfaces/learning-content.js'; +import dwengoApiLearningPathProvider from './dwengo-api-learning-path-provider.js'; +import databaseLearningPathProvider from './database-learning-path-provider.js'; +import { EnvVars, getEnvVar } from '../../util/envvars.js'; +import { Language } from '../../entities/content/language.js'; +import { PersonalizationTarget } from './learning-path-personalization-util.js'; + +const userContentPrefix = getEnvVar(EnvVars.UserContentPrefix); +const allProviders = [dwengoApiLearningPathProvider, databaseLearningPathProvider]; + +/** + * Service providing access to data about learning paths from the appropriate data source (database or Dwengo-api) + */ +const learningPathService = { + /** + * Fetch the learning paths with the given hruids from the data source. + * @param hruids For each of the hruids, the learning path will be fetched. + * @param language This is the language each of the learning paths will use. + * @param source + * @param personalizedFor If this is set, a learning path personalized for the given group or student will be returned. + */ + async fetchLearningPaths( + hruids: string[], + language: Language, + source: string, + personalizedFor?: PersonalizationTarget + ): Promise { + const userContentHruids = hruids.filter((hruid) => hruid.startsWith(userContentPrefix)); + const nonUserContentHruids = hruids.filter((hruid) => !hruid.startsWith(userContentPrefix)); + + const userContentLearningPaths = await databaseLearningPathProvider.fetchLearningPaths(userContentHruids, language, source, personalizedFor); + const nonUserContentLearningPaths = await dwengoApiLearningPathProvider.fetchLearningPaths( + nonUserContentHruids, + language, + source, + personalizedFor + ); + + const result = (userContentLearningPaths.data || []).concat(nonUserContentLearningPaths.data || []); + + return { + data: result, + source: source, + success: userContentLearningPaths.success || nonUserContentLearningPaths.success, + }; + }, + + /** + * Search learning paths in the data source using the given search string. + */ + async searchLearningPaths(query: string, language: Language, personalizedFor?: PersonalizationTarget): Promise { + const providerResponses = await Promise.all(allProviders.map((provider) => provider.searchLearningPaths(query, language, personalizedFor))); + return providerResponses.flat(); + }, +}; + +export default learningPathService; diff --git a/backend/src/services/questions.ts b/backend/src/services/questions.ts new file mode 100644 index 00000000..ee003bcd --- /dev/null +++ b/backend/src/services/questions.ts @@ -0,0 +1,107 @@ +import { getAnswerRepository, getQuestionRepository } from '../data/repositories.js'; +import { mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId } from '../interfaces/question.js'; +import { Question } from '../entities/questions/question.entity.js'; +import { Answer } from '../entities/questions/answer.entity.js'; +import { mapToAnswerDTO, mapToAnswerId } from '../interfaces/answer.js'; +import { QuestionRepository } from '../data/questions/question-repository.js'; +import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; +import { mapToUser } from '../interfaces/user.js'; +import { Student } from '../entities/users/student.entity.js'; +import { mapToStudent } from '../interfaces/student.js'; + +export async function getAllQuestions(id: LearningObjectIdentifier, full: boolean): Promise { + const questionRepository: QuestionRepository = getQuestionRepository(); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + if (!questions) { + return []; + } + + const questionsDTO: QuestionDTO[] = questions.map(mapToQuestionDTO); + + if (full) { + return questionsDTO; + } + + return questionsDTO.map(mapToQuestionId); +} + +async function fetchQuestion(questionId: QuestionId): Promise { + const questionRepository = getQuestionRepository(); + + return await questionRepository.findOne({ + learningObjectHruid: questionId.learningObjectIdentifier.hruid, + learningObjectLanguage: questionId.learningObjectIdentifier.language, + learningObjectVersion: questionId.learningObjectIdentifier.version, + sequenceNumber: questionId.sequenceNumber, + }); +} + +export async function getQuestion(questionId: QuestionId): Promise { + const question = await fetchQuestion(questionId); + + if (!question) { + return null; + } + + return mapToQuestionDTO(question); +} + +export async function getAnswersByQuestion(questionId: QuestionId, full: boolean) { + const answerRepository = getAnswerRepository(); + const question = await fetchQuestion(questionId); + + if (!question) { + return []; + } + + const answers: Answer[] = await answerRepository.findAllAnswersToQuestion(question); + + if (!answers) { + return []; + } + + const answersDTO = answers.map(mapToAnswerDTO); + + if (full) { + return answersDTO; + } + + return answersDTO.map(mapToAnswerId); +} + +export async function createQuestion(questionDTO: QuestionDTO) { + const questionRepository = getQuestionRepository(); + + const author = mapToStudent(questionDTO.author); + + try { + await questionRepository.createQuestion({ + loId: questionDTO.learningObjectIdentifier, + author, + content: questionDTO.content, + }); + } catch (e) { + return null; + } + + return questionDTO; +} + +export async function deleteQuestion(questionId: QuestionId) { + const questionRepository = getQuestionRepository(); + + const question = await fetchQuestion(questionId); + + if (!question) { + return null; + } + + try { + await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(questionId.learningObjectIdentifier, questionId.sequenceNumber); + } catch (e) { + return null; + } + + return question; +} diff --git a/backend/src/services/students.ts b/backend/src/services/students.ts new file mode 100644 index 00000000..5099a18d --- /dev/null +++ b/backend/src/services/students.ts @@ -0,0 +1,126 @@ +import { getClassRepository, getGroupRepository, getStudentRepository, getSubmissionRepository } from '../data/repositories.js'; +import { Class } from '../entities/classes/class.entity.js'; +import { Student } from '../entities/users/student.entity.js'; +import { AssignmentDTO } from '../interfaces/assignment.js'; +import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; +import { GroupDTO, mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; +import { mapToStudent, mapToStudentDTO, StudentDTO } from '../interfaces/student.js'; +import { mapToSubmissionDTO, SubmissionDTO } from '../interfaces/submission.js'; +import { getAllAssignments } from './assignments.js'; +import { UserService } from './users.js'; + +export async function getAllStudents(): Promise { + const studentRepository = getStudentRepository(); + const users = await studentRepository.findAll(); + return users.map(mapToStudentDTO); +} + +export async function getAllStudentIds(): Promise { + const users = await getAllStudents(); + return users.map((user) => user.username); +} + +export async function getStudent(username: string): Promise { + const studentRepository = getStudentRepository(); + const user = await studentRepository.findByUsername(username); + return user ? mapToStudentDTO(user) : null; +} + +export async function createStudent(userData: StudentDTO): Promise { + const studentRepository = getStudentRepository(); + + try { + const newStudent = studentRepository.create(mapToStudent(userData)); + await studentRepository.save(newStudent); + + return mapToStudentDTO(newStudent); + } catch (e) { + console.log(e); + return null; + } +} + +export async function deleteStudent(username: string): Promise { + const studentRepository = getStudentRepository(); + + const user = await studentRepository.findByUsername(username); + + if (!user) { + return null; + } + + try { + await studentRepository.deleteByUsername(username); + + return mapToStudentDTO(user); + } catch (e) { + console.log(e); + return null; + } +} + +export async function getStudentClasses(username: string, full: boolean): Promise { + const studentRepository = getStudentRepository(); + const student = await studentRepository.findByUsername(username); + + if (!student) { + return []; + } + + const classRepository = getClassRepository(); + const classes = await classRepository.findByStudent(student); + + if (full) { + return classes.map(mapToClassDTO); + } + + return classes.map((cls) => cls.classId!); +} + +export async function getStudentAssignments(username: string, full: boolean): Promise { + const studentRepository = getStudentRepository(); + const student = await studentRepository.findByUsername(username); + + if (!student) { + return []; + } + + const classRepository = getClassRepository(); + const classes = await classRepository.findByStudent(student); + + const assignments = (await Promise.all(classes.map(async (cls) => await getAllAssignments(cls.classId!, full)))).flat(); + + return assignments; +} + +export async function getStudentGroups(username: string, full: boolean): Promise { + const studentRepository = getStudentRepository(); + const student = await studentRepository.findByUsername(username); + + if (!student) { + return []; + } + + const groupRepository = getGroupRepository(); + const groups = await groupRepository.findAllGroupsWithStudent(student); + + if (full) { + return groups.map(mapToGroupDTO); + } + + return groups.map(mapToGroupDTOId); +} + +export async function getStudentSubmissions(username: string): Promise { + const studentRepository = getStudentRepository(); + const student = await studentRepository.findByUsername(username); + + if (!student) { + return []; + } + + const submissionRepository = getSubmissionRepository(); + const submissions = await submissionRepository.findAllSubmissionsForStudent(student); + + return submissions.map(mapToSubmissionDTO); +} diff --git a/backend/src/services/submissions.ts b/backend/src/services/submissions.ts new file mode 100644 index 00000000..a8fa96c7 --- /dev/null +++ b/backend/src/services/submissions.ts @@ -0,0 +1,51 @@ +import { getGroupRepository, getSubmissionRepository } from '../data/repositories.js'; +import { Language } from '../entities/content/language.js'; +import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; +import { mapToSubmission, mapToSubmissionDTO, SubmissionDTO } from '../interfaces/submission.js'; + +export async function getSubmission( + learningObjectHruid: string, + language: Language, + version: number, + submissionNumber: number +): Promise { + const loId = new LearningObjectIdentifier(learningObjectHruid, language, version); + + const submissionRepository = getSubmissionRepository(); + const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(loId, submissionNumber); + + if (!submission) { + return null; + } + + return mapToSubmissionDTO(submission); +} + +export async function createSubmission(submissionDTO: SubmissionDTO) { + const submissionRepository = getSubmissionRepository(); + const submission = mapToSubmission(submissionDTO); + + try { + const newSubmission = await submissionRepository.create(submission); + await submissionRepository.save(newSubmission); + } catch (e) { + return null; + } + + return submission; +} + +export async function deleteSubmission(learningObjectHruid: string, language: Language, version: number, submissionNumber: number) { + const submissionRepository = getSubmissionRepository(); + + const submission = getSubmission(learningObjectHruid, language, version, submissionNumber); + + if (!submission) { + return null; + } + + const loId = new LearningObjectIdentifier(learningObjectHruid, language, version); + await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(loId, submissionNumber); + + return submission; +} diff --git a/backend/src/services/teachers.ts b/backend/src/services/teachers.ts new file mode 100644 index 00000000..f4dbedfe --- /dev/null +++ b/backend/src/services/teachers.ts @@ -0,0 +1,129 @@ +import { + getClassRepository, + getLearningObjectRepository, + getQuestionRepository, + getStudentRepository, + getTeacherRepository, +} from '../data/repositories.js'; +import { Teacher } from '../entities/users/teacher.entity.js'; +import { ClassDTO, mapToClassDTO } from '../interfaces/class.js'; +import { getClassStudents } from './class.js'; +import { StudentDTO } from '../interfaces/student.js'; +import { mapToQuestionDTO, mapToQuestionId, QuestionDTO, QuestionId } from '../interfaces/question.js'; +import { UserService } from './users.js'; +import { mapToUser } from '../interfaces/user.js'; +import { mapToTeacher, mapToTeacherDTO, TeacherDTO } from '../interfaces/teacher.js'; + +export async function getAllTeachers(): Promise { + const teacherRepository = getTeacherRepository(); + const users = await teacherRepository.findAll(); + return users.map(mapToTeacherDTO); +} + +export async function getAllTeacherIds(): Promise { + const users = await getAllTeachers(); + return users.map((user) => user.username); +} + +export async function getTeacher(username: string): Promise { + const teacherRepository = getTeacherRepository(); + const user = await teacherRepository.findByUsername(username); + return user ? mapToTeacherDTO(user) : null; +} + +export async function createTeacher(userData: TeacherDTO): Promise { + const teacherRepository = getTeacherRepository(); + + try { + const newTeacher = teacherRepository.create(mapToTeacher(userData)); + await teacherRepository.save(newTeacher); + + return mapToTeacherDTO(newTeacher); + } catch (e) { + console.log(e); + return null; + } +} + +export async function deleteTeacher(username: string): Promise { + const teacherRepository = getTeacherRepository(); + + const user = await teacherRepository.findByUsername(username); + + if (!user) { + return null; + } + + try { + await teacherRepository.deleteByUsername(username); + + return mapToTeacherDTO(user); + } catch (e) { + console.log(e); + return null; + } +} + +export async function fetchClassesByTeacher(username: string): Promise { + const teacherRepository = getTeacherRepository(); + const teacher = await teacherRepository.findByUsername(username); + if (!teacher) { + return []; + } + + const classRepository = getClassRepository(); + const classes = await classRepository.findByTeacher(teacher); + return classes.map(mapToClassDTO); +} + +export async function getClassesByTeacher(username: string): Promise { + return await fetchClassesByTeacher(username); +} + +export async function getClassIdsByTeacher(username: string): Promise { + const classes = await fetchClassesByTeacher(username); + return classes.map((cls) => cls.id); +} + +export async function fetchStudentsByTeacher(username: string) { + const classes = await getClassIdsByTeacher(username); + + return (await Promise.all(classes.map(async (id) => getClassStudents(id)))).flat(); +} + +export async function getStudentsByTeacher(username: string): Promise { + return await fetchStudentsByTeacher(username); +} + +export async function getStudentIdsByTeacher(username: string): Promise { + const students = await fetchStudentsByTeacher(username); + return students.map((student) => student.username); +} + +export async function fetchTeacherQuestions(username: string): Promise { + const teacherRepository = getTeacherRepository(); + const teacher = await teacherRepository.findByUsername(username); + if (!teacher) { + throw new Error(`Teacher with username '${username}' not found.`); + } + + // Find all learning objects that this teacher manages + const learningObjectRepository = getLearningObjectRepository(); + const learningObjects = await learningObjectRepository.findAllByTeacher(teacher); + + // Fetch all questions related to these learning objects + const questionRepository = getQuestionRepository(); + const questions = await questionRepository.findAllByLearningObjects(learningObjects); + + return questions.map(mapToQuestionDTO); +} + +export async function getQuestionsByTeacher(username: string): Promise { + return await fetchTeacherQuestions(username); +} + +export async function getQuestionIdsByTeacher(username: string): Promise { + const questions = await fetchTeacherQuestions(username); + + return questions.map(mapToQuestionId); +} diff --git a/backend/src/services/users.ts b/backend/src/services/users.ts new file mode 100644 index 00000000..65ed5d4c --- /dev/null +++ b/backend/src/services/users.ts @@ -0,0 +1,41 @@ +import { UserRepository } from '../data/users/user-repository.js'; +import { UserDTO, mapToUser, mapToUserDTO } from '../interfaces/user.js'; +import { User } from '../entities/users/user.entity.js'; + +export class UserService { + protected repository: UserRepository; + + constructor(repository: UserRepository) { + this.repository = repository; + } + + async getAllUsers(): Promise { + const users = await this.repository.findAll(); + return users.map(mapToUserDTO); + } + + async getAllUserIds(): Promise { + const users = await this.getAllUsers(); + return users.map((user) => user.username); + } + + async getUserByUsername(username: string): Promise { + const user = await this.repository.findByUsername(username); + return user ? mapToUserDTO(user) : null; + } + + async createUser(userData: UserDTO, UserClass: new () => T): Promise { + const newUser = mapToUser(userData, new UserClass()); + await this.repository.save(newUser); + return newUser; + } + + async deleteUser(username: string): Promise { + const user = await this.getUserByUsername(username); + if (!user) { + return null; + } + await this.repository.deleteByUsername(username); + return mapToUserDTO(user); + } +} diff --git a/backend/src/sqlite-autoincrement-workaround.ts b/backend/src/sqlite-autoincrement-workaround.ts new file mode 100644 index 00000000..a5c20dfd --- /dev/null +++ b/backend/src/sqlite-autoincrement-workaround.ts @@ -0,0 +1,41 @@ +import { EntityProperty, EventArgs, EventSubscriber } from '@mikro-orm/core'; + +/** + * The tests are ran on an in-memory SQLite database. However, SQLite does not allow fields which are part of composite + * primary keys to be autoincremented (while PostgreSQL, which we use in production, does). This Subscriber works around + * the issue by remembering the highest values for every autoincremented part of a primary key and assigning them when + * creating a new entity. + * + * However, it is important to note the following limitations: + * - this class can only be used for in-memory SQLite databases since the information on what the highest sequence + * number for each of the properties is, is only saved transiently. + * - automatically setting the generated "autoincremented" value for properties only works when the entity is created + * via an entityManager.create(...) or repo.create(...) method. Otherwise, onInit will not be called and therefore, + * the sequence number will not be filled in. + */ +export class SqliteAutoincrementSubscriber implements EventSubscriber { + private sequenceNumbersForEntityType: Map = new Map(); + + /** + * When an entity with an autoincremented property which is part of the composite private key is created, + * automatically fill this property so we won't face not-null-constraint exceptions when persisting it. + */ + onInit(args: EventArgs): void { + if (!args.meta.compositePK) { + return; // If there is not a composite primary key, autoincrement works fine with SQLite anyway. + } + + for (const prop of Object.values(args.meta.properties)) { + const property = prop as EntityProperty; + if (property.primary && property.autoincrement && !(args.entity as Record)[property.name]) { + // Obtain and increment sequence number of this entity. + const propertyKey = args.meta.class.name + '.' + property.name; + const nextSeqNumber = this.sequenceNumbersForEntityType.get(propertyKey) || 0; + this.sequenceNumbersForEntityType.set(propertyKey, nextSeqNumber + 1); + + // Set the property accordingly. + (args.entity as Record)[property.name] = nextSeqNumber + 1; + } + } + } +} diff --git a/backend/src/swagger.ts b/backend/src/swagger.ts new file mode 100644 index 00000000..97b08496 --- /dev/null +++ b/backend/src/swagger.ts @@ -0,0 +1,7 @@ +import { RequestHandler } from 'express'; +import swaggerUi from 'swagger-ui-express'; +import swaggerDocument from '../../docs/api/swagger.json'; + +const swaggerMiddleware: RequestHandler = swaggerUi.setup(swaggerDocument); + +export default swaggerMiddleware; diff --git a/backend/src/util/api-helper.ts b/backend/src/util/api-helper.ts new file mode 100644 index 00000000..fff7a6a1 --- /dev/null +++ b/backend/src/util/api-helper.ts @@ -0,0 +1,43 @@ +import axios, { AxiosRequestConfig } from 'axios'; +import { getLogger, Logger } from '../logging/initalize.js'; + +const logger: Logger = getLogger(); + +/** + * Utility function to fetch data from an API endpoint with error handling. + * Logs errors but does NOT throw exceptions to keep the system running. + * + * @param url The API endpoint to fetch from. + * @param description A short description of what is being fetched (for logging). + * @param options Contains further options such as params (the query params) and responseType (whether the response + * should be parsed as JSON ("json") or whether it should be returned as plain text ("text") + * @returns The response data if successful, or null if an error occurs. + */ +export async function fetchWithLogging( + url: string, + description: string, + options?: { + params?: Record; + query?: Record; + responseType?: 'json' | 'text'; + } +): Promise { + try { + const config: AxiosRequestConfig = options || {}; + const response = await axios.get(url, config); + return response.data; + } catch (error: any) { + if (error.response) { + if (error.response.status === 404) { + logger.debug(`❌ ERROR: ${description} not found (404) at "${url}".`); + } else { + logger.debug( + `❌ ERROR: Failed to fetch ${description}. Status: ${error.response.status} - ${error.response.statusText} (URL: "${url}")` + ); + } + } else { + logger.debug(`❌ ERROR: Network or unexpected error when fetching ${description}:`, error.message); + } + return null; + } +} diff --git a/backend/src/util/async.ts b/backend/src/util/async.ts new file mode 100644 index 00000000..a5fc9b82 --- /dev/null +++ b/backend/src/util/async.ts @@ -0,0 +1,23 @@ +/** + * Replace all occurrences of regex in str with the result of asyncFn called with the matching snippet and each of + * the parts matched by a group in the regex as arguments. + * + * @param str The string where to replace the occurrences + * @param regex + * @param replacementFn + */ +export async function replaceAsync(str: string, regex: RegExp, replacementFn: (match: string, ...args: string[]) => Promise) { + const promises: Promise[] = []; + + // First run through matches: add all Promises resulting from the replacement function + str.replace(regex, (full, ...args) => { + promises.push(replacementFn(full, ...args)); + return full; + }); + + // Wait for the replacements to get loaded. Reverse them so when popping them, we work in a FIFO manner. + const replacements: string[] = await Promise.all(promises); + + // Second run through matches: Replace them by their previously computed replacements. + return str.replace(regex, () => replacements.pop()!); +} diff --git a/backend/src/util/envvars.ts b/backend/src/util/envvars.ts new file mode 100644 index 00000000..115291af --- /dev/null +++ b/backend/src/util/envvars.ts @@ -0,0 +1,59 @@ +const PREFIX = 'DWENGO_'; +const DB_PREFIX = PREFIX + 'DB_'; +const IDP_PREFIX = PREFIX + 'AUTH_'; +const STUDENT_IDP_PREFIX = IDP_PREFIX + 'STUDENT_'; +const TEACHER_IDP_PREFIX = IDP_PREFIX + 'TEACHER_'; +const CORS_PREFIX = PREFIX + 'CORS_'; + +type EnvVar = { key: string; required?: boolean; defaultValue?: any }; + +export const EnvVars: { [key: string]: EnvVar } = { + Port: { key: PREFIX + 'PORT', defaultValue: 3000 }, + DbHost: { key: DB_PREFIX + 'HOST', required: true }, + DbPort: { key: DB_PREFIX + 'PORT', defaultValue: 5432 }, + DbName: { key: DB_PREFIX + 'NAME', defaultValue: 'dwengo' }, + DbUsername: { key: DB_PREFIX + 'USERNAME', required: true }, + DbPassword: { key: DB_PREFIX + 'PASSWORD', required: true }, + DbUpdate: { key: DB_PREFIX + 'UPDATE', defaultValue: false }, + LearningContentRepoApiBaseUrl: { key: PREFIX + 'LEARNING_CONTENT_REPO_API_BASE_URL', defaultValue: 'https://dwengo.org/backend/api' }, + FallbackLanguage: { key: PREFIX + 'FALLBACK_LANGUAGE', defaultValue: 'nl' }, + UserContentPrefix: { key: DB_PREFIX + 'USER_CONTENT_PREFIX', defaultValue: 'u_' }, + IdpStudentUrl: { key: STUDENT_IDP_PREFIX + 'URL', required: true }, + IdpStudentClientId: { key: STUDENT_IDP_PREFIX + 'CLIENT_ID', required: true }, + IdpStudentJwksEndpoint: { key: STUDENT_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, + IdpTeacherUrl: { key: TEACHER_IDP_PREFIX + 'URL', required: true }, + IdpTeacherClientId: { key: TEACHER_IDP_PREFIX + 'CLIENT_ID', required: true }, + IdpTeacherJwksEndpoint: { key: TEACHER_IDP_PREFIX + 'JWKS_ENDPOINT', required: true }, + IdpAudience: { key: IDP_PREFIX + 'AUDIENCE', defaultValue: 'account' }, + CorsAllowedOrigins: { key: CORS_PREFIX + 'ALLOWED_ORIGINS', defaultValue: '' }, + CorsAllowedHeaders: { key: CORS_PREFIX + 'ALLOWED_HEADERS', defaultValue: 'Authorization,Content-Type' }, +} as const; + +/** + * Returns the value of the given environment variable if it is set. + * Otherwise, + * - throw an error if the environment variable was required, + * - return the default value if there is one and it was not required, + * - return an empty string if the environment variable is not required and there is also no default. + * @param envVar The properties of the environment variable (from the EnvVar object). + */ +export function getEnvVar(envVar: EnvVar): string { + const value: string | undefined = process.env[envVar.key]; + if (value) { + return value; + } else if (envVar.required) { + throw new Error(`Missing environment variable: ${envVar.key}`); + } else { + return envVar.defaultValue || ''; + } +} + +export function getNumericEnvVar(envVar: EnvVar): number { + const valueString = getEnvVar(envVar); + const value = parseInt(valueString); + if (isNaN(value)) { + throw new Error(`Invalid value for environment variable ${envVar.key}: ${valueString}. Expected a number.`); + } else { + return value; + } +} diff --git a/backend/src/util/links.ts b/backend/src/util/links.ts new file mode 100644 index 00000000..73e27965 --- /dev/null +++ b/backend/src/util/links.ts @@ -0,0 +1,26 @@ +import { LearningObjectIdentifier } from '../interfaces/learning-content'; + +export function isValidHttpUrl(url: string): boolean { + try { + const parsedUrl = new URL(url, 'http://test.be'); + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'; + } catch (e) { + return false; + } +} + +export function getUrlStringForLearningObject(learningObjectId: LearningObjectIdentifier) { + let url = `/learningObject/${learningObjectId.hruid}/html?language=${learningObjectId.language}`; + if (learningObjectId.version) { + url += `&version=${learningObjectId.version}`; + } + return url; +} + +export function getUrlStringForLearningObjectHTML(learningObjectIdentifier: LearningObjectIdentifier): string { + let url = `/learningObject/${learningObjectIdentifier.hruid}/html?language=${learningObjectIdentifier.language}`; + if (learningObjectIdentifier.version) { + url += `&version=${learningObjectIdentifier.version}`; + } + return url; +} diff --git a/backend/src/util/translation-helper.ts b/backend/src/util/translation-helper.ts new file mode 100644 index 00000000..d0a83b02 --- /dev/null +++ b/backend/src/util/translation-helper.ts @@ -0,0 +1,19 @@ +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; +import { FALLBACK_LANG } from '../config.js'; +import { getLogger, Logger } from '../logging/initalize.js'; + +const logger: Logger = getLogger(); + +export function loadTranslations(language: string): T { + try { + const filePath = path.join(process.cwd(), '_i18n', `${language}.yml`); + const yamlFile = fs.readFileSync(filePath, 'utf8'); + return yaml.load(yamlFile) as T; + } catch (error) { + logger.warn(`Cannot load translation for ${language}, fallen back to dutch`, error); + const fallbackPath = path.join(process.cwd(), '_i18n', `${FALLBACK_LANG}.yml`); + return yaml.load(fs.readFileSync(fallbackPath, 'utf8')) as T; + } +} diff --git a/backend/tests/data/assignments/assignments.test.ts b/backend/tests/data/assignments/assignments.test.ts new file mode 100644 index 00000000..c26fb5ba --- /dev/null +++ b/backend/tests/data/assignments/assignments.test.ts @@ -0,0 +1,42 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; +import { getAssignmentRepository, getClassRepository } from '../../../src/data/repositories'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('AssignmentRepository', () => { + let assignmentRepository: AssignmentRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + assignmentRepository = getAssignmentRepository(); + classRepository = getClassRepository(); + }); + + it('should return the requested assignment', async () => { + const class_ = await classRepository.findById('id02'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 2); + + expect(assignment).toBeTruthy(); + expect(assignment!.title).toBe('tool'); + }); + + it('should return all assignments for a class', async () => { + const class_ = await classRepository.findById('id02'); + const assignments = await assignmentRepository.findAllAssignmentsInClass(class_!); + + expect(assignments).toBeTruthy(); + expect(assignments).toHaveLength(1); + expect(assignments[0].title).toBe('tool'); + }); + + it('should not find removed assignment', async () => { + const class_ = await classRepository.findById('id01'); + await assignmentRepository.deleteByClassAndId(class_!, 3); + + const assignment = await assignmentRepository.findByClassAndId(class_!, 3); + + expect(assignment).toBeNull(); + }); +}); diff --git a/backend/tests/data/assignments/groups.test.ts b/backend/tests/data/assignments/groups.test.ts new file mode 100644 index 00000000..96684d68 --- /dev/null +++ b/backend/tests/data/assignments/groups.test.ts @@ -0,0 +1,49 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { GroupRepository } from '../../../src/data/assignments/group-repository'; +import { getAssignmentRepository, getClassRepository, getGroupRepository } from '../../../src/data/repositories'; +import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('GroupRepository', () => { + let groupRepository: GroupRepository; + let assignmentRepository: AssignmentRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + groupRepository = getGroupRepository(); + assignmentRepository = getAssignmentRepository(); + classRepository = getClassRepository(); + }); + + it('should return the requested group', async () => { + const class_ = await classRepository.findById('id01'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 1); + + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); + + expect(group).toBeTruthy(); + }); + + it('should return all groups for assignment', async () => { + const class_ = await classRepository.findById('id01'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 1); + + const groups = await groupRepository.findAllGroupsForAssignment(assignment!); + + expect(groups).toBeTruthy(); + expect(groups).toHaveLength(3); + }); + + it('should not find removed group', async () => { + const class_ = await classRepository.findById('id02'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 2); + + await groupRepository.deleteByAssignmentAndGroupNumber(assignment!, 1); + + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); + + expect(group).toBeNull(); + }); +}); diff --git a/backend/tests/data/assignments/submissions.test.ts b/backend/tests/data/assignments/submissions.test.ts new file mode 100644 index 00000000..cd212b77 --- /dev/null +++ b/backend/tests/data/assignments/submissions.test.ts @@ -0,0 +1,70 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { SubmissionRepository } from '../../../src/data/assignments/submission-repository'; +import { + getAssignmentRepository, + getClassRepository, + getGroupRepository, + getStudentRepository, + getSubmissionRepository, +} from '../../../src/data/repositories'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; +import { StudentRepository } from '../../../src/data/users/student-repository'; +import { GroupRepository } from '../../../src/data/assignments/group-repository'; +import { AssignmentRepository } from '../../../src/data/assignments/assignment-repository'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('SubmissionRepository', () => { + let submissionRepository: SubmissionRepository; + let studentRepository: StudentRepository; + let groupRepository: GroupRepository; + let assignmentRepository: AssignmentRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + submissionRepository = getSubmissionRepository(); + studentRepository = getStudentRepository(); + groupRepository = getGroupRepository(); + assignmentRepository = getAssignmentRepository(); + classRepository = getClassRepository(); + }); + + it('should find the requested submission', async () => { + const id = new LearningObjectIdentifier('id03', Language.English, 1); + const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1); + + expect(submission).toBeTruthy(); + expect(submission?.content).toBe('sub1'); + }); + + it('should find the most recent submission for a student', async () => { + const id = new LearningObjectIdentifier('id02', Language.English, 1); + const student = await studentRepository.findByUsername('Noordkaap'); + const submission = await submissionRepository.findMostRecentSubmissionForStudent(id, student!); + + expect(submission).toBeTruthy(); + expect(submission?.submissionTime.getDate()).toBe(25); + }); + + it('should find the most recent submission for a group', async () => { + const id = new LearningObjectIdentifier('id03', Language.English, 1); + const class_ = await classRepository.findById('id01'); + const assignment = await assignmentRepository.findByClassAndId(class_!, 1); + const group = await groupRepository.findByAssignmentAndGroupNumber(assignment!, 1); + const submission = await submissionRepository.findMostRecentSubmissionForGroup(id, group!); + + expect(submission).toBeTruthy(); + expect(submission?.submissionTime.getDate()).toBe(25); + }); + + it('should not find a deleted submission', async () => { + const id = new LearningObjectIdentifier('id01', Language.English, 1); + await submissionRepository.deleteSubmissionByLearningObjectAndSubmissionNumber(id, 1); + + const submission = await submissionRepository.findSubmissionByLearningObjectAndSubmissionNumber(id, 1); + + expect(submission).toBeNull(); + }); +}); diff --git a/backend/tests/data/classes/class-join-request.test.ts b/backend/tests/data/classes/class-join-request.test.ts new file mode 100644 index 00000000..f0aa2f62 --- /dev/null +++ b/backend/tests/data/classes/class-join-request.test.ts @@ -0,0 +1,47 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { ClassJoinRequestRepository } from '../../../src/data/classes/class-join-request-repository'; +import { getClassJoinRequestRepository, getClassRepository, getStudentRepository } from '../../../src/data/repositories'; +import { StudentRepository } from '../../../src/data/users/student-repository'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { Student } from '../../../src/entities/users/student.entity'; + +describe('ClassJoinRequestRepository', () => { + let classJoinRequestRepository: ClassJoinRequestRepository; + let studentRepository: StudentRepository; + let cassRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + classJoinRequestRepository = getClassJoinRequestRepository(); + studentRepository = getStudentRepository(); + cassRepository = getClassRepository(); + }); + + it('should list all requests from student to join classes', async () => { + const student = await studentRepository.findByUsername('PinkFloyd'); + const requests = await classJoinRequestRepository.findAllRequestsBy(student!); + + expect(requests).toBeTruthy(); + expect(requests).toHaveLength(2); + }); + + it('should list all requests to a single class', async () => { + const class_ = await cassRepository.findById('id02'); + const requests = await classJoinRequestRepository.findAllOpenRequestsTo(class_!); + + expect(requests).toBeTruthy(); + expect(requests).toHaveLength(2); + }); + + it('should not find a removed request', async () => { + const student = await studentRepository.findByUsername('SmashingPumpkins'); + const class_ = await cassRepository.findById('id03'); + await classJoinRequestRepository.deleteBy(student!, class_!); + + const request = await classJoinRequestRepository.findAllRequestsBy(student!); + + expect(request).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/classes/classes.test.ts b/backend/tests/data/classes/classes.test.ts new file mode 100644 index 00000000..22306ba6 --- /dev/null +++ b/backend/tests/data/classes/classes.test.ts @@ -0,0 +1,34 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; +import { setupTestApp } from '../../setup-tests'; +import { getClassRepository } from '../../../src/data/repositories'; + +describe('ClassRepository', () => { + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + classRepository = getClassRepository(); + }); + + it('should return nothing because id does not exist', async () => { + const classVar = await classRepository.findById('test_id'); + + expect(classVar).toBeNull(); + }); + + it('should return requested class', async () => { + const classVar = await classRepository.findById('id01'); + + expect(classVar).toBeTruthy(); + expect(classVar?.displayName).toBe('class01'); + }); + + it('class should be gone after deletion', async () => { + await classRepository.deleteById('id04'); + + const classVar = await classRepository.findById('id04'); + + expect(classVar).toBeNull(); + }); +}); diff --git a/backend/tests/data/classes/teacher-invitation.test.ts b/backend/tests/data/classes/teacher-invitation.test.ts new file mode 100644 index 00000000..dd03634a --- /dev/null +++ b/backend/tests/data/classes/teacher-invitation.test.ts @@ -0,0 +1,54 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { getClassRepository, getTeacherInvitationRepository, getTeacherRepository } from '../../../src/data/repositories'; +import { TeacherInvitationRepository } from '../../../src/data/classes/teacher-invitation-repository'; +import { TeacherRepository } from '../../../src/data/users/teacher-repository'; +import { ClassRepository } from '../../../src/data/classes/class-repository'; + +describe('ClassRepository', () => { + let teacherInvitationRepository: TeacherInvitationRepository; + let teacherRepository: TeacherRepository; + let classRepository: ClassRepository; + + beforeAll(async () => { + await setupTestApp(); + teacherInvitationRepository = getTeacherInvitationRepository(); + teacherRepository = getTeacherRepository(); + classRepository = getClassRepository(); + }); + + it('should return all invitations from a teacher', async () => { + const teacher = await teacherRepository.findByUsername('LimpBizkit'); + const invitations = await teacherInvitationRepository.findAllInvitationsBy(teacher!); + + expect(invitations).toBeTruthy(); + expect(invitations).toHaveLength(2); + }); + + it('should return all invitations for a teacher', async () => { + const teacher = await teacherRepository.findByUsername('FooFighters'); + const invitations = await teacherInvitationRepository.findAllInvitationsFor(teacher!); + + expect(invitations).toBeTruthy(); + expect(invitations).toHaveLength(2); + }); + + it('should return all invitations for a class', async () => { + const class_ = await classRepository.findById('id02'); + const invitations = await teacherInvitationRepository.findAllInvitationsForClass(class_!); + + expect(invitations).toBeTruthy(); + expect(invitations).toHaveLength(2); + }); + + it('should not find a removed invitation', async () => { + const class_ = await classRepository.findById('id01'); + const sender = await teacherRepository.findByUsername('FooFighters'); + const receiver = await teacherRepository.findByUsername('LimpBizkit'); + await teacherInvitationRepository.deleteBy(class_!, sender!, receiver!); + + const invitation = await teacherInvitationRepository.findAllInvitationsBy(sender!); + + expect(invitation).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/content/attachment-repository.test.ts b/backend/tests/data/content/attachment-repository.test.ts new file mode 100644 index 00000000..85c1f1c5 --- /dev/null +++ b/backend/tests/data/content/attachment-repository.test.ts @@ -0,0 +1,79 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests.js'; +import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js'; +import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; +import { Attachment } from '../../../src/entities/content/attachment.entity.js'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js'; + +const NEWER_TEST_SUFFIX = 'nEweR'; + +function createTestLearningObjects(learningObjectRepo: LearningObjectRepository): { older: LearningObject; newer: LearningObject } { + const olderExample = example.createLearningObject(); + learningObjectRepo.save(olderExample); + + const newerExample = example.createLearningObject(); + newerExample.title = 'Newer example'; + newerExample.version = 100; + + return { + older: olderExample, + newer: newerExample, + }; +} + +describe('AttachmentRepository', () => { + let attachmentRepo: AttachmentRepository; + let exampleLearningObjects: { older: LearningObject; newer: LearningObject }; + let attachmentsOlderLearningObject: Attachment[]; + + beforeAll(async () => { + await setupTestApp(); + attachmentRepo = getAttachmentRepository(); + exampleLearningObjects = createTestLearningObjects(getLearningObjectRepository()); + }); + + it('can add attachments to learning objects without throwing an error', () => { + attachmentsOlderLearningObject = Object.values(example.createAttachment).map((fn) => fn(exampleLearningObjects.older)); + + for (const attachment of attachmentsOlderLearningObject) { + attachmentRepo.save(attachment); + } + }); + + let attachmentOnlyNewer: Attachment; + it('allows us to add attachments with the same name to a different learning object without throwing an error', () => { + attachmentOnlyNewer = Object.values(example.createAttachment)[0](exampleLearningObjects.newer); + attachmentOnlyNewer.content.write(NEWER_TEST_SUFFIX); + + attachmentRepo.save(attachmentOnlyNewer); + }); + + let olderLearningObjectId: LearningObjectIdentifier; + it('returns the correct attachment when queried by learningObjectId and attachment name', async () => { + olderLearningObjectId = { + hruid: exampleLearningObjects.older.hruid, + language: exampleLearningObjects.older.language, + version: exampleLearningObjects.older.version, + }; + + const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, attachmentsOlderLearningObject[0].name); + expect(result).toBe(attachmentsOlderLearningObject[0]); + }); + + it('returns null when queried by learningObjectId and non-existing attachment name', async () => { + const result = await attachmentRepo.findByLearningObjectIdAndName(olderLearningObjectId, 'non-existing name'); + expect(result).toBe(null); + }); + + it('returns the newer version of the attachment when only queried by hruid, language and attachment name (but not version)', async () => { + const result = await attachmentRepo.findByMostRecentVersionOfLearningObjectAndName( + exampleLearningObjects.older.hruid, + exampleLearningObjects.older.language, + attachmentOnlyNewer.name + ); + expect(result).toBe(attachmentOnlyNewer); + }); +}); diff --git a/backend/tests/data/content/attachments.test.ts b/backend/tests/data/content/attachments.test.ts new file mode 100644 index 00000000..94e132a9 --- /dev/null +++ b/backend/tests/data/content/attachments.test.ts @@ -0,0 +1,31 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests.js'; +import { getAttachmentRepository, getLearningObjectRepository } from '../../../src/data/repositories.js'; +import { AttachmentRepository } from '../../../src/data/content/attachment-repository.js'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier.js'; +import { Language } from '../../../src/entities/content/language.js'; + +describe('AttachmentRepository', () => { + let attachmentRepository: AttachmentRepository; + let learningObjectRepository: LearningObjectRepository; + + beforeAll(async () => { + await setupTestApp(); + attachmentRepository = getAttachmentRepository(); + learningObjectRepository = getLearningObjectRepository(); + }); + + it('should return the requested attachment', async () => { + const id = new LearningObjectIdentifier('id02', Language.English, 1); + const learningObject = await learningObjectRepository.findByIdentifier(id); + + const attachment = await attachmentRepository.findByMostRecentVersionOfLearningObjectAndName( + learningObject!.hruid, + Language.English, + 'attachment01' + ); + + expect(attachment).toBeTruthy(); + }); +}); diff --git a/backend/tests/data/content/learning-object-repository.test.ts b/backend/tests/data/content/learning-object-repository.test.ts new file mode 100644 index 00000000..12e14452 --- /dev/null +++ b/backend/tests/data/content/learning-object-repository.test.ts @@ -0,0 +1,72 @@ +import { beforeAll, describe, it, expect } from 'vitest'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import { setupTestApp } from '../../setup-tests.js'; +import { getLearningObjectRepository } from '../../../src/data/repositories.js'; +import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; +import { expectToBeCorrectEntity } from '../../test-utils/expectations.js'; + +describe('LearningObjectRepository', () => { + let learningObjectRepository: LearningObjectRepository; + + let exampleLearningObject: LearningObject; + + beforeAll(async () => { + await setupTestApp(); + learningObjectRepository = getLearningObjectRepository(); + }); + + it('should be able to add a learning object to it without an error', async () => { + exampleLearningObject = example.createLearningObject(); + await learningObjectRepository.insert(exampleLearningObject); + }); + + it('should return the learning object when queried by id', async () => { + const result = await learningObjectRepository.findByIdentifier({ + hruid: exampleLearningObject.hruid, + language: exampleLearningObject.language, + version: exampleLearningObject.version, + }); + expect(result).toBeInstanceOf(LearningObject); + expectToBeCorrectEntity( + { + name: 'actual', + entity: result!, + }, + { + name: 'expected', + entity: exampleLearningObject, + } + ); + }); + + it('should return null when non-existing version is queried', async () => { + const result = await learningObjectRepository.findByIdentifier({ + hruid: exampleLearningObject.hruid, + language: exampleLearningObject.language, + version: 100, + }); + expect(result).toBe(null); + }); + + let newerExample: LearningObject; + + it('should allow a learning object with the same id except a different version to be added', async () => { + newerExample = example.createLearningObject(); + newerExample.version = 10; + newerExample.title += ' (nieuw)'; + await learningObjectRepository.save(newerExample); + }); + + it('should return the newest version of the learning object when queried by only hruid and language', async () => { + const result = await learningObjectRepository.findLatestByHruidAndLanguage(newerExample.hruid, newerExample.language); + expect(result).toBeInstanceOf(LearningObject); + expect(result?.version).toBe(10); + expect(result?.title).toContain('(nieuw)'); + }); + + it('should return null when queried by non-existing hruid or language', async () => { + const result = await learningObjectRepository.findLatestByHruidAndLanguage('something_that_does_not_exist', exampleLearningObject.language); + expect(result).toBe(null); + }); +}); diff --git a/backend/tests/data/content/learning-objects.test.ts b/backend/tests/data/content/learning-objects.test.ts new file mode 100644 index 00000000..712f75c9 --- /dev/null +++ b/backend/tests/data/content/learning-objects.test.ts @@ -0,0 +1,32 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository'; +import { getLearningObjectRepository } from '../../../src/data/repositories'; +import { setupTestApp } from '../../setup-tests'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; + +describe('LearningObjectRepository', () => { + let learningObjectRepository: LearningObjectRepository; + + beforeAll(async () => { + await setupTestApp(); + learningObjectRepository = getLearningObjectRepository(); + }); + + const id01 = new LearningObjectIdentifier('id01', Language.English, 1); + const id02 = new LearningObjectIdentifier('test_id', Language.English, 1); + + it('should return the learning object that matches identifier 1', async () => { + const learningObject = await learningObjectRepository.findByIdentifier(id01); + + expect(learningObject).toBeTruthy(); + expect(learningObject?.title).toBe('Undertow'); + expect(learningObject?.description).toBe('debute'); + }); + + it('should return nothing because the identifier does not exist in the database', async () => { + const learningObject = await learningObjectRepository.findByIdentifier(id02); + + expect(learningObject).toBeNull(); + }); +}); diff --git a/backend/tests/data/content/learning-path-repository.test.ts b/backend/tests/data/content/learning-path-repository.test.ts new file mode 100644 index 00000000..8dbff3c1 --- /dev/null +++ b/backend/tests/data/content/learning-path-repository.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests.js'; +import { getLearningPathRepository } from '../../../src/data/repositories.js'; +import { LearningPathRepository } from '../../../src/data/content/learning-path-repository.js'; +import example from '../../test-assets/learning-paths/pn-werking-example.js'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; +import { expectToBeCorrectEntity } from '../../test-utils/expectations.js'; +import { Language } from '../../../src/entities/content/language.js'; + +function expectToHaveFoundPrecisely(expected: LearningPath, result: LearningPath[]): void { + expect(result).toHaveProperty('length'); + expect(result.length).toBe(1); + expectToBeCorrectEntity({ entity: result[0]! }, { entity: expected }); +} + +function expectToHaveFoundNothing(result: LearningPath[]): void { + expect(result).toHaveProperty('length'); + expect(result.length).toBe(0); +} + +describe('LearningPathRepository', () => { + let learningPathRepo: LearningPathRepository; + + beforeAll(async () => { + await setupTestApp(); + learningPathRepo = getLearningPathRepository(); + }); + + let examplePath: LearningPath; + + it('should be able to add a learning path without throwing an error', async () => { + examplePath = example.createLearningPath(); + await learningPathRepo.insert(examplePath); + }); + + it('should return the added path when it is queried by hruid and language', async () => { + const result = await learningPathRepo.findByHruidAndLanguage(examplePath.hruid, examplePath.language); + expect(result).toBeInstanceOf(LearningPath); + expectToBeCorrectEntity({ entity: result! }, { entity: examplePath }); + }); + + it('should return null to a query on a non-existing hruid or language', async () => { + const result = await learningPathRepo.findByHruidAndLanguage('not_existing_hruid', examplePath.language); + expect(result).toBe(null); + }); + + it('should return the learning path when we search for a search term occurring in its title', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.title.slice(4, 9), examplePath.language); + expectToHaveFoundPrecisely(examplePath, result); + }); + + it('should return the learning path when we search for a search term occurring in its description', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.description.slice(8, 15), examplePath.language); + expectToHaveFoundPrecisely(examplePath, result); + }); + + it('should return null when we search for something not occurring in its title or description', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage('something not occurring in the path', examplePath.language); + expectToHaveFoundNothing(result); + }); + + it('should return null when we search for something occurring in its title, but another language', async () => { + const result = await learningPathRepo.findByQueryStringAndLanguage(examplePath.description.slice(1, 3), Language.Kalaallisut); + expectToHaveFoundNothing(result); + }); +}); diff --git a/backend/tests/data/content/learning-paths.test.ts b/backend/tests/data/content/learning-paths.test.ts new file mode 100644 index 00000000..01fd20e5 --- /dev/null +++ b/backend/tests/data/content/learning-paths.test.ts @@ -0,0 +1,28 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { getLearningPathRepository } from '../../../src/data/repositories'; +import { LearningPathRepository } from '../../../src/data/content/learning-path-repository'; +import { setupTestApp } from '../../setup-tests'; +import { Language } from '../../../src/entities/content/language'; + +describe('LearningPathRepository', () => { + let learningPathRepository: LearningPathRepository; + + beforeAll(async () => { + await setupTestApp(); + learningPathRepository = getLearningPathRepository(); + }); + + it('should return nothing because no match for hruid and language', async () => { + const learningPath = await learningPathRepository.findByHruidAndLanguage('test_id', Language.Dutch); + + expect(learningPath).toBeNull(); + }); + + it('should return requested learning path', async () => { + const learningPath = await learningPathRepository.findByHruidAndLanguage('id01', Language.English); + + expect(learningPath).toBeTruthy(); + expect(learningPath?.title).toBe('repertoire Tool'); + expect(learningPath?.description).toBe('all about Tool'); + }); +}); diff --git a/backend/tests/data/questions/answers.test.ts b/backend/tests/data/questions/answers.test.ts new file mode 100644 index 00000000..bcc62cf6 --- /dev/null +++ b/backend/tests/data/questions/answers.test.ts @@ -0,0 +1,66 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { AnswerRepository } from '../../../src/data/questions/answer-repository'; +import { getAnswerRepository, getQuestionRepository, getTeacherRepository } from '../../../src/data/repositories'; +import { QuestionRepository } from '../../../src/data/questions/question-repository'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; +import { TeacherRepository } from '../../../src/data/users/teacher-repository'; + +describe('AnswerRepository', () => { + let answerRepository: AnswerRepository; + let questionRepository: QuestionRepository; + let teacherRepository: TeacherRepository; + + beforeAll(async () => { + await setupTestApp(); + answerRepository = getAnswerRepository(); + questionRepository = getQuestionRepository(); + teacherRepository = getTeacherRepository(); + }); + + it('should find all answers to a question', async () => { + const id = new LearningObjectIdentifier('id05', Language.English, 1); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + const question = questions.filter((it) => it.sequenceNumber == 2)[0]; + + const answers = await answerRepository.findAllAnswersToQuestion(question); + + expect(answers).toBeTruthy(); + expect(answers).toHaveLength(2); + expect(answers[0].content).toBeOneOf(['answer', 'answer2']); + expect(answers[1].content).toBeOneOf(['answer', 'answer2']); + }); + + it('should create an answer to a question', async () => { + const teacher = await teacherRepository.findByUsername('FooFighters'); + const id = new LearningObjectIdentifier('id05', Language.English, 1); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + const question = questions[0]; + + await answerRepository.createAnswer({ + toQuestion: question, + author: teacher!, + content: 'created answer', + }); + + const answers = await answerRepository.findAllAnswersToQuestion(question); + + expect(answers).toBeTruthy(); + expect(answers).toHaveLength(1); + expect(answers[0].content).toBe('created answer'); + }); + + it('should not find a removed answer', async () => { + const id = new LearningObjectIdentifier('id04', Language.English, 1); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + await answerRepository.removeAnswerByQuestionAndSequenceNumber(questions[0], 1); + + const emptyList = await answerRepository.findAllAnswersToQuestion(questions[0]); + + expect(emptyList).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/questions/questions.test.ts b/backend/tests/data/questions/questions.test.ts new file mode 100644 index 00000000..7b408df4 --- /dev/null +++ b/backend/tests/data/questions/questions.test.ts @@ -0,0 +1,52 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { QuestionRepository } from '../../../src/data/questions/question-repository'; +import { getLearningObjectRepository, getQuestionRepository, getStudentRepository } from '../../../src/data/repositories'; +import { StudentRepository } from '../../../src/data/users/student-repository'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository'; +import { LearningObjectIdentifier } from '../../../src/entities/content/learning-object-identifier'; +import { Language } from '../../../src/entities/content/language'; + +describe('QuestionRepository', () => { + let questionRepository: QuestionRepository; + let studentRepository: StudentRepository; + let learningObjectRepository: LearningObjectRepository; + + beforeAll(async () => { + await setupTestApp(); + questionRepository = getQuestionRepository(); + studentRepository = getStudentRepository(); + learningObjectRepository = getLearningObjectRepository(); + }); + + it('should return all questions part of the given learning object', async () => { + const id = new LearningObjectIdentifier('id05', Language.English, 1); + const questions = await questionRepository.findAllQuestionsAboutLearningObject(id); + + expect(questions).toBeTruthy(); + expect(questions).toHaveLength(2); + }); + + it('should create new question', async () => { + const id = new LearningObjectIdentifier('id03', Language.English, 1); + const student = await studentRepository.findByUsername('Noordkaap'); + await questionRepository.createQuestion({ + loId: id, + author: student!, + content: 'question?', + }); + const question = await questionRepository.findAllQuestionsAboutLearningObject(id); + + expect(question).toBeTruthy(); + expect(question).toHaveLength(1); + }); + + it('should not find removed question', async () => { + const id = new LearningObjectIdentifier('id04', Language.English, 1); + await questionRepository.removeQuestionByLearningObjectAndSequenceNumber(id, 1); + + const question = await questionRepository.findAllQuestionsAboutLearningObject(id); + + expect(question).toHaveLength(0); + }); +}); diff --git a/backend/tests/data/users/students.test.ts b/backend/tests/data/users/students.test.ts new file mode 100644 index 00000000..78800e1f --- /dev/null +++ b/backend/tests/data/users/students.test.ts @@ -0,0 +1,47 @@ +import { setupTestApp } from '../../setup-tests.js'; +import { Student } from '../../../src/entities/users/student.entity.js'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { StudentRepository } from '../../../src/data/users/student-repository.js'; +import { getStudentRepository } from '../../../src/data/repositories.js'; + +const username = 'teststudent'; +const firstName = 'John'; +const lastName = 'Doe'; +describe('StudentRepository', () => { + let studentRepository: StudentRepository; + + beforeAll(async () => { + await setupTestApp(); + studentRepository = getStudentRepository(); + }); + + it('should not return a student because username does not exist', async () => { + const student = await studentRepository.findByUsername('test'); + + expect(student).toBeNull(); + }); + + it('should return student from the datbase', async () => { + const student = await studentRepository.findByUsername('Noordkaap'); + + expect(student).toBeTruthy(); + expect(student?.firstName).toBe('Stijn'); + expect(student?.lastName).toBe('Meuris'); + }); + + it('should return the queried student after he was added', async () => { + await studentRepository.insert(new Student(username, firstName, lastName)); + + const retrievedStudent = await studentRepository.findByUsername(username); + expect(retrievedStudent).toBeTruthy(); + expect(retrievedStudent?.firstName).toBe(firstName); + expect(retrievedStudent?.lastName).toBe(lastName); + }); + + it('should no longer return the queried student after he was removed again', async () => { + await studentRepository.deleteByUsername(username); + + const retrievedStudent = await studentRepository.findByUsername(username); + expect(retrievedStudent).toBeNull(); + }); +}); diff --git a/backend/tests/data/users/teachers.test.ts b/backend/tests/data/users/teachers.test.ts new file mode 100644 index 00000000..0bd014a6 --- /dev/null +++ b/backend/tests/data/users/teachers.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { TeacherRepository } from '../../../src/data/users/teacher-repository'; +import { setupTestApp } from '../../setup-tests'; +import { getTeacherRepository } from '../../../src/data/repositories'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; + +const username = 'testteacher'; +const firstName = 'John'; +const lastName = 'Doe'; +describe('TeacherRepository', () => { + let teacherRepository: TeacherRepository; + + beforeAll(async () => { + await setupTestApp(); + teacherRepository = getTeacherRepository(); + }); + + it('should not return a teacher because username does not exist', async () => { + const teacher = await teacherRepository.findByUsername('test'); + + expect(teacher).toBeNull(); + }); + + it('should return teacher from the datbase', async () => { + const teacher = await teacherRepository.findByUsername('FooFighters'); + + expect(teacher).toBeTruthy(); + expect(teacher?.firstName).toBe('Dave'); + expect(teacher?.lastName).toBe('Grohl'); + }); + + it('should return the queried teacher after he was added', async () => { + await teacherRepository.insert(new Teacher(username, firstName, lastName)); + + const retrievedTeacher = await teacherRepository.findByUsername(username); + expect(retrievedTeacher).toBeTruthy(); + expect(retrievedTeacher?.firstName).toBe(firstName); + expect(retrievedTeacher?.lastName).toBe(lastName); + }); + + it('should no longer return the queried teacher after he was removed again', async () => { + await teacherRepository.deleteByUsername('ZesdeMetaal'); + + const retrievedTeacher = await teacherRepository.findByUsername('ZesdeMetaal'); + expect(retrievedTeacher).toBeNull(); + }); +}); diff --git a/backend/tests/example.test.ts b/backend/tests/example.test.ts new file mode 100644 index 00000000..d0a1c3c8 --- /dev/null +++ b/backend/tests/example.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; + +describe('Sample test', () => { + it('should sum to 2', () => { + const expected = 2; + const result = 1 + 1; + expect(result).equals(expected); + }); +}); diff --git a/backend/tests/service/learning-objects.test.ts b/backend/tests/service/learning-objects.test.ts new file mode 100644 index 00000000..130c237e --- /dev/null +++ b/backend/tests/service/learning-objects.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { LearningObjectMetadata, LearningPath } from '../../src/interfaces/learningPath'; +import { fetchWithLogging } from '../../src/util/apiHelper'; +import { getLearningObjectById, getLearningObjectsFromPath } from '../../src/services/learningObjects'; +import { fetchLearningPaths } from '../../src/services/learningPaths'; + +// Mock API functions +vi.mock('../../src/util/apiHelper', () => ({ + fetchWithLogging: vi.fn(), +})); + +vi.mock('../../src/services/learningPaths', () => ({ + fetchLearningPaths: vi.fn(), +})); + +describe('getLearningObjectById', () => { + const hruid = 'test-object'; + const language = 'en'; + const mockMetadata: LearningObjectMetadata = { + hruid, + _id: '123', + uuid: 'uuid-123', + version: 1, + title: 'Test Object', + language, + difficulty: 5, + estimated_time: 120, + available: true, + teacher_exclusive: false, + educational_goals: [{ source: 'source', id: 'id' }], + keywords: ['robotics'], + description: 'A test object', + target_ages: [10, 12], + content_type: 'markdown', + content_location: '', + }; + + it('✅ Should return a filtered learning object when API provides data', async () => { + vi.mocked(fetchWithLogging).mockResolvedValueOnce(mockMetadata); + + const result = await getLearningObjectById(hruid, language); + + expect(result).toEqual({ + key: hruid, + _id: '123', + uuid: 'uuid-123', + version: 1, + title: 'Test Object', + htmlUrl: expect.stringContaining('/learningObject/getRaw?hruid=test-object&language=en'), + language, + difficulty: 5, + estimatedTime: 120, + available: true, + teacherExclusive: false, + educationalGoals: [{ source: 'source', id: 'id' }], + keywords: ['robotics'], + description: 'A test object', + targetAges: [10, 12], + contentType: 'markdown', + contentLocation: '', + }); + }); + + it('⚠️ Should return null if API returns no metadata', async () => { + vi.mocked(fetchWithLogging).mockResolvedValueOnce(null); + const result = await getLearningObjectById(hruid, language); + expect(result).toBeNull(); + }); +}); + +describe('getLearningObjectsFromPath', () => { + const hruid = 'test-path'; + const language = 'en'; + + it('✅ Should not give error or warning', async () => { + const mockPathResponse: LearningPath[] = [ + { + _id: 'path-1', + hruid, + language, + title: 'Test Path', + description: '', + num_nodes: 1, + num_nodes_left: 0, + nodes: [], + keywords: '', + target_ages: [], + min_age: 10, + max_age: 12, + __order: 1, + }, + ]; + + vi.mocked(fetchLearningPaths).mockResolvedValueOnce({ + success: true, + source: 'Test Source', + data: mockPathResponse, + }); + + const result = await getLearningObjectsFromPath(hruid, language); + expect(result).toEqual([]); + }); + + it('⚠️ Should give a warning', async () => { + vi.mocked(fetchLearningPaths).mockResolvedValueOnce({ success: false, source: 'Test Source', data: [] }); + + const result = await getLearningObjectsFromPath(hruid, language); + expect(result).toEqual([]); + }); + + it('❌ Should give an error', async () => { + vi.mocked(fetchLearningPaths).mockRejectedValueOnce(new Error('API Error')); + + const result = await getLearningObjectsFromPath(hruid, language); + expect(result).toEqual([]); + }); +}); diff --git a/backend/tests/service/learning-paths.test.ts b/backend/tests/service/learning-paths.test.ts new file mode 100644 index 00000000..c002dbac --- /dev/null +++ b/backend/tests/service/learning-paths.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fetchLearningPaths, searchLearningPaths } from '../../src/services/learningPaths'; +import { fetchWithLogging } from '../../src/util/apiHelper'; +import { LearningPathResponse } from '../../src/interfaces/learningPath'; + +// Mock the fetchWithLogging module using vi +vi.mock('../../src/util/apiHelper', () => ({ + fetchWithLogging: vi.fn(), +})); + +describe('fetchLearningPaths', () => { + // Mock data and response + const mockHruids = ['pn_werking', 'art1']; + const language = 'en'; + const source = 'Test Source'; + const mockResponse = [{ title: 'Test Path', hruids: mockHruids }]; + + it('✅ Should return a successful response when HRUIDs are provided', async () => { + // Mock the function to return mockResponse + vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse); + + const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source); + + expect(result.success).toBe(true); + expect(result.data).toEqual(mockResponse); + expect(result.source).toBe(source); + }); + + it('⚠️ Should return an error when no HRUIDs are provided', async () => { + vi.mocked(fetchWithLogging).mockResolvedValue(mockResponse); + + const result: LearningPathResponse = await fetchLearningPaths([], language, source); + + expect(result.success).toBe(false); + expect(result.data).toBeNull(); + expect(result.message).toBe(`No HRUIDs provided for ${source}.`); + }); + + it('⚠️ Should return a failure response when no learning paths are found', async () => { + // Mock fetchWithLogging to return an empty array + vi.mocked(fetchWithLogging).mockResolvedValue([]); + + const result: LearningPathResponse = await fetchLearningPaths(mockHruids, language, source); + + expect(result.success).toBe(false); + expect(result.data).toEqual([]); + expect(result.message).toBe(`No learning paths found for ${source}.`); + }); +}); + +describe('searchLearningPaths', () => { + const query = + 'https://dwengo.org/backend/api/learningPath/getPathsFromIdList?pathIdList=%7B%22hruids%22:%5B%22pn_werking%22,%22un_artificiele_intelligentie%22%5D%7D&language=nl'; + const language = 'nl'; + + it('✅ Should return search results when API responds with data', async () => { + const mockResults = [ + { + _id: '67b4488c9dadb305c4104618', + language: 'nl', + hruid: 'pn_werking', + title: 'Werken met notebooks', + description: 'Een korte inleiding tot Python notebooks. Hoe ga je gemakkelijk en efficiënt met de notebooks aan de slag?', + num_nodes: 0, + num_nodes_left: 0, + nodes: [], + keywords: 'Python KIKS Wiskunde STEM AI', + target_ages: [14, 15, 16, 17, 18], + min_age: 14, + max_age: 18, + __order: 0, + }, + ]; + + // Mock fetchWithLogging to return search results + vi.mocked(fetchWithLogging).mockResolvedValue(mockResults); + + const result = await searchLearningPaths(query, language); + + expect(result).toEqual(mockResults); + }); + + it('⚠️ Should return an empty array when API returns no results', async () => { + vi.mocked(fetchWithLogging).mockResolvedValue([]); + + const result = await searchLearningPaths(query, language); + + expect(result).toEqual([]); + }); +}); diff --git a/backend/tests/services/learning-objects/database-learning-object-provider.test.ts b/backend/tests/services/learning-objects/database-learning-object-provider.test.ts new file mode 100644 index 00000000..692a72de --- /dev/null +++ b/backend/tests/services/learning-objects/database-learning-object-provider.test.ts @@ -0,0 +1,108 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; +import example from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import databaseLearningObjectProvider from '../../../src/services/learning-objects/database-learning-object-provider'; +import { expectToBeCorrectFilteredLearningObject } from '../../test-utils/expectations'; +import { FilteredLearningObject } from '../../../src/interfaces/learning-content'; +import { Language } from '../../../src/entities/content/language'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +const EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT = 'Notebook opslaan'; + +describe('DatabaseLearningObjectProvider', () => { + let exampleLearningObject: LearningObject; + let exampleLearningPath: LearningPath; + + beforeAll(async () => { + await setupTestApp(); + const exampleData = await initExampleData(); + exampleLearningObject = exampleData.learningObject; + exampleLearningPath = exampleData.learningPath; + }); + describe('getLearningObjectById', () => { + it('should return the learning object when it is queried by its id', async () => { + const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById(exampleLearningObject); + expect(result).toBeTruthy(); + expectToBeCorrectFilteredLearningObject(result!, exampleLearningObject); + }); + + it('should return the learning object when it is queried by only hruid and language (but not version)', async () => { + const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById({ + hruid: exampleLearningObject.hruid, + language: exampleLearningObject.language, + }); + expect(result).toBeTruthy(); + expectToBeCorrectFilteredLearningObject(result!, exampleLearningObject); + }); + + it('should return null when queried with an id that does not exist', async () => { + const result: FilteredLearningObject | null = await databaseLearningObjectProvider.getLearningObjectById({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + describe('getLearningObjectHTML', () => { + it('should return the correct rendering of the learning object', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectHTML(exampleLearningObject); + expect(result).toEqual(example.getHTMLRendering()); + }); + it('should return null for a non-existing learning object', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectHTML({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + describe('getLearningObjectIdsFromPath', () => { + it('should return all learning object IDs from a path', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectIdsFromPath(exampleLearningPath); + expect(new Set(result)).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid))); + }); + it('should throw an error if queried with a path identifier for which there is no learning path', async () => { + await expect( + (async () => { + await databaseLearningObjectProvider.getLearningObjectIdsFromPath({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + })() + ).rejects.toThrowError(); + }); + }); + describe('getLearningObjectsFromPath', () => { + it('should correctly return all learning objects which are on the path, even those who are not in the database', async () => { + const result = await databaseLearningObjectProvider.getLearningObjectsFromPath(exampleLearningPath); + expect(result.length).toBe(exampleLearningPath.nodes.length); + expect(new Set(result.map((it) => it.key))).toEqual(new Set(exampleLearningPath.nodes.map((it) => it.learningObjectHruid))); + + expect(result.map((it) => it.title)).toContainEqual(EXPECTED_TITLE_FROM_DWENGO_LEARNING_OBJECT); + }); + it('should throw an error if queried with a path identifier for which there is no learning path', async () => { + await expect( + (async () => { + await databaseLearningObjectProvider.getLearningObjectsFromPath({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + })() + ).rejects.toThrowError(); + }); + }); +}); diff --git a/backend/tests/services/learning-objects/learning-object-service.test.ts b/backend/tests/services/learning-objects/learning-object-service.test.ts new file mode 100644 index 00000000..d80262df --- /dev/null +++ b/backend/tests/services/learning-objects/learning-object-service.test.ts @@ -0,0 +1,126 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import learningObjectService from '../../../src/services/learning-objects/learning-object-service'; +import { LearningObjectIdentifier, LearningPathIdentifier } from '../../../src/interfaces/learning-content'; +import { Language } from '../../../src/entities/content/language'; +import { EnvVars, getEnvVar } from '../../../src/util/envvars'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; + +const EXPECTED_DWENGO_LEARNING_OBJECT_TITLE = 'Werken met notebooks'; +const DWENGO_TEST_LEARNING_OBJECT_ID: LearningObjectIdentifier = { + hruid: 'pn_werkingnotebooks', + language: Language.Dutch, + version: 3, +}; + +const DWENGO_TEST_LEARNING_PATH_ID: LearningPathIdentifier = { + hruid: 'pn_werking', + language: Language.Dutch, +}; +const DWENGO_TEST_LEARNING_PATH_HRUIDS = new Set(['pn_werkingnotebooks', 'pn_werkingnotebooks2', 'pn_werkingnotebooks3']); + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +describe('LearningObjectService', () => { + let exampleLearningObject: LearningObject; + let exampleLearningPath: LearningPath; + + beforeAll(async () => { + await setupTestApp(); + const exampleData = await initExampleData(); + exampleLearningObject = exampleData.learningObject; + exampleLearningPath = exampleData.learningPath; + }); + + describe('getLearningObjectById', () => { + it('returns the learning object from the Dwengo API if it does not have the user content prefix', async () => { + const result = await learningObjectService.getLearningObjectById(DWENGO_TEST_LEARNING_OBJECT_ID); + expect(result).not.toBeNull(); + expect(result?.title).toBe(EXPECTED_DWENGO_LEARNING_OBJECT_TITLE); + }); + it('returns the learning object from the database if it does have the user content prefix', async () => { + const result = await learningObjectService.getLearningObjectById(exampleLearningObject); + expect(result).not.toBeNull(); + expect(result?.title).toBe(exampleLearningObject.title); + }); + it('returns null if the hruid does not have the user content prefix and does not exist in the Dwengo repo', async () => { + const result = await learningObjectService.getLearningObjectById({ + hruid: 'non-existing', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + + describe('getLearningObjectHTML', () => { + it('returns the expected HTML when queried with the identifier of a learning object saved in the database', async () => { + const result = await learningObjectService.getLearningObjectHTML(exampleLearningObject); + expect(result).not.toBeNull(); + expect(result).toEqual(learningObjectExample.getHTMLRendering()); + }); + it( + 'returns the same HTML as the Dwengo API when queried with the identifier of a learning object that does ' + + 'not start with the user content prefix', + async () => { + const result = await learningObjectService.getLearningObjectHTML(DWENGO_TEST_LEARNING_OBJECT_ID); + expect(result).not.toBeNull(); + + const responseFromDwengoApi = await fetch( + getEnvVar(EnvVars.LearningContentRepoApiBaseUrl) + + `/learningObject/getRaw?hruid=${DWENGO_TEST_LEARNING_OBJECT_ID.hruid}&language=${DWENGO_TEST_LEARNING_OBJECT_ID.language}&version=${DWENGO_TEST_LEARNING_OBJECT_ID.version}` + ); + const responseHtml = await responseFromDwengoApi.text(); + expect(result).toEqual(responseHtml); + } + ); + it('returns null when queried with a non-existing identifier', async () => { + const result = await learningObjectService.getLearningObjectHTML({ + hruid: 'non_existing_hruid', + language: Language.Dutch, + }); + expect(result).toBeNull(); + }); + }); + + describe('getLearningObjectsFromPath', () => { + it('returns all learning objects when a learning path in the database is queried', async () => { + const result = await learningObjectService.getLearningObjectsFromPath(exampleLearningPath); + expect(result.map((it) => it.key)).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); + }); + it('also returns all learning objects when a learning path from the Dwengo API is queried', async () => { + const result = await learningObjectService.getLearningObjectsFromPath(DWENGO_TEST_LEARNING_PATH_ID); + expect(new Set(result.map((it) => it.key))).toEqual(DWENGO_TEST_LEARNING_PATH_HRUIDS); + }); + it('returns an empty list when queried with a non-existing learning path id', async () => { + const result = await learningObjectService.getLearningObjectsFromPath({ hruid: 'non_existing', language: Language.Dutch }); + expect(result).toEqual([]); + }); + }); + + describe('getLearningObjectIdsFromPath', () => { + it('returns all learning objects when a learning path in the database is queried', async () => { + const result = await learningObjectService.getLearningObjectIdsFromPath(exampleLearningPath); + expect(result).toEqual(exampleLearningPath.nodes.map((it) => it.learningObjectHruid)); + }); + it('also returns all learning object hruids when a learning path from the Dwengo API is queried', async () => { + const result = await learningObjectService.getLearningObjectIdsFromPath(DWENGO_TEST_LEARNING_PATH_ID); + expect(new Set(result)).toEqual(DWENGO_TEST_LEARNING_PATH_HRUIDS); + }); + it('returns an empty list when queried with a non-existing learning path id', async () => { + const result = await learningObjectService.getLearningObjectIdsFromPath({ hruid: 'non_existing', language: Language.Dutch }); + expect(result).toEqual([]); + }); + }); +}); diff --git a/backend/tests/services/learning-objects/processing/processing-service.test.ts b/backend/tests/services/learning-objects/processing/processing-service.test.ts new file mode 100644 index 00000000..27714317 --- /dev/null +++ b/backend/tests/services/learning-objects/processing/processing-service.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import mdExample from '../../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import multipleChoiceExample from '../../../test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example'; +import essayExample from '../../../test-assets/learning-objects/test-essay/test-essay-example'; +import processingService from '../../../../src/services/learning-objects/processing/processing-service'; + +describe('ProcessingService', () => { + it('renders a markdown learning object correctly', async () => { + const markdownLearningObject = mdExample.createLearningObject(); + const result = await processingService.render(markdownLearningObject); + expect(result).toEqual(mdExample.getHTMLRendering()); + }); + + it('renders a multiple choice question correctly', async () => { + const multipleChoiceLearningObject = multipleChoiceExample.createLearningObject(); + const result = await processingService.render(multipleChoiceLearningObject); + expect(result).toEqual(multipleChoiceExample.getHTMLRendering()); + }); + + it('renders an essay question correctly', async () => { + const essayLearningObject = essayExample.createLearningObject(); + const result = await processingService.render(essayLearningObject); + expect(result).toEqual(essayExample.getHTMLRendering()); + }); +}); diff --git a/backend/tests/services/learning-path/database-learning-path-provider.test.ts b/backend/tests/services/learning-path/database-learning-path-provider.test.ts new file mode 100644 index 00000000..7c7ecdae --- /dev/null +++ b/backend/tests/services/learning-path/database-learning-path-provider.test.ts @@ -0,0 +1,220 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity.js'; +import { setupTestApp } from '../../setup-tests.js'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity.js'; +import { + getLearningObjectRepository, + getLearningPathRepository, + getStudentRepository, + getSubmissionRepository, +} from '../../../src/data/repositories.js'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.js'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example.js'; +import databaseLearningPathProvider from '../../../src/services/learning-paths/database-learning-path-provider.js'; +import { expectToBeCorrectLearningPath } from '../../test-utils/expectations.js'; +import { LearningObjectRepository } from '../../../src/data/content/learning-object-repository.js'; +import learningObjectService from '../../../src/services/learning-objects/learning-object-service.js'; +import { Language } from '../../../src/entities/content/language.js'; +import { + ConditionTestLearningPathAndLearningObjects, + createConditionTestLearningPathAndLearningObjects, +} from '../../test-assets/learning-paths/test-conditions-example.js'; +import { Student } from '../../../src/entities/users/student.entity.js'; +import { LearningObjectNode, LearningPathResponse } from '../../../src/interfaces/learning-content.js'; + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +async function initPersonalizationTestData(): Promise<{ + learningContent: ConditionTestLearningPathAndLearningObjects; + studentA: Student; + studentB: Student; +}> { + const studentRepo = getStudentRepository(); + const submissionRepo = getSubmissionRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObjectRepo = getLearningObjectRepository(); + const learningContent = createConditionTestLearningPathAndLearningObjects(); + await learningObjectRepo.save(learningContent.branchingObject); + await learningObjectRepo.save(learningContent.finalObject); + await learningObjectRepo.save(learningContent.extraExerciseObject); + await learningPathRepo.save(learningContent.learningPath); + + console.log(await getSubmissionRepository().findAll({})); + + const studentA = studentRepo.create({ + username: 'student_a', + firstName: 'Aron', + lastName: 'Student', + }); + await studentRepo.save(studentA); + const submissionA = submissionRepo.create({ + learningObjectHruid: learningContent.branchingObject.hruid, + learningObjectLanguage: learningContent.branchingObject.language, + learningObjectVersion: learningContent.branchingObject.version, + submissionNumber: 0, + submitter: studentA, + submissionTime: new Date(), + content: '[0]', + }); + await submissionRepo.save(submissionA); + + const studentB = studentRepo.create({ + username: 'student_b', + firstName: 'Bill', + lastName: 'Student', + }); + await studentRepo.save(studentB); + const submissionB = submissionRepo.create({ + learningObjectHruid: learningContent.branchingObject.hruid, + learningObjectLanguage: learningContent.branchingObject.language, + learningObjectVersion: learningContent.branchingObject.version, + submissionNumber: 1, + submitter: studentB, + submissionTime: new Date(), + content: '[1]', + }); + await submissionRepo.save(submissionB); + + return { + learningContent: learningContent, + studentA: studentA, + studentB: studentB, + }; +} + +function expectBranchingObjectNode( + result: LearningPathResponse, + persTestData: { + learningContent: ConditionTestLearningPathAndLearningObjects; + studentA: Student; + studentB: Student; + } +): LearningObjectNode { + const branchingObjectMatches = result.data![0].nodes.filter( + (it) => it.learningobject_hruid === persTestData.learningContent.branchingObject.hruid + ); + expect(branchingObjectMatches.length).toBe(1); + return branchingObjectMatches[0]; +} + +describe('DatabaseLearningPathProvider', () => { + let learningObjectRepo: LearningObjectRepository; + let example: { learningObject: LearningObject; learningPath: LearningPath }; + let persTestData: { learningContent: ConditionTestLearningPathAndLearningObjects; studentA: Student; studentB: Student }; + + beforeAll(async () => { + await setupTestApp(); + example = await initExampleData(); + persTestData = await initPersonalizationTestData(); + learningObjectRepo = getLearningObjectRepository(); + }); + + describe('fetchLearningPaths', () => { + it('returns the learning path correctly', async () => { + const result = await databaseLearningPathProvider.fetchLearningPaths( + [example.learningPath.hruid], + example.learningPath.language, + 'the source' + ); + expect(result.success).toBe(true); + expect(result.data?.length).toBe(1); + + const learningObjectsOnPath = ( + await Promise.all( + example.learningPath.nodes.map((node) => + learningObjectService.getLearningObjectById({ + hruid: node.learningObjectHruid, + version: node.version, + language: node.language, + }) + ) + ) + ).filter((it) => it !== null); + + expectToBeCorrectLearningPath(result.data![0], example.learningPath, learningObjectsOnPath); + }); + it('returns the correct personalized learning path', async () => { + // For student A: + let result = await databaseLearningPathProvider.fetchLearningPaths( + [persTestData.learningContent.learningPath.hruid], + persTestData.learningContent.learningPath.language, + 'the source', + { type: 'student', student: persTestData.studentA } + ); + expect(result.success).toBeTruthy(); + expect(result.data?.length).toBe(1); + + // There should be exactly one branching object + let branchingObject = expectBranchingObjectNode(result, persTestData); + + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.finalObject.hruid).length).toBe(0); // StudentA picked the first option, therefore, there should be no direct path to the final object. + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.extraExerciseObject.hruid).length).toBe( + 1 + ); // There should however be a path to the extra exercise object. + + // For student B: + result = await databaseLearningPathProvider.fetchLearningPaths( + [persTestData.learningContent.learningPath.hruid], + persTestData.learningContent.learningPath.language, + 'the source', + { type: 'student', student: persTestData.studentB } + ); + expect(result.success).toBeTruthy(); + expect(result.data?.length).toBe(1); + + // There should still be exactly one branching object + branchingObject = expectBranchingObjectNode(result, persTestData); + + // However, now the student picks the other option. + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.finalObject.hruid).length).toBe(1); // StudentB picked the second option, therefore, there should be a direct path to the final object. + expect(branchingObject.transitions.filter((it) => it.next.hruid === persTestData.learningContent.extraExerciseObject.hruid).length).toBe( + 0 + ); // There should not be a path anymore to the extra exercise object. + }); + it('returns a non-successful response if a non-existing learning path is queried', async () => { + const result = await databaseLearningPathProvider.fetchLearningPaths( + [example.learningPath.hruid], + Language.Abkhazian, // Wrong language + 'the source' + ); + + expect(result.success).toBe(false); + }); + }); + + describe('searchLearningPaths', () => { + it('returns the correct learning path when queried with a substring of its title', async () => { + const result = await databaseLearningPathProvider.searchLearningPaths( + example.learningPath.title.substring(2, 6), + example.learningPath.language + ); + expect(result.length).toBe(1); + expect(result[0].title).toBe(example.learningPath.title); + expect(result[0].description).toBe(example.learningPath.description); + }); + it('returns the correct learning path when queried with a substring of the description', async () => { + const result = await databaseLearningPathProvider.searchLearningPaths( + example.learningPath.description.substring(5, 12), + example.learningPath.language + ); + expect(result.length).toBe(1); + expect(result[0].title).toBe(example.learningPath.title); + expect(result[0].description).toBe(example.learningPath.description); + }); + it('returns an empty result when queried with a text which is not a substring of the title or the description of a learning path', async () => { + const result = await databaseLearningPathProvider.searchLearningPaths( + 'substring which does not occur in the title or the description of a learning object', + example.learningPath.language + ); + expect(result.length).toBe(0); + }); + }); +}); diff --git a/backend/tests/services/learning-path/learning-path-service.test.ts b/backend/tests/services/learning-path/learning-path-service.test.ts new file mode 100644 index 00000000..2a8906df --- /dev/null +++ b/backend/tests/services/learning-path/learning-path-service.test.ts @@ -0,0 +1,81 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { setupTestApp } from '../../setup-tests'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { getLearningObjectRepository, getLearningPathRepository } from '../../../src/data/repositories'; +import learningObjectExample from '../../test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example'; +import learningPathExample from '../../test-assets/learning-paths/pn-werking-example'; +import { Language } from '../../../src/entities/content/language'; +import learningPathService from '../../../src/services/learning-paths/learning-path-service'; + +async function initExampleData(): Promise<{ learningObject: LearningObject; learningPath: LearningPath }> { + const learningObjectRepo = getLearningObjectRepository(); + const learningPathRepo = getLearningPathRepository(); + const learningObject = learningObjectExample.createLearningObject(); + const learningPath = learningPathExample.createLearningPath(); + await learningObjectRepo.save(learningObject); + await learningPathRepo.save(learningPath); + return { learningObject, learningPath }; +} + +const TEST_DWENGO_LEARNING_PATH_HRUID = 'pn_werking'; +const TEST_DWENGO_LEARNING_PATH_TITLE = 'Werken met notebooks'; +const TEST_DWENGO_EXCLUSIVE_LEARNING_PATH_SEARCH_QUERY = 'Microscopie'; +const TEST_SEARCH_QUERY_EXPECTING_NO_MATCHES = 'su$m8f9usf89ud { + let example: { learningObject: LearningObject; learningPath: LearningPath }; + beforeAll(async () => { + await setupTestApp(); + example = await initExampleData(); + }); + describe('fetchLearningPaths', () => { + it('should return learning paths both from the database and from the Dwengo API', async () => { + const result = await learningPathService.fetchLearningPaths( + [example.learningPath.hruid, TEST_DWENGO_LEARNING_PATH_HRUID], + example.learningPath.language, + 'the source' + ); + expect(result.success).toBeTruthy(); + expect(result.data?.filter((it) => it.hruid === TEST_DWENGO_LEARNING_PATH_HRUID).length).not.toBe(0); + expect(result.data?.filter((it) => it.hruid === example.learningPath.hruid).length).not.toBe(0); + expect(result.data?.filter((it) => it.hruid === TEST_DWENGO_LEARNING_PATH_HRUID)[0].title).toEqual(TEST_DWENGO_LEARNING_PATH_TITLE); + expect(result.data?.filter((it) => it.hruid === example.learningPath.hruid)[0].title).toEqual(example.learningPath.title); + }); + it('should include both the learning objects from the Dwengo API and learning objects from the database in its response', async () => { + const result = await learningPathService.fetchLearningPaths([example.learningPath.hruid], example.learningPath.language, 'the source'); + expect(result.success).toBeTruthy(); + expect(result.data?.length).toBe(1); + + // Should include all the nodes, even those pointing to foreign learning objects. + expect([...result.data![0].nodes.map((it) => it.learningobject_hruid)].sort()).toEqual( + example.learningPath.nodes.map((it) => it.learningObjectHruid).sort() + ); + }); + }); + describe('searchLearningPath', () => { + it('should include both the learning paths from the Dwengo API and those from the database in its response', async () => { + // This matches the learning object in the database, but definitely also some learning objects in the Dwengo API. + const result = await learningPathService.searchLearningPaths(example.learningPath.title.substring(2, 3), example.learningPath.language); + + // Should find the one from the database + expect(result.filter((it) => it.hruid === example.learningPath.hruid && it.title === example.learningPath.title).length).toBe(1); + + // But should not only find that one. + expect(result.length).not.toBeLessThan(2); + }); + it('should still return results from the Dwengo API even though there are no matches in the database', async () => { + const result = await learningPathService.searchLearningPaths(TEST_DWENGO_EXCLUSIVE_LEARNING_PATH_SEARCH_QUERY, Language.Dutch); + + // Should find something... + expect(result.length).not.toBe(0); + + // But not the example learning path. + expect(result.filter((it) => it.hruid === example.learningPath.hruid && it.title === example.learningPath.title).length).toBe(0); + }); + it('should return an empty list if neither the Dwengo API nor the database contains matches', async () => { + const result = await learningPathService.searchLearningPaths(TEST_SEARCH_QUERY_EXPECTING_NO_MATCHES, Language.Dutch); + expect(result.length).toBe(0); + }); + }); +}); diff --git a/backend/tests/setup-tests.ts b/backend/tests/setup-tests.ts new file mode 100644 index 00000000..9502bcb8 --- /dev/null +++ b/backend/tests/setup-tests.ts @@ -0,0 +1,59 @@ +import { forkEntityManager, initORM } from '../src/orm.js'; +import dotenv from 'dotenv'; +import { makeTestStudents } from './test_assets/users/students.testdata.js'; +import { makeTestTeachers } from './test_assets/users/teachers.testdata.js'; +import { makeTestLearningObjects } from './test_assets/content/learning-objects.testdata.js'; +import { makeTestLearningPaths } from './test_assets/content/learning-paths.testdata.js'; +import { makeTestClasses } from './test_assets/classes/classes.testdata.js'; +import { makeTestAssignemnts } from './test_assets/assignments/assignments.testdata.js'; +import { makeTestGroups } from './test_assets/assignments/groups.testdata.js'; +import { makeTestTeacherInvitations } from './test_assets/classes/teacher-invitations.testdata.js'; +import { makeTestClassJoinRequests } from './test_assets/classes/class-join-requests.testdata.js'; +import { makeTestAttachments } from './test_assets/content/attachments.testdata.js'; +import { makeTestQuestions } from './test_assets/questions/questions.testdata.js'; +import { makeTestAnswers } from './test_assets/questions/answers.testdata.js'; +import { makeTestSubmissions } from './test_assets/assignments/submission.testdata.js'; + +export async function setupTestApp() { + dotenv.config({ path: '.env.test' }); + await initORM(true); + + const em = forkEntityManager(); + + const students = makeTestStudents(em); + const teachers = makeTestTeachers(em); + const learningObjects = makeTestLearningObjects(em); + const learningPaths = makeTestLearningPaths(em); + const classes = makeTestClasses(em, students, teachers); + const assignments = makeTestAssignemnts(em, classes); + const groups = makeTestGroups(em, students, assignments); + + assignments[0].groups = groups.slice(0, 3); + assignments[1].groups = groups.slice(3, 4); + + const teacherInvitations = makeTestTeacherInvitations(em, teachers, classes); + const classJoinRequests = makeTestClassJoinRequests(em, students, classes); + const attachments = makeTestAttachments(em, learningObjects); + + learningObjects[1].attachments = attachments; + + const questions = makeTestQuestions(em, students); + const answers = makeTestAnswers(em, teachers, questions); + const submissions = makeTestSubmissions(em, students, groups); + + await em.persistAndFlush([ + ...students, + ...teachers, + ...learningObjects, + ...learningPaths, + ...classes, + ...assignments, + ...groups, + ...teacherInvitations, + ...classJoinRequests, + ...attachments, + ...questions, + ...answers, + ...submissions, + ]); +} diff --git a/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts b/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts new file mode 100644 index 00000000..9bd0b4c3 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/create-example-learning-object-with-attachments.ts @@ -0,0 +1,10 @@ +import { LearningObjectExample } from './learning-object-example'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; + +export function createExampleLearningObjectWithAttachments(example: LearningObjectExample): LearningObject { + const learningObject = example.createLearningObject(); + for (const creationFn of Object.values(example.createAttachment)) { + learningObject.attachments.push(creationFn(learningObject)); + } + return learningObject; +} diff --git a/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts b/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts new file mode 100644 index 00000000..2f2e78ad --- /dev/null +++ b/backend/tests/test-assets/learning-objects/dummy/dummy-learning-object-example.ts @@ -0,0 +1,32 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; +import { Language } from '../../../../src/entities/content/language'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; + +/** + * Create a dummy learning object to be used in tests where multiple learning objects are needed (for example for use + * on a path), but where the precise contents of the learning object are not important. + */ +export function dummyLearningObject(hruid: string, language: Language, title: string): LearningObjectExample { + return { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = getEnvVar(EnvVars.UserContentPrefix) + hruid; + learningObject.language = language; + learningObject.version = 1; + learningObject.title = title; + learningObject.description = 'Just a dummy learning object for testing purposes'; + learningObject.contentType = DwengoContentType.TEXT_PLAIN; + learningObject.content = Buffer.from('Dummy content'); + learningObject.returnValue = { + callbackUrl: `/learningObject/${hruid}/submissions`, + callbackSchema: '[]', + }; + return learningObject; + }, + createAttachment: {}, + getHTMLRendering: () => loadTestAsset('learning-objects/dummy/rendering.txt').toString(), + }; +} diff --git a/backend/tests/test-assets/learning-objects/dummy/rendering.txt b/backend/tests/test-assets/learning-objects/dummy/rendering.txt new file mode 100644 index 00000000..f3ae8006 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/dummy/rendering.txt @@ -0,0 +1 @@ +Dummy content diff --git a/backend/tests/test-assets/learning-objects/learning-object-example.d.ts b/backend/tests/test-assets/learning-objects/learning-object-example.d.ts new file mode 100644 index 00000000..1d4009f8 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/learning-object-example.d.ts @@ -0,0 +1,8 @@ +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { Attachment } from '../../../src/entities/content/attachment.entity'; + +type LearningObjectExample = { + createLearningObject: () => LearningObject; + createAttachment: { [key: string]: (owner: LearningObject) => Attachment }; + getHTMLRendering: () => string; +}; diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/Knop.png b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/Knop.png new file mode 100644 index 00000000..34920e91 Binary files /dev/null and b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/Knop.png differ diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/content.md b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/content.md new file mode 100644 index 00000000..f469e46c --- /dev/null +++ b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/content.md @@ -0,0 +1,26 @@ +# Werken met notebooks + +Het lesmateriaal van 'Python in wiskunde en STEM' wordt aangeboden in de vorm van interactieve **_notebooks_**. Notebooks zijn _digitale documenten_ die zowel uitvoerbare code bevatten als tekst, afbeeldingen, video, hyperlinks ... + +_Nieuwe begrippen_ worden aangebracht via tekstuele uitleg, video en afbeeldingen. + +Er zijn uitgewerkte _voorbeelden_ met daarnaast ook kleine en grote _opdrachten_. In deze opdrachten zal je aangereikte code kunnen uitvoeren, maar ook zelf code opstellen. + +De code die in de notebooks gebruikt wordt, is Python versie 3. We kozen voor Python omdat dit een heel toegankelijke programmeertaal is, die vaak ook intuïtief is. +Python is bovendien bezig aan een opmars en wordt gebruikt door bedrijven, zoals Google, NASA, Netflix, Uber, AstraZeneca, Barco, Instagram en YouTube. + +We kozen voor notebooks omdat daar enkele belangrijke voordelen aan verbonden zijn: leerkrachten moeten geen geavanceerde installaties doen om de notebooks te gebruiken, leerkrachten kunnen verschillende soorten van lesinhouden aanbieden via één platform, de notebooks zijn interactief, leerlingen bouwen de oplossing van een probleem stap voor stap op in de notebook waardoor dat proces zichtbaar is voor de leerkracht ([Jeroen Van der Hooft, 2023](https://libstore.ugent.be/fulltxt/RUG01/003/151/437/RUG01-003151437_2023_0001_AC.pdf)). + +--- + +Klik je op onderstaande knop 'Open notebooks', dan word je doorgestuurd naar een andere website waar jouw persoonlijke notebooks ingeladen worden. (Dit kan even duren.) + +Links op het scherm vind je er twee bestanden met extensie _.ipynb_. +Dit zijn de twee notebooks waarin je resp. een overzicht krijgt van de opbouw en mogelijkheden en hoe je er mee aan de slag kan. Dubbelklik op de bestandsnaam om een notebook te openen. + +Je ziet er ook een map _images_ met de afbeeldingen die in de notebooks getoond worden. + +In deze eerste twee notebooks leer je hoe de notebooks zijn opgevat en hoe je ermee aan de slag kan. +Na het doorlopen van beide notebooks heb je een goed idee van hoe onze Python notebooks zijn opgevat. + +[![](Knop.png 'Knop')](https://kiks.ilabt.imec.be/hub/tmplogin?id=0101 'Notebooks Werking') diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/dwengo.png b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/dwengo.png new file mode 100644 index 00000000..b03fcd08 Binary files /dev/null and b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/dwengo.png differ diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts new file mode 100644 index 00000000..600a4305 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/pn-werkingnotebooks-example.ts @@ -0,0 +1,72 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { Language } from '../../../../src/entities/content/language'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { EducationalGoal, LearningObject, ReturnValue } from '../../../../src/entities/content/learning-object.entity'; +import { Attachment } from '../../../../src/entities/content/attachment.entity'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; + +const ASSETS_PREFIX = 'learning-objects/pn-werkingnotebooks/'; + +const example: LearningObjectExample = { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}pn_werkingnotebooks`; + learningObject.version = 3; + learningObject.language = Language.Dutch; + learningObject.title = 'Werken met notebooks'; + learningObject.description = 'Leren werken met notebooks'; + learningObject.keywords = ['Python', 'KIKS', 'Wiskunde', 'STEM', 'AI']; + + const educationalGoal1 = new EducationalGoal(); + educationalGoal1.source = 'Source'; + educationalGoal1.id = 'id'; + + const educationalGoal2 = new EducationalGoal(); + educationalGoal2.source = 'Source2'; + educationalGoal2.id = 'id2'; + + learningObject.educationalGoals = [educationalGoal1, educationalGoal2]; + learningObject.admins = []; + learningObject.contentType = DwengoContentType.TEXT_MARKDOWN; + learningObject.teacherExclusive = false; + learningObject.skosConcepts = [ + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-vaktaal', + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-digitale-media-en-toepassingen', + 'http://ilearn.ilabt.imec.be/vocab/curr1/s-computers-en-systemen', + ]; + learningObject.copyright = 'dwengo'; + learningObject.license = 'dwengo'; + learningObject.estimatedTime = 10; + + const returnValue = new ReturnValue(); + returnValue.callbackUrl = 'callback_url_example'; + returnValue.callbackSchema = '{"att": "test", "att2": "test2"}'; + + learningObject.returnValue = returnValue; + learningObject.available = true; + learningObject.content = loadTestAsset(`${ASSETS_PREFIX}/content.md`); + + return learningObject; + }, + createAttachment: { + dwengoLogo: (learningObject) => { + const att = new Attachment(); + att.learningObject = learningObject; + att.name = 'dwengo.png'; + att.mimeType = 'image/png'; + att.content = loadTestAsset(`${ASSETS_PREFIX}/dwengo.png`); + return att; + }, + knop: (learningObject) => { + const att = new Attachment(); + att.learningObject = learningObject; + att.name = 'Knop.png'; + att.mimeType = 'image/png'; + att.content = loadTestAsset(`${ASSETS_PREFIX}/Knop.png`); + return att; + }, + }, + getHTMLRendering: () => loadTestAsset(`${ASSETS_PREFIX}/rendering.txt`).toString(), +}; +export default example; diff --git a/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/rendering.txt b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/rendering.txt new file mode 100644 index 00000000..af596243 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/pn-werkingnotebooks/rendering.txt @@ -0,0 +1,20 @@ +

+ + + + Werken met notebooks +

+

Het lesmateriaal van 'Python in wiskunde en STEM' wordt aangeboden in de vorm van interactieve notebooks. Notebooks zijn digitale documenten die zowel uitvoerbare code bevatten als tekst, afbeeldingen, video, hyperlinks ...

+

Nieuwe begrippen worden aangebracht via tekstuele uitleg, video en afbeeldingen.

+

Er zijn uitgewerkte voorbeelden met daarnaast ook kleine en grote opdrachten. In deze opdrachten zal je aangereikte code kunnen uitvoeren, maar ook zelf code opstellen.

+

De code die in de notebooks gebruikt wordt, is Python versie 3. We kozen voor Python omdat dit een heel toegankelijke programmeertaal is, die vaak ook intuC/tief is. +Python is bovendien bezig aan een opmars en wordt gebruikt door bedrijven, zoals Google, NASA, Netflix, Uber, AstraZeneca, Barco, Instagram en YouTube.

+

We kozen voor notebooks omdat daar enkele belangrijke voordelen aan verbonden zijn: leerkrachten moeten geen geavanceerde installaties doen om de notebooks te gebruiken, leerkrachten kunnen verschillende soorten van lesinhouden aanbieden via C)C)n platform, de notebooks zijn interactief, leerlingen bouwen de oplossing van een probleem stap voor stap op in de notebook waardoor dat proces zichtbaar is voor de leerkracht (Jeroen Van der Hooft, 2023).

+
+

Klik je op onderstaande knop 'Open notebooks', dan word je doorgestuurd naar een andere website waar jouw persoonlijke notebooks ingeladen worden. (Dit kan even duren.)

+

Links op het scherm vind je er twee bestanden met extensie .ipynb. +Dit zijn de twee notebooks waarin je resp. een overzicht krijgt van de opbouw en mogelijkheden en hoe je er mee aan de slag kan. Dubbelklik op de bestandsnaam om een notebook te openen.

+

Je ziet er ook een map images met de afbeeldingen die in de notebooks getoond worden.

+

In deze eerste twee notebooks leer je hoe de notebooks zijn opgevat en hoe je ermee aan de slag kan. +Na het doorlopen van beide notebooks heb je een goed idee van hoe onze Python notebooks zijn opgevat.

+

diff --git a/backend/tests/test-assets/learning-objects/test-essay/content.txt b/backend/tests/test-assets/learning-objects/test-essay/content.txt new file mode 100644 index 00000000..dd6b5c77 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-essay/content.txt @@ -0,0 +1,2 @@ +::MC basic:: +How are you? {} diff --git a/backend/tests/test-assets/learning-objects/test-essay/rendering.txt b/backend/tests/test-assets/learning-objects/test-essay/rendering.txt new file mode 100644 index 00000000..adb072a0 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-essay/rendering.txt @@ -0,0 +1,7 @@ +
+
+

MC basic

+

How are you?

+ +
+
diff --git a/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts b/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts new file mode 100644 index 00000000..d57c7a33 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-essay/test-essay-example.ts @@ -0,0 +1,28 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; +import { Language } from '../../../../src/entities/content/language'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; + +const example: LearningObjectExample = { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}test_essay`; + learningObject.language = Language.English; + learningObject.version = 1; + learningObject.title = 'Essay question for testing'; + learningObject.description = 'This essay question was only created for testing purposes.'; + learningObject.contentType = DwengoContentType.GIFT; + learningObject.returnValue = { + callbackUrl: `/learningObject/${learningObject.hruid}/submissions`, + callbackSchema: '["antwoord vraag 1"]', + }; + learningObject.content = loadTestAsset('learning-objects/test-essay/content.txt'); + return learningObject; + }, + createAttachment: {}, + getHTMLRendering: () => loadTestAsset('learning-objects/test-essay/rendering.txt').toString(), +}; + +export default example; diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt b/backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt new file mode 100644 index 00000000..7dd5527d --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-multiple-choice/content.txt @@ -0,0 +1,5 @@ +::MC basic:: +Are you following along well with the class? { + ~No, it's very difficult to follow along. + =Yes, no problem! +} diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt b/backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt new file mode 100644 index 00000000..c1829f24 --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-multiple-choice/rendering.txt @@ -0,0 +1,14 @@ +
+
+

MC basic

+

Are you following along well with the class?

+
+ + +
+
+ + +
+
+
diff --git a/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts b/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts new file mode 100644 index 00000000..a634878a --- /dev/null +++ b/backend/tests/test-assets/learning-objects/test-multiple-choice/test-multiple-choice-example.ts @@ -0,0 +1,28 @@ +import { LearningObjectExample } from '../learning-object-example'; +import { LearningObject } from '../../../../src/entities/content/learning-object.entity'; +import { loadTestAsset } from '../../../test-utils/load-test-asset'; +import { EnvVars, getEnvVar } from '../../../../src/util/envvars'; +import { Language } from '../../../../src/entities/content/language'; +import { DwengoContentType } from '../../../../src/services/learning-objects/processing/content-type'; + +const example: LearningObjectExample = { + createLearningObject: () => { + const learningObject = new LearningObject(); + learningObject.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}test_multiple_choice`; + learningObject.language = Language.English; + learningObject.version = 1; + learningObject.title = 'Multiple choice question for testing'; + learningObject.description = 'This multiple choice question was only created for testing purposes.'; + learningObject.contentType = DwengoContentType.GIFT; + learningObject.returnValue = { + callbackUrl: `/learningObject/${learningObject.hruid}/submissions`, + callbackSchema: '["antwoord vraag 1"]', + }; + learningObject.content = loadTestAsset('learning-objects/test-multiple-choice/content.txt'); + return learningObject; + }, + createAttachment: {}, + getHTMLRendering: () => loadTestAsset('learning-objects/test-multiple-choice/rendering.txt').toString(), +}; + +export default example; diff --git a/backend/tests/test-assets/learning-paths/learning-path-example.d.ts b/backend/tests/test-assets/learning-paths/learning-path-example.d.ts new file mode 100644 index 00000000..9df3ba48 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/learning-path-example.d.ts @@ -0,0 +1,3 @@ +type LearningPathExample = { + createLearningPath: () => LearningPath; +}; diff --git a/backend/tests/test-assets/learning-paths/learning-path-utils.ts b/backend/tests/test-assets/learning-paths/learning-path-utils.ts new file mode 100644 index 00000000..c567de66 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/learning-path-utils.ts @@ -0,0 +1,31 @@ +import { Language } from '../../../src/entities/content/language'; +import { LearningPathTransition } from '../../../src/entities/content/learning-path-transition.entity'; +import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; + +export function createLearningPathTransition(node: LearningPathNode, transitionNumber: number, condition: string | null, to: LearningPathNode) { + const trans = new LearningPathTransition(); + trans.node = node; + trans.transitionNumber = transitionNumber; + trans.condition = condition || 'true'; + trans.next = to; + return trans; +} + +export function createLearningPathNode( + learningPath: LearningPath, + nodeNumber: number, + learningObjectHruid: string, + version: number, + language: Language, + startNode: boolean +) { + const node = new LearningPathNode(); + node.learningPath = learningPath; + node.nodeNumber = nodeNumber; + node.learningObjectHruid = learningObjectHruid; + node.version = version; + node.language = language; + node.startNode = startNode; + return node; +} diff --git a/backend/tests/test-assets/learning-paths/pn-werking-example.ts b/backend/tests/test-assets/learning-paths/pn-werking-example.ts new file mode 100644 index 00000000..810b4da5 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/pn-werking-example.ts @@ -0,0 +1,30 @@ +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { Language } from '../../../src/entities/content/language'; +import { EnvVars, getEnvVar } from '../../../src/util/envvars'; +import { createLearningPathNode, createLearningPathTransition } from './learning-path-utils'; +import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; + +function createNodes(learningPath: LearningPath): LearningPathNode[] { + const nodes = [ + createLearningPathNode(learningPath, 0, 'u_pn_werkingnotebooks', 3, Language.Dutch, true), + createLearningPathNode(learningPath, 1, 'pn_werkingnotebooks2', 3, Language.Dutch, false), + createLearningPathNode(learningPath, 2, 'pn_werkingnotebooks3', 3, Language.Dutch, false), + ]; + nodes[0].transitions.push(createLearningPathTransition(nodes[0], 0, 'true', nodes[1])); + nodes[1].transitions.push(createLearningPathTransition(nodes[1], 0, 'true', nodes[2])); + return nodes; +} + +const example: LearningPathExample = { + createLearningPath: () => { + const path = new LearningPath(); + path.language = Language.Dutch; + path.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}pn_werking`; + path.title = 'Werken met notebooks'; + path.description = 'Een korte inleiding tot Python notebooks. Hoe ga je gemakkelijk en efficiënt met de notebooks aan de slag?'; + path.nodes = createNodes(path); + return path; + }, +}; + +export default example; diff --git a/backend/tests/test-assets/learning-paths/test-conditions-example.ts b/backend/tests/test-assets/learning-paths/test-conditions-example.ts new file mode 100644 index 00000000..07857235 --- /dev/null +++ b/backend/tests/test-assets/learning-paths/test-conditions-example.ts @@ -0,0 +1,84 @@ +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { Language } from '../../../src/entities/content/language'; +import testMultipleChoiceExample from '../learning-objects/test-multiple-choice/test-multiple-choice-example'; +import { dummyLearningObject } from '../learning-objects/dummy/dummy-learning-object-example'; +import { createLearningPathNode, createLearningPathTransition } from './learning-path-utils'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; +import { EnvVars, getEnvVar } from '../../../src/util/envvars'; + +export type ConditionTestLearningPathAndLearningObjects = { + branchingObject: LearningObject; + extraExerciseObject: LearningObject; + finalObject: LearningObject; + learningPath: LearningPath; +}; + +export function createConditionTestLearningPathAndLearningObjects() { + const learningPath = new LearningPath(); + learningPath.hruid = `${getEnvVar(EnvVars.UserContentPrefix)}test_conditions`; + learningPath.language = Language.English; + learningPath.title = 'Example learning path with conditional transitions'; + learningPath.description = 'This learning path was made for the purpose of testing conditional transitions'; + + const branchingLearningObject = testMultipleChoiceExample.createLearningObject(); + const extraExerciseLearningObject = dummyLearningObject( + 'test_extra_exercise', + Language.English, + 'Extra exercise (for students with difficulties)' + ).createLearningObject(); + const finalLearningObject = dummyLearningObject( + 'test_final_learning_object', + Language.English, + 'Final exercise (for everyone)' + ).createLearningObject(); + + const branchingNode = createLearningPathNode( + learningPath, + 0, + branchingLearningObject.hruid, + branchingLearningObject.version, + branchingLearningObject.language, + true + ); + const extraExerciseNode = createLearningPathNode( + learningPath, + 1, + extraExerciseLearningObject.hruid, + extraExerciseLearningObject.version, + extraExerciseLearningObject.language, + false + ); + const finalNode = createLearningPathNode( + learningPath, + 2, + finalLearningObject.hruid, + finalLearningObject.version, + finalLearningObject.language, + false + ); + + const transitionToExtraExercise = createLearningPathTransition( + branchingNode, + 0, + '$[?(@[0] == 0)]', // The answer to the first question was the first one, which says that it is difficult for the student to follow along. + extraExerciseNode + ); + const directTransitionToFinal = createLearningPathTransition(branchingNode, 1, '$[?(@[0] == 1)]', finalNode); + const transitionExtraExerciseToFinal = createLearningPathTransition(extraExerciseNode, 0, 'true', finalNode); + + branchingNode.transitions = [transitionToExtraExercise, directTransitionToFinal]; + extraExerciseNode.transitions = [transitionExtraExerciseToFinal]; + + learningPath.nodes = [branchingNode, extraExerciseNode, finalNode]; + + return { + branchingObject: branchingLearningObject, + finalObject: finalLearningObject, + extraExerciseObject: extraExerciseLearningObject, + learningPath: learningPath, + }; +} + +const example: LearningPathExample = { + createLearningPath: () => createConditionTestLearningPathAndLearningObjects().learningPath, +}; diff --git a/backend/tests/test-utils/expectations.ts b/backend/tests/test-utils/expectations.ts new file mode 100644 index 00000000..0fe63811 --- /dev/null +++ b/backend/tests/test-utils/expectations.ts @@ -0,0 +1,150 @@ +import { AssertionError } from 'node:assert'; +import { LearningObject } from '../../src/entities/content/learning-object.entity'; +import { FilteredLearningObject, LearningPath } from '../../src/interfaces/learning-content'; +import { LearningPath as LearningPathEntity } from '../../src/entities/content/learning-path.entity'; +import { expect } from 'vitest'; + +// Ignored properties because they belang for example to the class, not to the entity itself. +const IGNORE_PROPERTIES = ['parent']; + +/** + * Checks if the actual entity from the database conforms to the entity that was added previously. + * @param actual The actual entity retrieved from the database + * @param expected The (previously added) entity we would expect to retrieve + */ +export function expectToBeCorrectEntity(actual: { entity: T; name?: string }, expected: { entity: T; name?: string }): void { + if (!actual.name) { + actual.name = 'actual'; + } + if (!expected.name) { + expected.name = 'expected'; + } + for (const property in expected.entity) { + if ( + property! in IGNORE_PROPERTIES && + expected.entity[property] !== undefined && // If we don't expect a certain value for a property, we assume it can be filled in by the database however it wants. + typeof expected.entity[property] !== 'function' // Functions obviously are not persisted via the database + ) { + if (!actual.entity.hasOwnProperty(property)) { + throw new AssertionError({ + message: `${expected.name} has defined property ${property}, but ${actual.name} is missing it.`, + }); + } + if (typeof expected.entity[property] === 'boolean') { + // Sometimes, booleans get represented by numbers 0 and 1 in the objects actual from the database. + if (Boolean(expected.entity[property]) !== Boolean(actual.entity[property])) { + throw new AssertionError({ + message: `${property} was ${expected.entity[property]} in ${expected.name}, + but ${actual.entity[property]} (${Boolean(expected.entity[property])}) in ${actual.name}`, + }); + } + } else if (typeof expected.entity[property] !== typeof actual.entity[property]) { + throw new AssertionError({ + message: `${property} has type ${typeof expected.entity[property]} in ${expected.name}, but type ${typeof actual.entity[property]} in ${actual.name}.`, + }); + } else if (typeof expected.entity[property] === 'object') { + expectToBeCorrectEntity( + { + name: actual.name + '.' + property, + entity: actual.entity[property] as object, + }, + { + name: expected.name + '.' + property, + entity: expected.entity[property] as object, + } + ); + } else { + if (expected.entity[property] !== actual.entity[property]) { + throw new AssertionError({ + message: `${property} was ${expected.entity[property]} in ${expected.name}, but ${actual.entity[property]} in ${actual.name}`, + }); + } + } + } + } +} + +/** + * Checks that filtered is the correct representation of original as FilteredLearningObject. + * @param filtered the representation as FilteredLearningObject + * @param original the original entity added to the database + */ +export function expectToBeCorrectFilteredLearningObject(filtered: FilteredLearningObject, original: LearningObject) { + expect(filtered.uuid).toEqual(original.uuid); + expect(filtered.version).toEqual(original.version); + expect(filtered.language).toEqual(original.language); + expect(filtered.keywords).toEqual(original.keywords); + expect(filtered.key).toEqual(original.hruid); + expect(filtered.targetAges).toEqual(original.targetAges); + expect(filtered.title).toEqual(original.title); + expect(Boolean(filtered.teacherExclusive)).toEqual(Boolean(original.teacherExclusive)); + expect(filtered.skosConcepts).toEqual(original.skosConcepts); + expect(filtered.estimatedTime).toEqual(original.estimatedTime); + expect(filtered.educationalGoals).toEqual(original.educationalGoals); + expect(filtered.difficulty).toEqual(original.difficulty || 1); + expect(filtered.description).toEqual(original.description); + expect(filtered.returnValue?.callback_url).toEqual(original.returnValue.callbackUrl); + expect(filtered.returnValue?.callback_schema).toEqual(JSON.parse(original.returnValue.callbackSchema)); + expect(filtered.contentType).toEqual(original.contentType); + expect(filtered.contentLocation || null).toEqual(original.contentLocation || null); + expect(filtered.htmlUrl).toContain(`/${original.hruid}/html`); + expect(filtered.htmlUrl).toContain(`language=${original.language}`); + expect(filtered.htmlUrl).toContain(`version=${original.version}`); +} + +/** + * Check that a learning path returned by a LearningPathRetriever, the LearningPathService or an API endpoint + * is a correct representation of the given learning path entity. + * + * @param learningPath The learning path returned by the retriever, service or endpoint + * @param expectedEntity The expected entity + * @param learningObjectsOnPath The learning objects on LearningPath. Necessary since some information in + * the learning path returned from the API endpoint + */ +export function expectToBeCorrectLearningPath( + learningPath: LearningPath, + expectedEntity: LearningPathEntity, + learningObjectsOnPath: FilteredLearningObject[] +) { + expect(learningPath.hruid).toEqual(expectedEntity.hruid); + expect(learningPath.language).toEqual(expectedEntity.language); + expect(learningPath.description).toEqual(expectedEntity.description); + expect(learningPath.title).toEqual(expectedEntity.title); + + const keywords = new Set(learningObjectsOnPath.flatMap((it) => it.keywords || [])); + expect(new Set(learningPath.keywords.split(' '))).toEqual(keywords); + + const targetAges = new Set(learningObjectsOnPath.flatMap((it) => it.targetAges || [])); + expect(new Set(learningPath.target_ages)).toEqual(targetAges); + expect(learningPath.min_age).toEqual(Math.min(...targetAges)); + expect(learningPath.max_age).toEqual(Math.max(...targetAges)); + + expect(learningPath.num_nodes).toEqual(expectedEntity.nodes.length); + expect(learningPath.image || null).toEqual(expectedEntity.image); + + const expectedLearningPathNodes = new Map( + expectedEntity.nodes.map((node) => [ + { learningObjectHruid: node.learningObjectHruid, language: node.language, version: node.version }, + { startNode: node.startNode, transitions: node.transitions }, + ]) + ); + + for (const node of learningPath.nodes) { + const nodeKey = { + learningObjectHruid: node.learningobject_hruid, + language: node.language, + version: node.version, + }; + expect(expectedLearningPathNodes.keys()).toContainEqual(nodeKey); + const expectedNode = [...expectedLearningPathNodes.entries()].filter( + ([key, _]) => key.learningObjectHruid === nodeKey.learningObjectHruid && key.language === node.language && key.version === node.version + )[0][1]; + expect(node.start_node).toEqual(expectedNode?.startNode); + + expect(new Set(node.transitions.map((it) => it.next.hruid))).toEqual( + new Set(expectedNode.transitions.map((it) => it.next.learningObjectHruid)) + ); + expect(new Set(node.transitions.map((it) => it.next.language))).toEqual(new Set(expectedNode.transitions.map((it) => it.next.language))); + expect(new Set(node.transitions.map((it) => it.next.version))).toEqual(new Set(expectedNode.transitions.map((it) => it.next.version))); + } +} diff --git a/backend/tests/test-utils/load-test-asset.ts b/backend/tests/test-utils/load-test-asset.ts new file mode 100644 index 00000000..35f6cdbf --- /dev/null +++ b/backend/tests/test-utils/load-test-asset.ts @@ -0,0 +1,10 @@ +import fs from 'fs'; +import path from 'node:path'; + +/** + * Load the asset at the given path. + * @param relPath Path of the asset relative to the test-assets folder. + */ +export function loadTestAsset(relPath: string): Buffer { + return fs.readFileSync(path.resolve(__dirname, `../test-assets/${relPath}`)); +} diff --git a/backend/tests/test_assets/assignments/assignments.testdata.ts b/backend/tests/test_assets/assignments/assignments.testdata.ts new file mode 100644 index 00000000..7f909de4 --- /dev/null +++ b/backend/tests/test_assets/assignments/assignments.testdata.ts @@ -0,0 +1,38 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Assignment } from '../../../src/entities/assignments/assignment.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { Language } from '../../../src/entities/content/language'; + +export function makeTestAssignemnts(em: EntityManager>, classes: Array): Array { + const assignment01 = em.create(Assignment, { + within: classes[0], + id: 1, + title: 'dire straits', + description: 'reading', + learningPathHruid: 'id02', + learningPathLanguage: Language.English, + groups: [], + }); + + const assignment02 = em.create(Assignment, { + within: classes[1], + id: 2, + title: 'tool', + description: 'reading', + learningPathHruid: 'id01', + learningPathLanguage: Language.English, + groups: [], + }); + + const assignment03 = em.create(Assignment, { + within: classes[0], + id: 3, + title: 'delete', + description: 'will be deleted', + learningPathHruid: 'id02', + learningPathLanguage: Language.English, + groups: [], + }); + + return [assignment01, assignment02, assignment03]; +} diff --git a/backend/tests/test_assets/assignments/groups.testdata.ts b/backend/tests/test_assets/assignments/groups.testdata.ts new file mode 100644 index 00000000..0e9ef201 --- /dev/null +++ b/backend/tests/test_assets/assignments/groups.testdata.ts @@ -0,0 +1,36 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Group } from '../../../src/entities/assignments/group.entity'; +import { Assignment } from '../../../src/entities/assignments/assignment.entity'; +import { Student } from '../../../src/entities/users/student.entity'; + +export function makeTestGroups( + em: EntityManager>, + students: Array, + assignments: Array +): Array { + const group01 = em.create(Group, { + assignment: assignments[0], + groupNumber: 1, + members: students.slice(0, 2), + }); + + const group02 = em.create(Group, { + assignment: assignments[0], + groupNumber: 2, + members: students.slice(2, 4), + }); + + const group03 = em.create(Group, { + assignment: assignments[0], + groupNumber: 3, + members: students.slice(4, 6), + }); + + const group04 = em.create(Group, { + assignment: assignments[1], + groupNumber: 4, + members: students.slice(3, 4), + }); + + return [group01, group02, group03, group04]; +} diff --git a/backend/tests/test_assets/assignments/submission.testdata.ts b/backend/tests/test_assets/assignments/submission.testdata.ts new file mode 100644 index 00000000..95dd65df --- /dev/null +++ b/backend/tests/test_assets/assignments/submission.testdata.ts @@ -0,0 +1,65 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Submission } from '../../../src/entities/assignments/submission.entity'; +import { Language } from '../../../src/entities/content/language'; +import { Student } from '../../../src/entities/users/student.entity'; +import { Group } from '../../../src/entities/assignments/group.entity'; + +export function makeTestSubmissions( + em: EntityManager>, + students: Array, + groups: Array +): Array { + const submission01 = em.create(Submission, { + learningObjectHruid: 'id03', + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + submissionNumber: 1, + submitter: students[0], + submissionTime: new Date(2025, 2, 20), + onBehalfOf: groups[0], + content: 'sub1', + }); + + const submission02 = em.create(Submission, { + learningObjectHruid: 'id03', + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + submissionNumber: 2, + submitter: students[0], + submissionTime: new Date(2025, 2, 25), + onBehalfOf: groups[0], + content: '', + }); + + const submission03 = em.create(Submission, { + learningObjectHruid: 'id02', + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + submissionNumber: 1, + submitter: students[0], + submissionTime: new Date(2025, 2, 20), + content: '', + }); + + const submission04 = em.create(Submission, { + learningObjectHruid: 'id02', + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + submissionNumber: 2, + submitter: students[0], + submissionTime: new Date(2025, 2, 25), + content: '', + }); + + const submission05 = em.create(Submission, { + learningObjectHruid: 'id01', + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + submissionNumber: 1, + submitter: students[1], + submissionTime: new Date(2025, 2, 20), + content: '', + }); + + return [submission01, submission02, submission03, submission04, submission05]; +} diff --git a/backend/tests/test_assets/classes/class-join-requests.testdata.ts b/backend/tests/test_assets/classes/class-join-requests.testdata.ts new file mode 100644 index 00000000..8d9e328f --- /dev/null +++ b/backend/tests/test_assets/classes/class-join-requests.testdata.ts @@ -0,0 +1,36 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { ClassJoinRequest, ClassJoinRequestStatus } from '../../../src/entities/classes/class-join-request.entity'; +import { Student } from '../../../src/entities/users/student.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; + +export function makeTestClassJoinRequests( + em: EntityManager>, + students: Array, + classes: Array +): Array { + const classJoinRequest01 = em.create(ClassJoinRequest, { + requester: students[4], + class: classes[1], + status: ClassJoinRequestStatus.Open, + }); + + const classJoinRequest02 = em.create(ClassJoinRequest, { + requester: students[2], + class: classes[1], + status: ClassJoinRequestStatus.Open, + }); + + const classJoinRequest03 = em.create(ClassJoinRequest, { + requester: students[4], + class: classes[2], + status: ClassJoinRequestStatus.Open, + }); + + const classJoinRequest04 = em.create(ClassJoinRequest, { + requester: students[3], + class: classes[2], + status: ClassJoinRequestStatus.Open, + }); + + return [classJoinRequest01, classJoinRequest02, classJoinRequest03, classJoinRequest04]; +} diff --git a/backend/tests/test_assets/classes/classes.testdata.ts b/backend/tests/test_assets/classes/classes.testdata.ts new file mode 100644 index 00000000..b3e98bc8 --- /dev/null +++ b/backend/tests/test_assets/classes/classes.testdata.ts @@ -0,0 +1,48 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Class } from '../../../src/entities/classes/class.entity'; +import { Student } from '../../../src/entities/users/student.entity'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; + +export function makeTestClasses(em: EntityManager>, students: Array, teachers: Array): Array { + const studentsClass01 = students.slice(0, 7); + const teacherClass01: Array = teachers.slice(0, 1); + + const class01 = em.create(Class, { + classId: 'id01', + displayName: 'class01', + teachers: teacherClass01, + students: studentsClass01, + }); + + const studentsClass02: Array = students.slice(0, 2).concat(students.slice(3, 4)); + const teacherClass02: Array = teachers.slice(1, 2); + + const class02 = em.create(Class, { + classId: 'id02', + displayName: 'class02', + teachers: teacherClass02, + students: studentsClass02, + }); + + const studentsClass03: Array = students.slice(1, 4); + const teacherClass03: Array = teachers.slice(2, 3); + + const class03 = em.create(Class, { + classId: 'id03', + displayName: 'class03', + teachers: teacherClass03, + students: studentsClass03, + }); + + const studentsClass04: Array = students.slice(0, 2); + const teacherClass04: Array = teachers.slice(2, 3); + + const class04 = em.create(Class, { + classId: 'id04', + displayName: 'class04', + teachers: teacherClass04, + students: studentsClass04, + }); + + return [class01, class02, class03, class04]; +} diff --git a/backend/tests/test_assets/classes/teacher-invitations.testdata.ts b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts new file mode 100644 index 00000000..84eeab01 --- /dev/null +++ b/backend/tests/test_assets/classes/teacher-invitations.testdata.ts @@ -0,0 +1,36 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { TeacherInvitation } from '../../../src/entities/classes/teacher-invitation.entity'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; +import { Class } from '../../../src/entities/classes/class.entity'; + +export function makeTestTeacherInvitations( + em: EntityManager>, + teachers: Array, + classes: Array +): Array { + const teacherInvitation01 = em.create(TeacherInvitation, { + sender: teachers[1], + receiver: teachers[0], + class: classes[1], + }); + + const teacherInvitation02 = em.create(TeacherInvitation, { + sender: teachers[1], + receiver: teachers[2], + class: classes[1], + }); + + const teacherInvitation03 = em.create(TeacherInvitation, { + sender: teachers[2], + receiver: teachers[0], + class: classes[2], + }); + + const teacherInvitation04 = em.create(TeacherInvitation, { + sender: teachers[0], + receiver: teachers[1], + class: classes[0], + }); + + return [teacherInvitation01, teacherInvitation02, teacherInvitation03, teacherInvitation04]; +} diff --git a/backend/tests/test_assets/content/attachments.testdata.ts b/backend/tests/test_assets/content/attachments.testdata.ts new file mode 100644 index 00000000..9f690d9c --- /dev/null +++ b/backend/tests/test_assets/content/attachments.testdata.ts @@ -0,0 +1,14 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Attachment } from '../../../src/entities/content/attachment.entity'; +import { LearningObject } from '../../../src/entities/content/learning-object.entity'; + +export function makeTestAttachments(em: EntityManager>, learningObjects: Array): Array { + const attachment01 = em.create(Attachment, { + learningObject: learningObjects[1], + name: 'attachment01', + mimeType: '', + content: Buffer.from(''), + }); + + return [attachment01]; +} diff --git a/backend/tests/test_assets/content/learning-objects.testdata.ts b/backend/tests/test_assets/content/learning-objects.testdata.ts new file mode 100644 index 00000000..17ed4f01 --- /dev/null +++ b/backend/tests/test_assets/content/learning-objects.testdata.ts @@ -0,0 +1,134 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { LearningObject, ReturnValue } from '../../../src/entities/content/learning-object.entity'; +import { Language } from '../../../src/entities/content/language'; +import { DwengoContentType } from '../../../src/services/learning-objects/processing/content-type'; + +export function makeTestLearningObjects(em: EntityManager>): Array { + const returnValue: ReturnValue = new ReturnValue(); + returnValue.callbackSchema = ''; + returnValue.callbackUrl = ''; + + const learningObject01 = em.create(LearningObject, { + hruid: 'id01', + language: Language.English, + version: 1, + admins: [], + title: 'Undertow', + description: 'debute', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 45, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from("there's a shadow just behind me, shrouding every step i take, making every promise empty pointing every finger at me"), + }); + + const learningObject02 = em.create(LearningObject, { + hruid: 'id02', + language: Language.English, + version: 1, + admins: [], + title: 'Aenema', + description: 'second album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 80, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + "I've been crawling on my belly clearing out what could've been I've been wallowing in my own confused and insecure delusions" + ), + }); + + const learningObject03 = em.create(LearningObject, { + hruid: 'id03', + language: Language.English, + version: 1, + admins: [], + title: 'love over gold', + description: 'third album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + 'he wrote me a prescription, he said you are depressed, \ + but I am glad you came to see me to get this off your chest, \ + come back and see me later next patient please \ + send in another victim of industrial disease' + ), + }); + + const learningObject04 = em.create(LearningObject, { + hruid: 'id04', + language: Language.English, + version: 1, + admins: [], + title: 'making movies', + description: 'fifth album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from( + 'I put my hand upon the lever \ + Said let it rock and let it roll \ + I had the one-arm bandit fever \ + There was an arrow through my heart and my soul' + ), + }); + + const learningObject05 = em.create(LearningObject, { + hruid: 'id05', + language: Language.English, + version: 1, + admins: [], + title: 'on every street', + description: 'sixth album', + contentType: DwengoContentType.TEXT_MARKDOWN, + keywords: [], + teacherExclusive: false, + skosConcepts: [], + educationalGoals: [], + copyright: '', + license: '', + estimatedTime: 55, + returnValue: returnValue, + available: true, + contentLocation: '', + attachments: [], + content: Buffer.from('calling Elvis, is anybody home, calling elvis, I am here all alone'), + }); + + return [learningObject01, learningObject02, learningObject03, learningObject04, learningObject05]; +} diff --git a/backend/tests/test_assets/content/learning-paths.testdata.ts b/backend/tests/test_assets/content/learning-paths.testdata.ts new file mode 100644 index 00000000..10de885c --- /dev/null +++ b/backend/tests/test_assets/content/learning-paths.testdata.ts @@ -0,0 +1,100 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { LearningPath } from '../../../src/entities/content/learning-path.entity'; +import { Language } from '../../../src/entities/content/language'; +import { LearningPathTransition } from '../../../src/entities/content/learning-path-transition.entity'; +import { LearningPathNode } from '../../../src/entities/content/learning-path-node.entity'; + +export function makeTestLearningPaths(em: EntityManager>): Array { + const learningPathNode01: LearningPathNode = new LearningPathNode(); + const learningPathNode02: LearningPathNode = new LearningPathNode(); + const learningPathNode03: LearningPathNode = new LearningPathNode(); + const learningPathNode04: LearningPathNode = new LearningPathNode(); + const learningPathNode05: LearningPathNode = new LearningPathNode(); + + const transitions01: LearningPathTransition = new LearningPathTransition(); + const transitions02: LearningPathTransition = new LearningPathTransition(); + const transitions03: LearningPathTransition = new LearningPathTransition(); + const transitions04: LearningPathTransition = new LearningPathTransition(); + const transitions05: LearningPathTransition = new LearningPathTransition(); + + transitions01.condition = 'true'; + transitions01.next = learningPathNode02; + + transitions02.condition = 'true'; + transitions02.next = learningPathNode02; + + transitions03.condition = 'true'; + transitions03.next = learningPathNode04; + + transitions04.condition = 'true'; + transitions04.next = learningPathNode05; + + transitions05.condition = 'true'; + transitions05.next = learningPathNode05; + + learningPathNode01.instruction = ''; + learningPathNode01.language = Language.English; + learningPathNode01.learningObjectHruid = 'id01'; + learningPathNode01.startNode = true; + learningPathNode01.transitions = [transitions01]; + learningPathNode01.version = 1; + + learningPathNode02.instruction = ''; + learningPathNode02.language = Language.English; + learningPathNode02.learningObjectHruid = 'id02'; + learningPathNode02.startNode = false; + learningPathNode02.transitions = [transitions02]; + learningPathNode02.version = 1; + + learningPathNode03.instruction = ''; + learningPathNode03.language = Language.English; + learningPathNode03.learningObjectHruid = 'id03'; + learningPathNode03.startNode = true; + learningPathNode03.transitions = [transitions03]; + learningPathNode03.version = 1; + + learningPathNode04.instruction = ''; + learningPathNode04.language = Language.English; + learningPathNode04.learningObjectHruid = 'id04'; + learningPathNode04.startNode = false; + learningPathNode04.transitions = [transitions04]; + learningPathNode04.version = 1; + + learningPathNode05.instruction = ''; + learningPathNode05.language = Language.English; + learningPathNode05.learningObjectHruid = 'id05'; + learningPathNode05.startNode = false; + learningPathNode05.transitions = [transitions05]; + learningPathNode05.version = 1; + + const nodes01: Array = [ + // LearningPathNode01, + // LearningPathNode02, + ]; + const learningPath01 = em.create(LearningPath, { + hruid: 'id01', + language: Language.English, + admins: [], + title: 'repertoire Tool', + description: 'all about Tool', + image: null, + nodes: nodes01, + }); + + const nodes02: Array = [ + // LearningPathNode03, + // LearningPathNode04, + // LearningPathNode05, + ]; + const learningPath02 = em.create(LearningPath, { + hruid: 'id02', + language: Language.English, + admins: [], + title: 'repertoire Dire Straits', + description: 'all about Dire Straits', + image: null, + nodes: nodes02, + }); + + return [learningPath01, learningPath02]; +} diff --git a/backend/tests/test_assets/questions/answers.testdata.ts b/backend/tests/test_assets/questions/answers.testdata.ts new file mode 100644 index 00000000..20e816da --- /dev/null +++ b/backend/tests/test_assets/questions/answers.testdata.ts @@ -0,0 +1,32 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Answer } from '../../../src/entities/questions/answer.entity'; +import { Teacher } from '../../../src/entities/users/teacher.entity'; +import { Question } from '../../../src/entities/questions/question.entity'; + +export function makeTestAnswers(em: EntityManager>, teachers: Array, questions: Array): Array { + const answer01 = em.create(Answer, { + author: teachers[0], + toQuestion: questions[1], + sequenceNumber: 1, + timestamp: new Date(), + content: 'answer', + }); + + const answer02 = em.create(Answer, { + author: teachers[0], + toQuestion: questions[1], + sequenceNumber: 2, + timestamp: new Date(), + content: 'answer2', + }); + + const answer03 = em.create(Answer, { + author: teachers[1], + toQuestion: questions[3], + sequenceNumber: 1, + timestamp: new Date(), + content: 'answer3', + }); + + return [answer01, answer02, answer03]; +} diff --git a/backend/tests/test_assets/questions/questions.testdata.ts b/backend/tests/test_assets/questions/questions.testdata.ts new file mode 100644 index 00000000..cea43e18 --- /dev/null +++ b/backend/tests/test_assets/questions/questions.testdata.ts @@ -0,0 +1,48 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Question } from '../../../src/entities/questions/question.entity'; +import { Language } from '../../../src/entities/content/language'; +import { Student } from '../../../src/entities/users/student.entity'; + +export function makeTestQuestions(em: EntityManager>, students: Array): Array { + const question01 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + learningObjectHruid: 'id05', + sequenceNumber: 1, + author: students[0], + timestamp: new Date(), + content: 'question', + }); + + const question02 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + learningObjectHruid: 'id05', + sequenceNumber: 2, + author: students[2], + timestamp: new Date(), + content: 'question', + }); + + const question03 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + learningObjectHruid: 'id04', + sequenceNumber: 1, + author: students[0], + timestamp: new Date(), + content: 'question', + }); + + const question04 = em.create(Question, { + learningObjectLanguage: Language.English, + learningObjectVersion: 1, + learningObjectHruid: 'id01', + sequenceNumber: 1, + author: students[1], + timestamp: new Date(), + content: 'question', + }); + + return [question01, question02, question03, question04]; +} diff --git a/backend/tests/test_assets/users/students.testdata.ts b/backend/tests/test_assets/users/students.testdata.ts new file mode 100644 index 00000000..61e0b590 --- /dev/null +++ b/backend/tests/test_assets/users/students.testdata.ts @@ -0,0 +1,49 @@ +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; +import { Student } from '../../../src/entities/users/student.entity'; + +export function makeTestStudents(em: EntityManager>): Array { + const student01 = em.create(Student, { + username: 'Noordkaap', + firstName: 'Stijn', + lastName: 'Meuris', + }); + + const student02 = em.create(Student, { + username: 'DireStraits', + firstName: 'Mark', + lastName: 'Knopfler', + }); + + const student03 = em.create(Student, { + username: 'Tool', + firstName: 'Maynard', + lastName: 'Keenan', + }); + + const student04 = em.create(Student, { + username: 'SmashingPumpkins', + firstName: 'Billy', + lastName: 'Corgan', + }); + + const student05 = em.create(Student, { + username: 'PinkFloyd', + firstName: 'David', + lastName: 'Gilmoure', + }); + + const student06 = em.create(Student, { + username: 'TheDoors', + firstName: 'Jim', + lastName: 'Morisson', + }); + + // Do not use for any tests, gets deleted in a unit test + const student07 = em.create(Student, { + username: 'Nirvana', + firstName: 'Kurt', + lastName: 'Cobain', + }); + + return [student01, student02, student03, student04, student05, student06, student07]; +} diff --git a/backend/tests/test_assets/users/teachers.testdata.ts b/backend/tests/test_assets/users/teachers.testdata.ts new file mode 100644 index 00000000..d8985e44 --- /dev/null +++ b/backend/tests/test_assets/users/teachers.testdata.ts @@ -0,0 +1,31 @@ +import { Teacher } from '../../../src/entities/users/teacher.entity'; +import { Connection, EntityManager, IDatabaseDriver } from '@mikro-orm/core'; + +export function makeTestTeachers(em: EntityManager>): Array { + const teacher01 = em.create(Teacher, { + username: 'FooFighters', + firstName: 'Dave', + lastName: 'Grohl', + }); + + const teacher02 = em.create(Teacher, { + username: 'LimpBizkit', + firstName: 'Fred', + lastName: 'Durst', + }); + + const teacher03 = em.create(Teacher, { + username: 'Staind', + firstName: 'Aaron', + lastName: 'Lewis', + }); + + // Should not be used, gets deleted in a unit test + const teacher04 = em.create(Teacher, { + username: 'ZesdeMetaal', + firstName: 'Wannes', + lastName: 'Cappelle', + }); + + return [teacher01, teacher02, teacher03, teacher04]; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 00000000..2dd3998d --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "resolveJsonModule": true + } +} diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 00000000..461d2018 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + testTimeout: 10000, + }, +}); diff --git a/compose.override.yml b/compose.override.yml new file mode 100644 index 00000000..5c35441e --- /dev/null +++ b/compose.override.yml @@ -0,0 +1,72 @@ +# +# Use this configuration to test the production configuration locally. +# +# This configuration builds the frontend and backend services as Docker images, +# and uses the paths for the services, instead of ports. +# +services: + web: + build: + context: . + dockerfile: frontend/Dockerfile + ports: + - '8080:8080/tcp' + restart: unless-stopped + labels: + - 'traefik.http.routers.web.rule=PathPrefix(`/`)' + - 'traefik.http.services.web.loadbalancer.server.port=8080' + + api: + build: + context: . + dockerfile: backend/Dockerfile + ports: + - '3000:3000/tcp' + restart: unless-stopped + volumes: + - ./backend/.env:/app/.env + depends_on: + - db + - logging + labels: + - 'traefik.http.routers.api.rule=PathPrefix(`/api`)' + - 'traefik.http.services.api.loadbalancer.server.port=3000' + + idp: + # Also see compose.yml + labels: + - 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)' + - 'traefik.http.services.idp.loadbalancer.server.port=7080' + environment: + PROXY_ADDRESS_FORWARDING: 'true' + KC_HTTP_RELATIVE_PATH: '/idp' + + reverse-proxy: + image: traefik:v3.3 + command: + # Enable web UI + - '--api.insecure=true' + + # Add Docker provider + - '--providers.docker=true' + - '--providers.docker.exposedbydefault=true' + + # Add web entrypoint + - '--entrypoints.web.address=:80/tcp' + ports: + - '9000:8080' + - '80:80/tcp' + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + + dashboards: + image: grafana/grafana:latest + ports: + - '9002:3000' + volumes: + - dwengo_grafana_data:/var/lib/grafana + restart: unless-stopped + +volumes: + dwengo_grafana_data: diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 00000000..8825796e --- /dev/null +++ b/compose.prod.yml @@ -0,0 +1,112 @@ +# +# This file is used to define the production environment for the project. +# It is used to deploy the project on a server. +# Should not be used for local development. +# +services: + web: + build: + context: . + dockerfile: frontend/Dockerfile + restart: unless-stopped + networks: + - dwengo-1 + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.web.rule=PathPrefix(`/`)' + - 'traefik.http.services.web.loadbalancer.server.port=8080' + + api: + build: + context: . + dockerfile: backend/Dockerfile + restart: unless-stopped + volumes: + # TODO Replace with environment keys + - ./backend/.env:/app/.env + depends_on: + - db + - logging + networks: + - dwengo-1 + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.api.rule=PathPrefix(`/api`)' + - 'traefik.http.services.api.loadbalancer.server.port=3000' + + db: + # Also see compose.yml + networks: + - dwengo-1 + + idp: + # Also see compose.yml + # TODO Replace with proper production command + command: ['start-dev', '--http-port', '7080', '--https-port', '7443', '--import-realm'] + networks: + - dwengo-1 + labels: + - 'traefik.enable=true' + - 'traefik.http.routers.idp.rule=PathPrefix(`/idp`)' + - 'traefik.http.services.idp.loadbalancer.server.port=7080' + env_file: + - ./config/idp/.env + environment: + KC_HOSTNAME: 'sel2-1.ugent.be' + PROXY_ADDRESS_FORWARDING: 'true' + KC_PROXY_HEADERS: 'xforwarded' + KC_HTTP_ENABLED: 'true' + KC_HTTP_RELATIVE_PATH: '/idp' + + reverse-proxy: + image: traefik:v3.3 + ports: + - '80:80/tcp' + - '443:443/tcp' + command: + # Add Docker provider + - '--providers.docker=true' + - '--providers.docker.exposedbydefault=false' + + # Add web entrypoint + - '--entrypoints.web.address=:80/tcp' + - '--entrypoints.web.http.redirections.entryPoint.to=websecure' + - '--entrypoints.web.http.redirections.entryPoint.scheme=https' + + # Add websecure entrypoint + - '--entrypoints.websecure.address=:443/tcp' + - '--entrypoints.websecure.http.tls=true' + - '--entrypoints.websecure.http.tls.certResolver=letsencrypt' + - '--entrypoints.websecure.http.tls.domains[0].main=sel2-1.ugent.be' + + # Certificates + - '--certificatesresolvers.letsencrypt.acme.httpchallenge=true' + - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web' + - '--certificatesresolvers.letsencrypt.acme.email=timo.demeyst@ugent.be' + - '--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json' + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - dwengo_letsencrypt:/letsencrypt + networks: + - dwengo-1 + + logging: + # Also see compose.yml + networks: + - dwengo-1 + + dashboards: + image: grafana/grafana:latest + ports: + - '9002:3000' + restart: unless-stopped + volumes: + - dwengo_grafana_data:/var/lib/grafana + +volumes: + dwengo_grafana_data: + dwengo_letsencrypt: + +networks: + dwengo-1: diff --git a/compose.yml b/compose.yml new file mode 100644 index 00000000..1276c1af --- /dev/null +++ b/compose.yml @@ -0,0 +1,52 @@ +# +# Use this configuration during development. +# +# This configuration is suitable to access the services using their ports. +# +services: + db: + image: postgres:latest + ports: + - '5431:5432' + restart: unless-stopped + volumes: + - dwengo_postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + + idp: # Based on: https://medium.com/@fingervinicius/easy-running-keycloak-with-docker-compose-b0d7a4ee2358 + image: quay.io/keycloak/keycloak:latest + ports: + - '7080:7080' + # - '7443:7443' + command: ['start-dev', '--http-port', '7080', '--https-port', '7443', '--import-realm'] + restart: unless-stopped + volumes: + - ./config/idp:/opt/keycloak/data/import + depends_on: + - db + environment: + KC_HOSTNAME: localhost + KC_HOSTNAME_PORT: 7080 + KC_HOSTNAME_STRICT_BACKCHANNEL: 'true' + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: 'true' + KC_LOG_LEVEL: info + + logging: + image: grafana/loki:latest + ports: + - '9001:3102' + - '9095:9095' + command: -config.file=/etc/loki/config.yaml + restart: unless-stopped + volumes: + - ./config/loki/config.yml:/etc/loki/config.yaml + - dwengo_loki_data:/loki + +volumes: + dwengo_loki_data: + dwengo_postgres_data: diff --git a/config/idp/README.md b/config/idp/README.md new file mode 100644 index 00000000..f67d0462 --- /dev/null +++ b/config/idp/README.md @@ -0,0 +1,9 @@ +# Testdata in de IDP + +De IDP in `docker-compose.yml` is zo geconfigureerd dat hij automatisch bij het starten een testconfiguratie inlaadt. Deze houdt in: + +- Een realm `student` die de IDP voor leerlingen representeert. + - Hierin de gebruiker met username `testleerling1`, wachtwoord `password`. +- Een realm `teacher` die de IDP voor leerkrachten representeert. + - Hierin de gebruiker met username `testleerkracht1`, wachtwoord `password`. +- De admin-account (in de realm `master`) heeft username `admin` en wachtwoord `admin`. diff --git a/config/idp/student-realm.json b/config/idp/student-realm.json new file mode 100644 index 00000000..32107e4e --- /dev/null +++ b/config/idp/student-realm.json @@ -0,0 +1,2355 @@ +{ + "id": "08a7ab0a-d483-4103-a781-76013864bf50", + "realm": "student", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "bruteForceStrategy": "MULTIPLE", + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "a0bb00f5-0b3a-4d57-a3fc-a3f93cbe3427", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "08a7ab0a-d483-4103-a781-76013864bf50", + "attributes": {} + }, + { + "id": "b3bf9566-098c-4167-9cce-f64c720ca511", + "name": "default-roles-student", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": ["offline_access", "uma_authorization"], + "client": { + "account": ["manage-account", "view-profile"] + } + }, + "clientRole": false, + "containerId": "08a7ab0a-d483-4103-a781-76013864bf50", + "attributes": {} + }, + { + "id": "6d044f54-8ff3-4223-9e8c-771882da7a3f", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "08a7ab0a-d483-4103-a781-76013864bf50", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "f125e557-2427-4eeb-95c5-b3dadf35f9c7", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "33c7285a-7308-4752-acad-1fe59bf1c81a", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "31fb3621-62c7-43c8-af98-a4add3470fcc", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "e077c3c3-d573-494f-9cf8-34eca6603fc6", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "manage-authorization", + "query-clients", + "manage-identity-providers", + "create-client", + "view-users", + "view-authorization", + "query-users", + "manage-users", + "view-identity-providers", + "impersonation", + "manage-realm", + "view-events", + "view-clients", + "manage-events", + "manage-clients", + "view-realm", + "query-groups", + "query-realms" + ] + } + }, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "8bbe59b1-7693-4274-bdde-c08f94ec3187", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "0533162d-7dac-4ebf-87a2-7f72dad79d53", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-groups", "query-users"] + } + }, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "d4b32078-67b4-4aa8-8ddf-01a820e7b64a", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "2a48ab18-b710-41e7-8b8c-67a5cd6af685", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "d71d575f-3f21-4f4a-b9e0-2628352aac8d", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "7d3cd659-4ddd-45cd-8186-210431a25bbd", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "3dbd18ca-11dc-463d-bf8e-e7d80928a90d", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "d4a6ef1e-bf84-4bd6-8763-1b0c9997c109", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "f0eab8d7-0570-44d3-94d0-2a43906d9f09", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "0a24b91f-ef4a-4f4b-a753-1286dd59df2b", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "b307485c-8840-4c39-ba81-fb840fa404d1", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "3719a5ed-be30-4d2c-93f5-cc6e6c0e792e", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "d4b13416-9f5e-42fb-bfdd-6489093922da", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "15ac861b-5440-4fe8-9f7d-857d75ec481d", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + }, + { + "id": "f05a8e4d-90ea-41f6-887b-0b6b1ecb9cd9", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "attributes": {} + } + ], + "dwengo": [], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "da1edd82-7479-4e9d-ad66-9a4cf739e828", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "befe3d72-8102-49a6-8268-bce6def58159", + "attributes": {} + } + ], + "account": [ + { + "id": "5a3da53d-235b-4d12-b8ec-1573b13ebafc", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "b3a22454-d780-4093-8333-9be6f6cd5855", + "attributes": {} + }, + { + "id": "cbc0c1d4-487b-488c-8566-1d4537212de8", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "b3a22454-d780-4093-8333-9be6f6cd5855", + "attributes": {} + }, + { + "id": "79b0ed8f-bf10-4b01-bb2c-e7a58d57c798", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "b3a22454-d780-4093-8333-9be6f6cd5855", + "attributes": {} + }, + { + "id": "b6aa748e-0fb0-4fa6-a0d1-3ea37c870467", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": ["manage-account-links"] + } + }, + "clientRole": true, + "containerId": "b3a22454-d780-4093-8333-9be6f6cd5855", + "attributes": {} + }, + { + "id": "ddaea6cd-ede8-49f7-9746-3a3a02fdeca5", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "b3a22454-d780-4093-8333-9be6f6cd5855", + "attributes": {} + }, + { + "id": "061b2038-b415-4a45-89ec-7141004c0151", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "b3a22454-d780-4093-8333-9be6f6cd5855", + "attributes": {} + }, + { + "id": "95972aa1-6666-421c-8596-a91eee54b0e8", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "b3a22454-d780-4093-8333-9be6f6cd5855", + "attributes": {} + }, + { + "id": "1cf27d94-d88d-42d3-b8f3-ede1f127ac45", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": ["view-consent"] + } + }, + "clientRole": true, + "containerId": "b3a22454-d780-4093-8333-9be6f6cd5855", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "b3bf9566-098c-4167-9cce-f64c720ca511", + "name": "default-roles-student", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "08a7ab0a-d483-4103-a781-76013864bf50" + }, + "requiredCredentials": ["password"], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": ["totpAppFreeOTPName", "totpAppGoogleName", "totpAppMicrosoftAuthenticatorName"], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": ["ES256", "RS256"], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256", "RS256"], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "79e9a395-d7e4-48c9-a06e-702435bae290", + "username": "testleerling1", + "firstName": "Gerald", + "lastName": "Schmittinger", + "email": "Gerald.Schmittinger@UGent.be", + "emailVerified": false, + "createdTimestamp": 1740858528405, + "enabled": true, + "totp": false, + "credentials": [ + { + "id": "c31a708f-8614-4144-a25f-3e976c9035ce", + "type": "password", + "userLabel": "My password", + "createdDate": 1740858548515, + "secretData": "{\"value\":\"yDKIAbZPuVXBGk4zjiqE/YFcPDm1vjXLwTrPUrvMhXY=\",\"salt\":\"tYvjd4mhV2UWeOUssK01Cw==\",\"additionalParameters\":{}}", + "credentialData": "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}" + } + ], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["default-roles-student"], + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": ["offline_access"] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": ["manage-account", "view-groups"] + } + ] + }, + "clients": [ + { + "id": "b3a22454-d780-4093-8333-9be6f6cd5855", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/student/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/student/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "854c221b-630c-4cc3-9365-bd254246dd69", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/student/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/student/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "f33b40fe-bb9e-4254-ada9-f98dd203641b", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "9449aa8b-d5cc-4b9f-bb01-be1e5a896f2f", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "befe3d72-8102-49a6-8268-bce6def58159", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "714243ae-72cc-4c26-842a-047357b5919a", + "clientId": "dwengo", + "name": "Dwengo", + "description": "", + "rootUrl": "http://localhost:5173", + "adminUrl": "http://localhost:5173", + "baseUrl": "/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-jwt", + "redirectUris": [ + "urn:ietf:wg:oauth:2.0:oob", + "http://localhost:5173/*", + "http://localhost:5173", + "http://localhost/*", + "http://localhost", + "https://sel2-1.ugent.be/*", + "https://sel2-1.ugent.be" + ], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1740860818", + "backchannel.logout.session.required": "true", + "token.endpoint.auth.signing.alg": "RS256", + "post.logout.redirect.uris": "+", + "frontchannel.logout.session.required": "true", + "oauth2.device.authorization.grant.enabled": "false", + "display.on.consent.screen": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "0b06aaa3-717d-4a52-ab46-295a6571b642", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "dfc7248c-3794-4e3b-aed2-3ee553cd0feb", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/student/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/admin/student/console/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "9e9ff295-30c9-43f1-a11a-773724709c07", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": ["web-origins", "acr", "profile", "roles", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + } + ], + "clientScopes": [ + { + "id": "0721b27a-284f-4e6d-af70-b6f190ebdcd4", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "d256bdc1-8983-41e0-b8fa-fcf45653045e", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "651c2415-db30-40ed-bdef-745b6ea744ed", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "573f6eea-7626-44fe-9855-50f15c3939ba", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "3489c748-3cc7-4350-9351-2955fc7084ba", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "00afe548-c677-4595-8478-16f752c2713a", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "1448ed2b-ec1d-4bf4-a8b7-00cb85459289", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "12d491b6-5d74-4168-ac5c-517ebc2f1de4", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "52223fb1-9651-4cdf-8317-a1301d4042f7", + "name": "organization", + "description": "Additional claims about the organization a subject belongs to", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${organizationScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "dccc4214-ece6-4235-8119-ee8cb954c29a", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "organization", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "8be22542-e327-4a25-8265-a34a29607d1b", + "name": "service_account", + "description": "Specific scope for a client enabled for service accounts", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "cf89064a-0af3-4a4b-a838-3528a8f4d780", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "dc0f77e6-cc20-4c0a-baf3-f45046d749d1", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + }, + { + "id": "d63fd29a-3613-4529-a8e4-3a7d7e9f5802", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "d9079603-62b7-4680-9d01-950daae75d6b", + "name": "saml_organization", + "description": "Organization Membership", + "protocol": "saml", + "attributes": { + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "d826fc58-b006-49ad-93dc-a76700e800df", + "name": "organization", + "protocol": "saml", + "protocolMapper": "saml-organization-membership-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "171d8267-87da-4a4b-9346-d901d470248b", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${phoneScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "f8bb18d4-af9d-49b0-a61f-cc81887870cd", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "88a2c658-9b61-40a2-abd5-69c501286031", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "ea3b84ac-a91f-4a3d-be4e-893e11eaf4a1", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "152d66d4-524f-47f1-a592-be3a0c043a4f", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "2fc1ad0d-1065-4196-8d1b-c61525c9425d", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "9d537486-f6bf-4856-91fc-ca3acaa78814", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "55425438-4111-47a0-9a36-fec9dbbc6a8a", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "0d186f4e-ef6d-4fbc-9593-081e0d5ad171", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "bb8bb550-2db6-4631-97dc-1d115d0e3034", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "c942089b-2898-4052-a64d-85b61e27aaa4", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "5ff3a9ca-7036-458c-b0dc-41216292d210", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "41f93d62-4074-4373-a270-9bdf1e298cb5", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "ffec7d63-0f78-41ea-8023-6c7c64661b34", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "4a514ae7-d29f-4979-8df9-a97b36a81a96", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "286e349b-cb9f-41b1-b9dc-d787f13e9d99", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "f5177603-55b1-4abe-aee6-b1e5a05e37f6", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "a31114d7-05fc-40c1-9ea8-6977f6f0bec5", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "8884be77-648d-4083-b0cf-57130162c8dc", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "61840434-c79f-455a-a914-117977197304", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "1f40ff0b-1664-4259-846b-ab707c76d33b", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "8534d400-8a81-4ae3-b51f-78b93e5a2045", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "82a0e240-0824-41b9-b6e8-856a72d1e930", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "a5cedc85-d9e9-42e1-9ea3-ff37d21d5e27", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "19009128-590f-4bc9-80de-c9ba4aae822d", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "3b6bb88b-c833-4bb5-9bd0-95831aa2ad0d", + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "ce925803-aec2-47cb-a3b9-4bef12c80367", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "58729b3a-3816-460e-bf2e-d0d2206c1830", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "auth_time", + "jsonType.label": "long" + } + } + ] + }, + { + "id": "7aa2d936-3edb-45e5-bae0-b4a618d06371", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "a9d1e8e2-ca10-4904-8a42-7708b0bfdefa", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "1f217073-ad43-483b-b0d5-f3ca4c74282f", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "61b0a069-8b67-4692-bcca-66a197b230eb", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + } + ], + "defaultDefaultClientScopes": ["role_list", "saml_organization", "profile", "email", "roles", "web-origins", "acr", "basic"], + "defaultOptionalClientScopes": ["offline_access", "address", "phone", "microprofile-jwt", "organization"], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "9eac5531-7f25-493f-a721-6c5e65cd34c2", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": ["200"] + } + }, + { + "id": "d9319a22-4c67-4b08-822f-4162a1ee01bc", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "21456c8e-7f6b-4e49-a3e1-bea7f900e2fb", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "4872e99b-b55b-4e13-8a93-63e853289cac", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "a118a194-09f5-435d-9d4b-363813413167", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": ["true"], + "client-uris-must-match": ["true"] + } + }, + { + "id": "e32b1e26-6571-4b0c-a205-0fbb3de44384", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "9dbe6752-9978-42a3-9210-9ec166140de2", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-address-mapper", + "saml-role-list-mapper", + "saml-user-property-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper", + "saml-user-attribute-mapper" + ] + } + }, + { + "id": "7027b3f4-d877-4814-ac78-f1edb8eb89b0", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-address-mapper", + "saml-user-property-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "819cfc66-a997-4747-9d90-a7f0c09774bf", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "kid": ["eb74df73-3f34-457d-95c7-5ad909107703"], + "secret": ["1K8IJiDODmotHJPStrXhtA"], + "priority": ["100"] + } + }, + { + "id": "299857cd-52a4-4981-8171-02e7d8f12960", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEpAIBAAKCAQEA1MRmAT/yImkVfPMBxC0QHdC4DQfuWUTjKeEku+gMI9jX5ChUzzzVugcvZWmxBNcvOz7p6R8EdPllZKIwFSH5WvQ1w1VIgQwIlYfpi/pknfftLd66MI2fXrQK19dRTeQRivEf39GTfBQ2Xc7y1q7zbMo5TVxATJ3DgPi13dYO7zVPpGTiQQeYiezlcBedyGe4cS1g6oBoaVif1QPY1Ni2vEjJhczNMGI408tIFws8G04Tlno814nT0ysdflUSGcRUku41NtfM9hr57LQ459sGYho8Pn11lDuiUWkomJv0y3GJ1wFBvQbDvI+6QvEdFu0GxShrlcORrNmj3BwOOLhB7wIDAQABAoIBAA6zrXq7oO8YxMfYANC97mWpBPa9jA42EN5VdNTZIXGeq7hTwxx4zynmEjPXPEih190nqUEXCBdPHl74SAtFyDWtN0PSkkp8euFePViTSj2SIpzvTX1KY+9G0JL+iVsw/bdUlwe/swm5WdJcmPIVr7NeO9xpGfZRVm+EgAieoHSN4Z7g20wLbVz1fya+6O5Hy+IGezamIA4tchk+4hyiVpSh2TcdjkJJZWOlHKPkwWU/MYQbJibuea5jLoWA39NIqV2l5GT0SoCbffGJNb9CMTTGmXoK5zNwHhG+M0a4eP1vbFDLaoDne86JySmTdv/WrTFFa3veelw2K8PHDybuB70CgYEA/gbxqLZYkJcEpqsjM/XcISFJ09icJLKl5r2l/Dm4Qq587QniQYribX/PPLfDhgVwPByQe3rccq9FoiILycTdIwgSMTsg5fzvbLJTqMAcl2r0zJgHVIDc6iXnytuE0FffKN0kSKL1C4d6n6vKoCGvOcZoXK5jxgzpY8lasvKxhCsCgYEA1mtr7CDYY3qPmTu4/Uz6cFgX8RDMZZ11AQQXNMsKHIu5C4xLeYmJMlpt0y4h52/NWRzh2svdw3SEZTCfP1WVC7StfP8KD8QdwVkQlY5EGkiz9uRtEgwk8chkOTIm2JedeRL6YWlTgnH9PIuGq84OOnEbFjVN3Lbx3N1QuQfVA00CgYEAybA1uuBcXSCqfrIuVxkD2AIYHe1DvBdjhVpaKXKii78CTSmlzKg6svnhTrIQuZ4jyHZdeMzJrvzeaqZheaemdCP6XcA2lKRIbKMBrWAq00YGa1LhrwRJYlcKPJQiVVEPS+CY6FsJ+Edu4suBK7bS6ypOvhdv/FVQEPxT2PS8YNUCgYEAxwJ+8XNuw63ud9+Zi+gVjY4F8qWPwESLYz0DuOk2YlZAknpNVumTYBvUUSxBJYh8RFhtO+D53D5Z331oYKUzJ+EzII+qLAXvRBRBMz4O8YJHHkDXBugkphBDDV8B9QeLjeNSZnUWoDziOH6bqPwf8pgl9s/Ui6V1CHSVRpcBWwUCgYA2kMgu7qS5kLtUWySPzW4nTKwhN+HFTIbRrNrECxXmxroigTEyfBFuNR5QaeYYrAtqgY1m5Lev//2GnWM7dAr7hewj6qfGszrvegHsqMs4cakVqEOtbrWxL+WtWPaIdjJ+x7ZoMnZxZDg3ysemybNHHwSyBsp1TDc+glzmMtJtLA==" + ], + "keyUse": ["SIG"], + "certificate": [ + "MIICnTCCAYUCBgGVUbFIeTANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdzdHVkZW50MB4XDTI1MDMwMTEyMzAyN1oXDTM1MDMwMTEyMzIwN1owEjEQMA4GA1UEAwwHc3R1ZGVudDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANTEZgE/8iJpFXzzAcQtEB3QuA0H7llE4ynhJLvoDCPY1+QoVM881boHL2VpsQTXLzs+6ekfBHT5ZWSiMBUh+Vr0NcNVSIEMCJWH6Yv6ZJ337S3eujCNn160CtfXUU3kEYrxH9/Rk3wUNl3O8tau82zKOU1cQEydw4D4td3WDu81T6Rk4kEHmIns5XAXnchnuHEtYOqAaGlYn9UD2NTYtrxIyYXMzTBiONPLSBcLPBtOE5Z6PNeJ09MrHX5VEhnEVJLuNTbXzPYa+ey0OOfbBmIaPD59dZQ7olFpKJib9MtxidcBQb0Gw7yPukLxHRbtBsUoa5XDkazZo9wcDji4Qe8CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAedqvKOBsz4IKKzkWHIQnN5H8dQKnuPUMdWewOwmMGIUdBU9k6aS+y+BB7mugF/Hnr8Lw5d2AHwVLj2VyP4Pq0d2My3Ihxi0vr6sSfxVHuD9y/a7FxDGVTkCvmy5DOmpF/kdNnL9xG5ZivHaucnrIHHGMcQCdbWAaac0qPZihv9pdMZFMtI3aiBO5jVJ7KP8iLNKsshg60mxCOPzauMVXi+rqqqhGAgMKAL4hjjvdIKTLWwmthnmAlGqlTk/7H82hS9aKygufXszXWdFAYhX/r8/hjyc+6zJUvkG20uRWnkR35gya7jQoZ2O6OvkQf0mgSvzgIP3xoYV2uKYD03wINg==" + ], + "priority": ["100"] + } + }, + { + "id": "3d6bfeeb-fa86-435e-8c39-6f547a0f4a38", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "kid": ["176e970f-5915-4d27-8233-8fab6d7ad947"], + "secret": [ + "sXeOdtyIPpH_kcZWikHFjTur9yWok0QUwKi95l8wHp6kTVX9vhoZL2siNHRoFnn8tFgT4JZbR0bMsD57qAXlmVjA830Ny_GZdhL_PFWQh7JYMEJrl-1nyLy_SReQXRtq_q9tKUafUZqeYSKBlUYZ7D4jNRJ4-uniq80Ger-4ee0" + ], + "priority": ["100"], + "algorithm": ["HS512"] + } + }, + { + "id": "df1247b5-041e-4ae8-b7fc-26c4b6f5ff67", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEpAIBAAKCAQEAq0piY/PaZh1IX46e0G6tCPtfRx7Q8zGslOFSBLR9PNAdSlBpYiV9kOpN1kTK2Sca5j4yyO9HAFK5j+zh/cy8SsP1iyuI0sCPG7NMKV1pP5Y753wTC0lTq8z16bvXPvPYrPRKgGDmU7Ww0/WD6/P+z9Li/+ujFHzzzfoPuQvbBhma6A4oadsC+zun/mWCyiD14mB+X00BeAIsxKJZ/Sd+U4lMkkvmpoXyx61xK+j48EAZ18u7FprlvUjgGzzAmm/K6O/fHPIw5eViVly7aj5gjh67ntSuZArVtrxy/Py5V4hkSO8guXKqNz3liJvLbFCqqpfTR/0duArZR0xcnaGc6wIDAQABAoIBAFRuu5YaXxbDq2eS5RzH2Vpakin7+jJOU4wljujL0QnXagC2J2QeJ8l1fT23tieZO4yvrxfVvnFd1aMouHMC5vORqWja4jxEd6ZHWKzxIw6ZbtjZk4eWMvy18KewlFavGyiR2GF0okQ0BMBOPqNhp8JoaMWOsNnKB+GJuBNWUTWtPWNQlbaeI+uIgFywrvZOcQuWqU+9Y5rQa7oKZisufu+z0vd9XyvjXQ/Thnuu9/k1m88EMAMS63zwfIOZm35PPqh1/6aBBcRWquT1X2S8g2hwmMLZJgU91yKtQcIujHXAcvxeK72/dcm8NU6AxM+8aj+821TvcNzJi7he5SGcin0CgYEA4cuxwqRixXz6yDcTv9GpJMULbJGjA+Qf/iSfT+ftBeKbnKgZGzHwOCTu5DMkag3BPjclut4sEt3QPf1cFv5vZvdkOnPeaFxrtoMhSz8ssh8qaOsObCwicel1zdPVTmMw7YzEZV14fdIq3lkHsLy2uWa0imRH0l4xTccmsJiPtU8CgYEAwjQtspxOejCyME+M+hcU3RelD6kaMjICuWGJj8g0OpqdHM7iNVJq78fOlWjntt/ydzfOXVMMVh4AG8dAvlc86iwwsBRsJPVrrrRoSNuAwFbjKisbjlnPbqyclHfUsyQitj19tp//ExH7JaBibzKd6KhqFuQTE1iYLs2mFQAz76UCgYEAsNLu64oGm7frQP345mAPgO8aqjRHIBX3g/Q0GsR61wAGcyElQCnUgHNT7burSa5p5goT7wpsI343xUPzaUJqBY25nRj+VGYEKFL6sM3Rd9B2SuHBUq8hbmmwyraYtiFxwKZbazJO2OHMloHMRvkSc5Dd0/8CS9ld7RYH04Y2DHsCgYEAoKXTK44baP7BWC9mOjc/vgjiNQs4rU8ra7igt7zwX44o63zEKUHNTh7l6DiIfYHRrAcRAahCazaT9makSxAVRs1ZVT7/mq8d7b41Chfx8KmvbuGMAPyQGEhXmoVqAOqigEhrptfBhD/6lkyPQNcJQz2VzOvMT9OYyBa8DWFGlTUCgYAfModz6g2HsYYr37/0ByXHKL0WQQtuAlZzCY9GuDLEok7QLFI/E+bdOHos3goW72Iswo/SO7inlW4S3gojuy+zZhwCO31T9p2Z0Yn0tDK3fkUO32flOLwxCZA99pKkIul3svl6643GqSD1feybmbYRtqoPCTSKSE9vI9T9DkBTvA==" + ], + "keyUse": ["ENC"], + "certificate": [ + "MIICnTCCAYUCBgGVUbFItzANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdzdHVkZW50MB4XDTI1MDMwMTEyMzAyN1oXDTM1MDMwMTEyMzIwN1owEjEQMA4GA1UEAwwHc3R1ZGVudDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKtKYmPz2mYdSF+OntBurQj7X0ce0PMxrJThUgS0fTzQHUpQaWIlfZDqTdZEytknGuY+MsjvRwBSuY/s4f3MvErD9YsriNLAjxuzTCldaT+WO+d8EwtJU6vM9em71z7z2Kz0SoBg5lO1sNP1g+vz/s/S4v/roxR88836D7kL2wYZmugOKGnbAvs7p/5lgsog9eJgfl9NAXgCLMSiWf0nflOJTJJL5qaF8setcSvo+PBAGdfLuxaa5b1I4Bs8wJpvyujv3xzyMOXlYlZcu2o+YI4eu57UrmQK1ba8cvz8uVeIZEjvILlyqjc95Yiby2xQqqqX00f9HbgK2UdMXJ2hnOsCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAHfum0Ymw/qHTpjNeJjJyh5A1N4Z58m/PjuCXafIdDDjuAYpB3/M4bTGZRUvEvv2RuBNv3rONvMR8dRrZwio0/T0aEXnHrEAaCSfVcMy1To8TGGOzgtPMub4YCqXLMCwW5cwIbqT3P58HEqsqEbv7Zp4LtLYZBYXWDF8vM4zEn3CPYxuxRPKlrBUynRKYcwN7+/dbhJKiARpPMIZ5viGbjaTnNE/d/VFdv1q5xm3ItYnShDyJ0REGN18sWleLI6qkW0X22Gcjn38fWjiXDnF0HQYzC2UzMcEo/iLfPxTKbJnc+PPmnszfmCh7mWs5xVGfMOz/Oy8HI121x1ZSriRktA==" + ], + "priority": ["100"], + "algorithm": ["RSA-OAEP"] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "f7d1108f-7994-47e5-81e9-1a88cdbe545c", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "cf40a5d3-bec8-4aef-9658-1b88c6cec561", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "6820625f-5bb5-4fa2-8539-26a8568265c1", + "alias": "Browser - Conditional Organization", + "description": "Flow to determine if the organization identity-first login is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "organization", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "66d5e52e-592e-4cef-bfa0-512e90b609ec", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "b5bed405-b5f2-4839-861c-612501e4c412", + "alias": "First Broker Login - Conditional Organization", + "description": "Flow to determine if the authenticator that adds organization members is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "idp-add-organization-member", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "dd786e24-e822-43ec-be03-29874eb73737", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "8751572f-623e-4bdc-a02c-e92c15a91143", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "61efadf2-a54e-4071-b8c9-83e094525051", + "alias": "Organization", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "b99c3a7a-8ef7-46b1-b8a1-cb51f8a6e725", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "a3bfc2e4-af67-4d3e-851f-3c58bf32be83", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "4cc3bf25-d1b7-43a6-8619-5ed5f2d65aed", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "4e5564ce-87da-4b25-8dcb-062216ceaa8d", + "alias": "browser", + "description": "Browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 26, + "autheticatorFlow": true, + "flowAlias": "Organization", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "def90462-5831-4856-b186-05df9e640bbb", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "f8c9010d-f197-417b-bda1-2993e1a73a21", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "0fb9e2a4-ea0d-453f-a1fe-f000c849fd66", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "79a9efc4-1279-4093-8914-92f4e0b02bb4", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 50, + "autheticatorFlow": true, + "flowAlias": "First Broker Login - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "f855b3a1-6612-4528-94bc-d0793bfda561", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "fb84970b-6f04-4849-a385-792e17c1b8ce", + "alias": "registration", + "description": "Registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "fcdfd4d4-1c04-487d-aa7c-85e136814274", + "alias": "registration form", + "description": "Registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "306d8f7d-c12a-46cb-9a68-c6c3f1622f57", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "35a54b09-ff8c-46c4-9f04-1efbb153276c", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "fc1b82d7-593d-4906-a4d9-13220b66b7ce", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "a90543f4-7da7-43bc-8737-7e58dd190014", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "parRequestUriLifespan": "60", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "26.1.3", + "userManagedAccessAllowed": false, + "organizationsEnabled": false, + "verifiableCredentialsEnabled": false, + "adminPermissionsEnabled": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} diff --git a/config/idp/teacher-realm.json b/config/idp/teacher-realm.json new file mode 100644 index 00000000..b9d29dcb --- /dev/null +++ b/config/idp/teacher-realm.json @@ -0,0 +1,2353 @@ +{ + "id": "02ba6887-22f5-4de4-ad9b-cb2a2060bce1", + "realm": "teacher", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "bruteForceStrategy": "MULTIPLE", + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "e7f1e366-0bfc-4469-bcde-92bcd1ed5ce7", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "02ba6887-22f5-4de4-ad9b-cb2a2060bce1", + "attributes": {} + }, + { + "id": "6b546a34-4ebe-4c09-b274-fc1f6bebdf93", + "name": "default-roles-teacher", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": ["offline_access", "uma_authorization"], + "client": { + "account": ["manage-account", "view-profile"] + } + }, + "clientRole": false, + "containerId": "02ba6887-22f5-4de4-ad9b-cb2a2060bce1", + "attributes": {} + }, + { + "id": "747c4433-f128-4f72-b56f-315e7779d4fd", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "02ba6887-22f5-4de4-ad9b-cb2a2060bce1", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "4c8243b1-b576-4cb2-a4f7-3ce25e408fe5", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "71fd672b-024b-4d44-b058-03320aeb1842", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-groups", "query-users"] + } + }, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "fea88d42-3065-4600-a5b6-c4e2589e1304", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "6247b5b0-4d41-4fda-900c-3dfc725e03a2", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "a3b55a4b-b7f9-4db3-a64f-6ddf80bf74e7", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "d6714bc8-ff2d-4da0-98b4-2a6479e67954", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "d389da82-1730-4c66-9b43-34ac3c8d7f6c", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "4dc3905f-311b-4de0-b2e6-a3de50a078a3", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "28ea5d84-4e7d-484e-82fa-c9adcea4ffc0", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "e020bc9c-f2c9-4023-82eb-b62266749334", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "e7373af5-924a-4f01-b34d-55a09aac6c74", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "0879b6d5-7db6-4c83-8b99-e889028cb13e", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "ff2c82f3-7f04-4ced-9127-65097e2c16b9", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "impersonation", + "view-users", + "view-events", + "manage-users", + "view-authorization", + "query-users", + "query-realms", + "manage-events", + "manage-identity-providers", + "query-clients", + "manage-realm", + "view-clients", + "manage-clients", + "query-groups", + "create-client", + "view-realm", + "manage-authorization", + "view-identity-providers" + ] + } + }, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "156a28de-00d8-4828-9dc9-e09e7841312f", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": ["query-clients"] + } + }, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "a241d7dd-b028-474a-bdf8-4d33e00c1b90", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "681e3f7e-bb8c-4e09-a49e-ba8c21f916ff", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "1c5886ad-b354-4246-b288-13ea7635db58", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "7dedf6ff-b715-4f14-85ac-40d0652f153d", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + }, + { + "id": "694721e8-3bf3-47b5-ae38-874db0dc7740", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "attributes": {} + } + ], + "dwengo": [], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "broker": [ + { + "id": "0cb1b2b5-a751-4f09-ac2f-ea26c398a857", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "cfd0202e-a6b9-4c5e-9f49-2ef17df9089b", + "attributes": {} + } + ], + "account": [ + { + "id": "d21c51c5-353c-4d78-8c8d-8b8e9f37efa8", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "7ceb65eb-30da-4dc3-95bc-f06863362fd6", + "attributes": {} + }, + { + "id": "49c8ac02-defa-41af-9e63-2fd24cfc103f", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "7ceb65eb-30da-4dc3-95bc-f06863362fd6", + "attributes": {} + }, + { + "id": "3850c5cc-510a-417b-9976-a1d1d6650804", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": ["manage-account-links"] + } + }, + "clientRole": true, + "containerId": "7ceb65eb-30da-4dc3-95bc-f06863362fd6", + "attributes": {} + }, + { + "id": "6554709e-304a-428f-8665-970aacd1dae8", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "7ceb65eb-30da-4dc3-95bc-f06863362fd6", + "attributes": {} + }, + { + "id": "7a0c9d85-daea-4b80-93b5-095e21e5d569", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "7ceb65eb-30da-4dc3-95bc-f06863362fd6", + "attributes": {} + }, + { + "id": "ee2c5cff-1b05-417f-ab3a-a796be754299", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": ["view-consent"] + } + }, + "clientRole": true, + "containerId": "7ceb65eb-30da-4dc3-95bc-f06863362fd6", + "attributes": {} + }, + { + "id": "128fb31d-0784-4b4e-9aa5-82ceb2824fa0", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "7ceb65eb-30da-4dc3-95bc-f06863362fd6", + "attributes": {} + }, + { + "id": "ca850b8d-b75b-4b04-9e42-1e4cc8ab2179", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "7ceb65eb-30da-4dc3-95bc-f06863362fd6", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "6b546a34-4ebe-4c09-b274-fc1f6bebdf93", + "name": "default-roles-teacher", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "02ba6887-22f5-4de4-ad9b-cb2a2060bce1" + }, + "requiredCredentials": ["password"], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": ["totpAppFreeOTPName", "totpAppGoogleName", "totpAppMicrosoftAuthenticatorName"], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": ["ES256", "RS256"], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256", "RS256"], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "63dbbb64-c09f-4e4e-9cbf-af9e557dbb09", + "username": "testleerkracht1", + "firstName": "Kris", + "lastName": "Coolsaet", + "email": "kris.coolsaet@ugent.be", + "emailVerified": false, + "createdTimestamp": 1740866530658, + "enabled": true, + "totp": false, + "credentials": [ + { + "id": "c5382bf7-ccc6-47de-93b9-2c11ea7b6862", + "type": "password", + "userLabel": "My password", + "createdDate": 1740866544032, + "secretData": "{\"value\":\"H2vKyHF3j/alz6CNap2uaKSRb+/wrWImVecj7dcHe1w=\",\"salt\":\"32WjW1KzFaR5RJqU0Pfq9w==\",\"additionalParameters\":{}}", + "credentialData": "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}" + } + ], + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": ["default-roles-teacher"], + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": ["offline_access"] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": ["manage-account", "view-groups"] + } + ] + }, + "clients": [ + { + "id": "7ceb65eb-30da-4dc3-95bc-f06863362fd6", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/teacher/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/teacher/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "920e8621-36b5-4046-b1cd-4b293668f64b", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/teacher/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/realms/teacher/account/*"], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "cd3f4ae0-3008-488b-88c5-b6d640a9edd3", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "9d7b2827-b7bb-451e-ad38-8f55a69f7c9c", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "cfd0202e-a6b9-4c5e-9f49-2ef17df9089b", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "abdee18a-4549-48b5-b976-4c1a42820ef9", + "clientId": "dwengo", + "name": "Dwengo", + "description": "", + "rootUrl": "http://localhost:5173", + "adminUrl": "http://localhost:5173", + "baseUrl": "http://localhost:5173", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "urn:ietf:wg:oauth:2.0:oob", + "http://localhost:5173/*", + "http://localhost:5173", + "http://localhost/*", + "http://localhost", + "https://sel2-1.ugent.be/*", + "https://sel2-1.ugent.be" + ], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "post.logout.redirect.uris": "+", + "frontchannel.logout.session.required": "true", + "oauth2.device.authorization.grant.enabled": "false", + "display.on.consent.screen": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "112e0e97-df75-4ed7-a35f-03b7c5f9d36a", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + }, + { + "id": "c421853c-5bdf-4ea9-ae97-51f5ad7b8df8", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/teacher/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["/admin/teacher/console/*"], + "webOrigins": ["+"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "a9a893af-925e-46c9-ba33-47b06101ce5f", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": ["web-origins", "acr", "roles", "profile", "basic", "email"], + "optionalClientScopes": ["address", "phone", "offline_access", "organization", "microprofile-jwt"] + } + ], + "clientScopes": [ + { + "id": "fef4fbeb-d7e6-4474-b802-6c63df0dc9a3", + "name": "saml_organization", + "description": "Organization Membership", + "protocol": "saml", + "attributes": { + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "2384b79b-5cc3-4e1c-b4b2-4bee2ceeed72", + "name": "organization", + "protocol": "saml", + "protocolMapper": "saml-organization-membership-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "a097893c-7eed-4556-b2ed-3751c7fc3c51", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "ffc38cb2-eb10-47cf-a2d6-6647fdd4da65", + "name": "service_account", + "description": "Specific scope for a client enabled for service accounts", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "06ed3629-1c3d-48d9-80c6-98fcd3958c48", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "04eeb81e-05c0-484a-91df-9a79138bcd66", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "6e673f49-ce38-4583-8040-8a2e7ec5e7c8", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "ee188d9c-ab26-4e53-a16c-c9f77094f854", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "05ff270b-6a50-4bbb-903d-9546a59f20bf", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "394f808d-bc7b-476e-a372-7cfece5c6db0", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "0371c44f-c6e0-4f88-ac8f-17a56e2b90f8", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "21d66073-42f2-443b-aac4-e49c9038253c", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "5cc6a97f-9d1a-4c72-b682-af6d1bd36883", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "d6a6d46b-80a7-4228-af07-0faae2911fed", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "322b508a-7464-4b0f-90df-3f489975a62e", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "f757ae7a-3005-4899-bb4e-da1ab4b47bb0", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "bab8eb17-0cb0-4275-8456-aa1d65933a35", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "6ea1d43c-d4c7-4f2f-93b0-dfdb3bb584eb", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "3a2ebc93-05fb-4904-996b-5e3331b72fcd", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "217b417e-d4f6-4225-bf92-3bd38f6fbefb", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "3dd5da51-5842-4358-a69f-f7ffffe521ac", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "790bda99-1c27-4970-b3b9-4fa1c90c738c", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "e6cf59c7-9390-4f48-ab01-79a0fa138960", + "name": "organization", + "description": "Additional claims about the organization a subject belongs to", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${organizationScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "417ff129-6b95-4e95-9f57-a6699ca18d8d", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "organization", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "43d92ef5-76d8-4df0-84b5-5f833875d345", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "74d21718-190a-4c53-b446-b07e5f029394", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "949871a0-d68c-4563-a9b3-945a3148f937", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "b07a2014-d07e-450f-a593-66e9f9cf4799", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "79efdc37-0f06-43e6-a516-7bc9dc29f04d", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "3bbbff21-0446-4813-8bdf-54c35d8fffca", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "0e996cda-fe5b-439d-ba4c-cf2129ae812f", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "ddf1efe2-e765-475c-a4a0-d52f1f597834", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "93a40d0e-f163-42f7-a9d4-53cc2e17914e", + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "41eb9e93-8e04-404b-a12b-40ef5a55f640", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "1291062a-10f6-4061-b9ea-f54ff5d8ec54", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "auth_time", + "jsonType.label": "long" + } + } + ] + }, + { + "id": "9ea27173-e54b-42f0-8f6c-5a36c5073ede", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "d10a6975-8aeb-4215-8d6b-23b0286d4abb", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "e8a99a5a-1519-4c7d-a3f0-ac6d34c61a0b", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${phoneScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "b2de087f-169f-44b3-ad46-3a063ac9025f", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "ffb8aebd-0d03-4811-8fd4-aa03bda36b2d", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "30e06d84-f610-4f17-8820-6f785a510357", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "de707a09-a895-4b67-9ac5-0ff4e69715ea", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "1762c903-9f07-451c-915d-855488e4aa42", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "0164bdc3-c79d-4467-b6bf-ca9a6889d04c", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "91301d6d-0bb9-4da6-b8db-ee2480e25fee", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "2880d772-b0da-4ee8-bf1e-3f729a945db9", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "535042c5-58c5-4225-94b8-0b5b3411968e", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "a88432f1-565f-480d-958d-a5cea1dbcf0a", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + } + ], + "defaultDefaultClientScopes": ["role_list", "saml_organization", "profile", "email", "roles", "web-origins", "acr", "basic"], + "defaultOptionalClientScopes": ["offline_access", "address", "phone", "microprofile-jwt", "organization"], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "a689e06a-e440-4d94-ba54-692fba5a5486", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": ["200"] + } + }, + { + "id": "2778fda5-0a9f-40ab-ab4b-054ff8ce38e9", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + }, + { + "id": "36dc0167-9c9a-4b4a-9f04-29129aecac4d", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-property-mapper", + "saml-user-attribute-mapper", + "oidc-full-name-mapper", + "saml-user-property-mapper", + "oidc-address-mapper", + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper" + ] + } + }, + { + "id": "4b79c6fd-5166-4bc2-ab0b-bff0018452f6", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "2003600a-89fb-421e-9dfe-d5096ee7fd4e", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-role-list-mapper", + "saml-user-property-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-property-mapper" + ] + } + }, + { + "id": "d62a2e93-f877-462a-bad3-93dcf91d49d2", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": ["true"], + "client-uris-must-match": ["true"] + } + }, + { + "id": "6e659a80-a638-4504-b507-21b9f77586ed", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "9ef67c59-5c3e-40cf-90ee-516b2e35ed3d", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": ["true"] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "b5365a56-e00d-4612-80bf-262a9c8dba7c", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEogIBAAKCAQEAudnbEbQij+g9JWYMuyJjF/sKe4fVEd9SrCmkFeAHZ7dEAmEKQcAlvJn1aL99pAm8KV0w9PAZugQx7ZzG6eUm4JLc+LYGJ8G8JDiVR6hIWRQ3k9HGcUwNacHKFlDj4XOeMykwLEo7jrQHAUx82vJO8bKZr2ixZqGc1UUUQNbEVYv0HzxhPKPoFYh9qQ8U6P0r/K9xIusLL6ZzmXx6uXqQuqtse05e5G9xwLjQDDrOKju4s1PJ1GZLt5Db9PGypMeA9J34tGL3O0rQom2WbO7R2GZGX044ZoNw6UnraGTmCxin1ywmwTq3JTb1IZ4DPLH/rucnPuKUb60B5ByPmXW/lQIDAQABAoIBAAlyYrGLbb9RX3xNa+O+O3m+U7nUPXcfWikwo6vN+6pgtScGzjnp3bEwxTnqE+WY7hTPLRwiMTiUmoIYuD6u3HNJW8yTmgv+y8SukJ34FpdakPmlTdg39K2VwWMxgOfWk+nHU/DIZC8chQeinu0VKICeIrQ5Ft1f5SQtEvq5v/iWIql+v2ipxdJ2dSl1NIO0/2S2Lyd7Slab50gbJ3kP0uZggN5IMtNd5GBvAbV+jaT4QWKuyXyHqOnyU/+2WU+XhmVPrX6c29sQg2CilqWf4RzEIeO/FgAiANEPyaAgW6mGtf17K1xsSrusyGMUsNGsGJSd7Q8K2o7g/Jv9V8160iECgYEA2xgKIoZ6+fT7UIDr+5insRr6PIht2WYl+JqibjzNRh+rvp1fjKkmjMOA39V6cvLaAHieSOUerSOD7nQONCedpHe6To1zOG9z5yYGgwa/2c/9eNORJq8vNw/4wXaARVf0mCNaexugPTdYvsSaqwW4+azIbB21xXUfKykLp0SjNfUCgYEA2ShJSZyTIqJ64IHi+Kj/E22ajK2DYBiFgNDmzKrW/ANO5ADumgMhahCcxW28Nw68n25vBWCK4o+6eVg1ldEdB1LxoKOYZAaA+zAiMsGI1/ndxdnlFopuJZguKhYDTmxzT0KcD9mApLKZBnCadGjG3FcdC8i14OK6S9lUIIpCvyECgYBXqWC0u7YMuPatGUhSXJwMAs1I1xWMvJBIziZbkTxY6GchV3pZn3xrKfYwmQvrXjvXoGtEo1gI0oMBL7JXL9qlabpDn9kQJZfsToygdFzi25OBerVDEykDEQLo9W8RT8Xv8YVMaJtOowyBF80CzMFcNMPkbmbCYMBd1ohxHsdm2QKBgGB9RhMvPzFkgLTBAdj7Plujl8hqULWiL6/NIsBOKLhRv/wPbfWA7pfySbZvy/Gq2qT8rNf2zb9dnb3NNAIdqIhYkoSOLGhFe4ohGRD0bZmJrMD80I3zdH2/4MNShKWUCqhtMGraeg60TMpPvlF7POEq0/0ocag7FgwdxQOwa3gBAoGASvjvVtfXyzWA9MycWLvlTPEGW5bjXrroYwulF8DkKfIaKWHfEiulTwQe4SHgS7CzWSg8KgvKIhJC/yTwfOtxZ894G9LWivwjjZCottIE+/qs6ioYSXouQr6IsWxs7U0i3gP36tsePjuSjR06kpBGfcFdynypAAq+mVBCV0Mxk9A=" + ], + "keyUse": ["ENC"], + "certificate": [ + "MIICnTCCAYUCBgGVU7avyzANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAd0ZWFjaGVyMB4XDTI1MDMwMTIxNTUzNloXDTM1MDMwMTIxNTcxNlowEjEQMA4GA1UEAwwHdGVhY2hlcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALnZ2xG0Io/oPSVmDLsiYxf7CnuH1RHfUqwppBXgB2e3RAJhCkHAJbyZ9Wi/faQJvCldMPTwGboEMe2cxunlJuCS3Pi2BifBvCQ4lUeoSFkUN5PRxnFMDWnByhZQ4+FznjMpMCxKO460BwFMfNryTvGyma9osWahnNVFFEDWxFWL9B88YTyj6BWIfakPFOj9K/yvcSLrCy+mc5l8erl6kLqrbHtOXuRvccC40Aw6zio7uLNTydRmS7eQ2/TxsqTHgPSd+LRi9ztK0KJtlmzu0dhmRl9OOGaDcOlJ62hk5gsYp9csJsE6tyU29SGeAzyx/67nJz7ilG+tAeQcj5l1v5UCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAf3HTufrfWb0uqsEwfUETl4S4IbJOjqDdzFhUkMVtiq5I9LLUlJ7StZ6eoDCEoKUzF2lPy0qR2Om7IKC8BA7J5qUio0NSNh9j/t1Ipcjzx6SQI2cD6AjJFZndnF+OBTxdm9c6J+KMho6ZSMQEGwn2osgRBeItauxUshogQJPY/GzWMHlZyCAJcYtuflzgyw1VIQ0OiWCpCiSGeWpojxh19KR9qSBU1rETZMLokmdp84muq8aqEnNIFY5XRyUdH4gjNBx3TGsammZbvzuZdZIDvFNE19SXl/J9QcWJlRw0DuOblLcLKiamcJkQj35T9DgwtYRc/2zM3u8jNwQXKwrUWA==" + ], + "priority": ["100"], + "algorithm": ["RSA-OAEP"] + } + }, + { + "id": "ce5dcd75-614d-453a-868c-4413b4a10c39", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "kid": ["a58f2df5-d24b-4aae-9e38-d42736883c7d"], + "secret": [ + "4sDZ4TC6Cuo0-A5Wa42n_HLCxFj6ir4enL6OmdllOTtR7f5YJN5bsPOJXOFGHeuNPe5jgNq2GfOaeqyQ19PnJMd3Ctsj7vQlx57hywXNvQ1FNuKL1uoxF2Szvw65Y4gIM7xoZpQglVhg2Zh7kA3HJEVhDvnmjNdjtm1QgdlFYws" + ], + "priority": ["100"], + "algorithm": ["HS512"] + } + }, + { + "id": "972a70cc-5e9d-4435-8423-f4d32e18d1e7", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "privateKey": [ + "MIIEogIBAAKCAQEAy3EHaNAy6udZMC3A2KIZhMDg0SfGN+FVOKRJfh6aJwtNPuP6EXUhCMRSCXj3EHvFYrBJwKllC8li8MC5rw0vF/P9OY1zdbNkg8Rpwa1D55AS/MlYqiKHazKIV55SXVt5MKtjGATk9D4P/UZ8iP3viBnT0Kws4lWrAv1Uk12CkeLzojeTdCr4I8xgsj5U6dWu2f9DJlsieBhefgJpIXgdWXzVyGC3dOQOrCMHxYdyL4dbBlJPk13QJGkmgH65PnRB0Zu4CtlNHWN1jbXWcRNs8iMLZ4R36F8OzbMcv51lv7+0UTP7HJ4iRjw+sSlUH1AB+3sklyoNgRnW6sisEA+UFQIDAQABAoIBAA9kbWGWQwv31g0poQoi9ZhQOZJJlps6vsZq066ppRMoLT+BYzW37Xhq1iQmVVcXbj9BxErB5kXGhmhdxI7EihgfWzzkAWTZ3lSD41aGg/k8stsSZtV0iFdpetxaO7QZjClNBlHWaPY7zdzlXN3GjL146shChqDXR3mR7ji6HftolGVnmzUXRK+gZG9IirlC+qCJ+sd6m9h83x31X5PRT6yiJ/jeNN4XpuMh61xHFckFOFCGfV2isWM9qL5kLllN1+m8nMjt0HOeEB0GRrHTMSp7QC9RI0z1C/uxdAdSyMhCUtva8jAfjtqYAo7yc63zlOvlkFuYeOQ9X8UmnavBbL8CgYEA7FjyjVr3OK139528/FJMmLk2xOCDQ08pS+ADX5Eib7R62k1ZzXiKnv/8whfdQFeJwIunSYn+y2JCaMFjARrh04SELeH6/CvQ5uCknfIkLeNBij1ye5Ruy7JpaV3oe36h8sJYv1+p5RcrxxINBxbEeKM/YQWRwcXVE54MBCB4dKsCgYEA3FufmasbB4Pna2Txlgo+XCKpf+1U+gcN6lRzsKzqtFVT7+ofUndnqTKgPrLHYytYOFIZIIP8YZBno5gUvK7bFjgAGWacayYNWAXSiEhRQ3ah95Ii/b4lcU095GN/Xu68yqlGQc0GDVD9xRejBNyYgHc2GPQ9bigjsb6pQLy0mj8CgYAN5SjVcKyqM2CjOS3cM8Z3ECSNLJnrAiNuZ4wrOTAqGxVB8lw+PUEBGhG1I4wJdVwO6ub55tgJAwzedcgpT3hJZDgVLn0ACF9uw3RKKOtBm2PGCdjKNS7SYPnbjP7XC9nfmNd44NnvMw6K1J/Zc9g3M3nNbXNlTgk57wfL0lDiowKBgEIF4cf1EGAsEUaINCo0X4LTj92YioFvY6f2LcOdy6TEfCXCDCh1RkXXuVOP1VXNQt19G7I2WYQR9Dt78Zqm+VWq6byyleM0v4LEG9Rhdpe0D8tRqdJFCorsDcNEXIFhHofKOBa3Cz0qKx7Gej2Wqsqy7S6E33MF68vxyFxxLduZAoGAcHhDa8r7EMFTEt3rZIqmblqOYvhfMKJ+Ruom11mUSHWLgyQGzK8mVPhB59J7gt0DKU6XRIwby/7c7x2wFWQ+dsy03PN49PDtewLcGtrsicJlY2mofFZpsFsYhOpyhPg4/zFiX77Ev3UEYiJJ4qXnlV5Yb+ae5D8ZNlmhIP1HQY4=" + ], + "keyUse": ["SIG"], + "certificate": [ + "MIICnTCCAYUCBgGVU7avAzANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAd0ZWFjaGVyMB4XDTI1MDMwMTIxNTUzNloXDTM1MDMwMTIxNTcxNlowEjEQMA4GA1UEAwwHdGVhY2hlcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMtxB2jQMurnWTAtwNiiGYTA4NEnxjfhVTikSX4emicLTT7j+hF1IQjEUgl49xB7xWKwScCpZQvJYvDAua8NLxfz/TmNc3WzZIPEacGtQ+eQEvzJWKoih2syiFeeUl1beTCrYxgE5PQ+D/1GfIj974gZ09CsLOJVqwL9VJNdgpHi86I3k3Qq+CPMYLI+VOnVrtn/QyZbIngYXn4CaSF4HVl81chgt3TkDqwjB8WHci+HWwZST5Nd0CRpJoB+uT50QdGbuArZTR1jdY211nETbPIjC2eEd+hfDs2zHL+dZb+/tFEz+xyeIkY8PrEpVB9QAft7JJcqDYEZ1urIrBAPlBUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAHkOqsY1iHqqCMDFvTh/XCNZRwdLdGY6ev+5zShrgb8MPJNgM/HIqQtwQ9wuKf5RfMF1+FQdSU1eavTeuHXp4IuMgv97DAjdZ/pBGHz5tCWMdlaf+Au/1zDoqCV91CbGuV6WHaUhDJLZfp9/phiq2BzPZO6LeWhFJLMzH+N6rPZ7Om72rjTN31TlLLgmLuKlOhMp2QpyaQB16g4ksLGIYq7IXIbCqPRuB33k3gO/+ZMYRpU2U4DQ3FZyIe4LzLXQQ7VSFz/x/rvnbF+hHBdcbszUvsQYCS21aZ6nAq4CGinU2iAOLXHmFotKs+01KZT1N3ZGlGQmHM8ywYyb9qbcfPA==" + ], + "priority": ["100"] + } + }, + { + "id": "24e3094f-f962-49bd-b355-ff3096bfefe8", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "kid": ["52ac32c1-f589-4e04-9667-16d2e7bd707a"], + "secret": ["ZEiWoUCZ30PSKa2rx8UXTQ"], + "priority": ["100"] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "2ac7aebb-c1ac-4fdf-9687-cedd34665024", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "2505f3dc-719b-43a1-9631-585302dd449e", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "5a07c120-c34b-4cf2-b38d-2e558af6853a", + "alias": "Browser - Conditional Organization", + "description": "Flow to determine if the organization identity-first login is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "organization", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "a3317f52-b2bc-4b4c-af14-53901d253fca", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "2281818c-fb40-4997-a1ad-fc9ad2c3cacc", + "alias": "First Broker Login - Conditional Organization", + "description": "Flow to determine if the authenticator that adds organization members is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "idp-add-organization-member", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "fcab0380-ca38-4f66-aaf2-ec741ef8be8e", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "ae2e214a-82b6-4d78-a7d0-f80d454e5083", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "ad2add46-e1bb-47bf-a125-d76c517f66a4", + "alias": "Organization", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "74e5d429-4db2-4323-b504-005c03e530fc", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "d11dbfe7-2472-4cda-a7f5-e9a536154028", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "f1131dc8-ea34-48e1-9363-438c15f985a4", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "f2880986-ef01-4199-ac31-35e0b16c989b", + "alias": "browser", + "description": "Browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 26, + "autheticatorFlow": true, + "flowAlias": "Organization", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "a08dca2e-d491-483f-a310-25bcfa2d89b3", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "4742ab83-03c9-417d-ba61-017d9f02afb3", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "458f78fd-84e5-4e4d-8198-200f25942134", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "8cbdd82f-3794-4fce-9494-70279a3d1fcb", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 50, + "autheticatorFlow": true, + "flowAlias": "First Broker Login - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "b64919c6-da2b-4e66-bcc6-0112d9e3132b", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "3c8979fe-c98c-4911-b16c-510dba8fb8e3", + "alias": "registration", + "description": "Registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "6f598384-bb66-485e-8ed5-7da83c1deba1", + "alias": "registration form", + "description": "Registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "086acb80-23bb-496d-a982-0d8886b2e844", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "2b5042d2-f5e2-456c-bd94-1f23ea0bfb20", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "3007c3b0-cdd5-4464-93f4-23e439b15253", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "ce14faa0-34fe-496f-bcb5-a7e72fcf3fbb", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "parRequestUriLifespan": "60", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "26.1.3", + "userManagedAccessAllowed": false, + "organizationsEnabled": false, + "verifiableCredentialsEnabled": false, + "adminPermissionsEnabled": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} diff --git a/config/loki/config.yml b/config/loki/config.yml new file mode 100644 index 00000000..b84377bd --- /dev/null +++ b/config/loki/config.yml @@ -0,0 +1,29 @@ +# This is a complete configuration to deploy Loki backed by the filesystem. +# The index will be shipped to the storage via tsdb-shipper. + +auth_enabled: false + +server: + http_listen_port: 3102 + +common: + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory + replication_factor: 1 + path_prefix: /tmp/loki + +schema_config: + configs: + - from: 2020-05-15 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + filesystem: + directory: /tmp/loki/chunks diff --git a/config/nginx/nginx.conf b/config/nginx/nginx.conf new file mode 100644 index 00000000..dc9317f6 --- /dev/null +++ b/config/nginx/nginx.conf @@ -0,0 +1,32 @@ +worker_processes auto; + + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + types { + application/javascript mjs; + text/css; + } + + server { + listen 8080; + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + root /usr/share/nginx/html; + expires 1y; + add_header Cache-Control "public"; + try_files $uri =404; + } + } +} diff --git a/docs/api/generate.ts b/docs/api/generate.ts new file mode 100644 index 00000000..87814c19 --- /dev/null +++ b/docs/api/generate.ts @@ -0,0 +1,58 @@ +import swaggerAutogen from 'swagger-autogen'; + +const doc = { + info: { + version: '0.1.0', + title: 'Dwengo-1 Backend API', + description: 'Dwengo-1 Backend API using Express, based on VZW Dwengo', + license: { + name: 'MIT', + url: 'https://github.com/SELab-2/Dwengo-1/blob/336496ab6352ee3f8bf47490c90b5cf81526cef6/LICENSE', + }, + }, + servers: [ + { + url: 'http://localhost:3000/', + description: 'Development server', + }, + { + url: 'https://sel2-1.ugent.be/api', + description: 'Production server', + }, + ], + components: { + securitySchemes: { + student: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost:7080/realms/student/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + teacher: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'http://localhost:7080/realms/teacher/protocol/openid-connect/auth', + scopes: { + openid: 'openid', + profile: 'profile', + email: 'email', + }, + }, + }, + }, + }, + }, +}; + +const outputFile = './swagger.json'; +const routes = ['../../backend/src/app.ts']; + +swaggerAutogen({ openapi: '3.1.0' })(outputFile, routes, doc); diff --git a/docs/api/swagger.json b/docs/api/swagger.json new file mode 100644 index 00000000..22337c4b --- /dev/null +++ b/docs/api/swagger.json @@ -0,0 +1,735 @@ +{ + "openapi": "3.1.0", + "info": { + "version": "0.1.0", + "title": "Dwengo-1 Backend API", + "description": "Dwengo-1 Backend API using Express, based on VZW Dwengo", + "license": { + "name": "MIT", + "url": "https://github.com/SELab-2/Dwengo-1/blob/336496ab6352ee3f8bf47490c90b5cf81526cef6/LICENSE" + } + }, + "servers": [ + { + "url": "http://localhost:3000/", + "description": "Development server" + }, + { + "url": "https://sel2-1.ugent.be/api", + "description": "Production server" + } + ], + "paths": { + "/": { + "get": { + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/student/": { + "get": { + "tags": ["Student"], + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/student/{id}": { + "get": { + "tags": ["Student"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/student/{id}/classes": { + "get": { + "tags": ["Student"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/student/{id}/submissions": { + "get": { + "tags": ["Student"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/student/{id}/assignments": { + "get": { + "tags": ["Student"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/student/{id}/groups": { + "get": { + "tags": ["Student"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/group/": { + "get": { + "tags": ["Group"], + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/group/{id}": { + "get": { + "tags": ["Group"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/group/{id}/question": { + "get": { + "tags": ["Group"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/assignment/": { + "get": { + "tags": ["Assignment"], + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/assignment/{id}": { + "get": { + "tags": ["Assignment"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/assignment/{id}/submissions": { + "get": { + "tags": ["Assignment"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/assignment/{id}/groups": { + "get": { + "tags": ["Assignment"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/assignment/{id}/questions": { + "get": { + "tags": ["Assignment"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/submission/": { + "get": { + "tags": ["Submission"], + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/submission/{id}": { + "get": { + "tags": ["Submission"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/class/": { + "get": { + "tags": ["Class"], + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/class/{id}": { + "get": { + "tags": ["Class"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/class/{id}/invitations": { + "get": { + "tags": ["Class"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/class/{id}/assignments": { + "get": { + "tags": ["Class"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/class/{id}/students": { + "get": { + "tags": ["Class"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/question/": { + "get": { + "tags": ["Question"], + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/question/{id}": { + "get": { + "tags": ["Question"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/question/{id}/answers": { + "get": { + "tags": ["Question"], + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/auth/config": { + "get": { + "tags": ["Auth"], + "description": "", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/auth/testAuthenticatedOnly": { + "get": { + "tags": ["Auth"], + "description": "", + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "student": [] + }, + { + "teacher": [] + } + ] + } + }, + "/auth/testStudentsOnly": { + "get": { + "tags": ["Auth"], + "description": "", + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "student": [] + } + ] + } + }, + "/auth/testTeachersOnly": { + "get": { + "tags": ["Auth"], + "description": "", + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "teacher": [] + } + ] + } + }, + "/theme/": { + "get": { + "tags": ["Theme"], + "description": "", + "parameters": [ + { + "name": "language", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/theme/{theme}": { + "get": { + "tags": ["Theme"], + "description": "", + "parameters": [ + { + "name": "theme", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + } + } + } + }, + "/learningPath/": { + "get": { + "tags": ["Learning Path"], + "description": "", + "parameters": [ + { + "name": "hruid", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "theme", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "language", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/learningObject/": { + "get": { + "tags": ["Learning Object"], + "description": "", + "parameters": [ + { + "name": "hruid", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "full", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "language", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/learningObject/{hruid}": { + "get": { + "tags": ["Learning Object"], + "description": "", + "parameters": [ + { + "name": "hruid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "language", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + } + }, + "components": { + "securitySchemes": { + "student": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "http://localhost:7080/realms/student/protocol/openid-connect/auth", + "scopes": { + "openid": "openid", + "profile": "profile", + "email": "email" + } + } + } + }, + "teacher": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "http://localhost:7080/realms/teacher/protocol/openid-connect/auth", + "scopes": { + "openid": "openid", + "profile": "profile", + "email": "email" + } + } + } + } + } + } +} diff --git a/docs/architecture/schema.png b/docs/architecture/schema.png new file mode 100644 index 00000000..9e4b00ce Binary files /dev/null and b/docs/architecture/schema.png differ diff --git a/docs/architecture/schema.py b/docs/architecture/schema.py new file mode 100644 index 00000000..7aa4cefd --- /dev/null +++ b/docs/architecture/schema.py @@ -0,0 +1,49 @@ +from diagrams import Cluster, Diagram, Edge +from diagrams.custom import Custom +from diagrams.onprem.certificates import LetsEncrypt +from diagrams.onprem.database import PostgreSQL +from diagrams.onprem.logging import Loki +from diagrams.onprem.monitoring import Grafana +from diagrams.onprem.network import Nginx +from diagrams.programming.flowchart import InputOutput +from diagrams.programming.framework import Vue +from diagrams.programming.language import Nodejs + +with Diagram("Dwengo-1 architectuur", filename="docs/architecture/schema", show=False): + ingress = Nginx("Reverse Proxy") + certificates = LetsEncrypt("SSL") + + with Cluster("Dwengo VZW"): + dwengo = Custom("Dwengo", "../../assets/img/dwengo-groen-zwart.png") + + with Cluster("Dwengo-1"): + frontend = Vue("/") + backend = Nodejs("/api") + identity_provider = Custom("IDP", "../../assets/img/keycloak.png") + + database = PostgreSQL("Database") + orm = InputOutput("MikroORM") + orm >> Edge(label="map") << database + + with Cluster("Observability"): + logging = Loki("Logging") + logging << Edge(color="firebrick", style="dashed") << Grafana("Monitoring") + + dependencies = [ + dwengo, + logging, + orm + ] + + backend >> dependencies + + service = [ + frontend, + backend, + identity_provider, + certificates + ] + + ingress \ + >> Edge(color="darkgreen") \ + << service diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000..3e3cb619 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,14 @@ +{ + "name": "dwengo-1-docs", + "version": "0.0.1", + "description": "Documentation for Dwengo-1", + "private": true, + "scripts": { + "build": "npm run architecture && npm run swagger", + "architecture": "python3 -m venv .venv && source .venv/bin/activate && pip install -r docs/requirements.txt && python docs/architecture/schema.py", + "swagger": "tsx api/generate.ts" + }, + "devDependencies": { + "swagger-autogen": "^2.23.7" + } +} diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..76c7036b --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +diagrams==0.24.1 diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 00000000..52a36775 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,86 @@ +import pluginJs from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import prettierConfig from 'eslint-config-prettier'; + +import { includeIgnoreFile } from '@eslint/compat'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const gitignorePath = path.resolve(__dirname, '.gitignore'); + +export default [ + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + prettierConfig, + includeIgnoreFile(gitignorePath), + { + ignores: ['**/dist/**', '**/.node_modules/**', '**/coverage/**', '**/.github/**'], + files: ['**/*.ts', '**/*.cts', '**.*.mts', '**/*.ts'], + }, + { + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + linterOptions: { + reportUnusedInlineConfigs: 'error', + }, + rules: { + 'no-await-in-loop': 'warn', + 'no-constructor-return': 'error', + 'no-duplicate-imports': 'error', + 'no-inner-declarations': 'error', + 'no-self-compare': 'error', + 'no-template-curly-in-string': 'error', + 'no-unmodified-loop-condition': 'warn', + 'no-unreachable-loop': 'warn', + 'no-use-before-define': 'error', + 'no-useless-assignment': 'error', + + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': 'error', + + 'arrow-body-style': ['warn', 'as-needed'], + 'block-scoped-var': 'warn', + camelcase: 'warn', + 'capitalized-comments': 'warn', + 'consistent-return': 'warn', + 'consistent-this': 'error', + curly: 'error', + 'default-case': 'error', + 'default-case-last': 'error', + 'default-param-last': 'error', + 'dot-notation': 'warn', + eqeqeq: 'error', + 'func-names': 'warn', + 'func-style': ['warn', 'declaration'], + 'grouped-accessor-pairs': ['warn', 'getBeforeSet'], + 'guard-for-in': 'warn', + 'logical-assignment-operators': 'warn', + 'max-classes-per-file': 'warn', + 'no-alert': 'error', + 'no-array-constructor': 'warn', + 'no-bitwise': 'warn', + 'no-console': 'warn', + 'no-continue': 'warn', + 'no-else-return': 'warn', + 'no-empty-function': 'warn', + 'no-eq-null': 'error', + 'no-eval': 'error', + 'no-extend-native': 'error', + 'no-extra-label': 'error', + 'no-implicit-coercion': 'warn', + 'no-implied-eval': 'error', + 'no-invalid-this': 'error', + 'no-iterator': 'error', + 'no-label-var': 'warn', + 'no-labels': 'warn', + 'no-loop-func': 'error', + 'no-multi-assign': 'error', + 'no-nested-ternary': 'error', + 'no-object-constructor': 'error', + }, + }, +]; diff --git a/frontend/.gitattributes b/frontend/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/frontend/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..aef72d03 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,33 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo + +test-results/ +playwright-report/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..9cbb61ea --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,36 @@ +FROM node:22 AS build-stage + +# install simple http server for serving static content +RUN npm install -g http-server + +WORKDIR /app + +# Install dependencies + +COPY package*.json ./ +COPY ./frontend/package.json ./frontend/ + +RUN npm install --silent + +# Build the frontend + +# Root tsconfig.json +COPY tsconfig.json ./ +COPY assets ./assets/ + +WORKDIR /app/frontend + +COPY frontend ./ + +RUN npx vite build + +FROM nginx:stable AS production-stage + +COPY config/nginx/nginx.conf /etc/nginx/nginx.conf + +COPY --from=build-stage /app/assets /usr/share/nginx/html/assets +COPY --from=build-stage /app/frontend/dist /usr/share/nginx/html + +EXPOSE 8080 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..f798f404 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,66 @@ +# dwengo-1-frontend + +This template should help get you started developing with Vue 3 in Vite. + +## Recommended IDE Setup + +[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur). + +Webstorm should work out of the box. + +## Type Support for `.vue` Imports in TS + +TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types. + +## Customize configuration + +See [Vite Configuration Reference](https://vite.dev/config/). + +## Project Setup + +```sh +npm install +``` + +### Compile and Hot-Reload for Development + +```sh +npm run dev +``` + +### Type-Check, Compile and Minify for Production + +```sh +npm run build +``` + +### Run Unit Tests with [Vitest](https://vitest.dev/) + +```sh +npm run test:unit +``` + +### Run End-to-End Tests with [Playwright](https://playwright.dev) + +```sh +# Install browsers for the first run +npx playwright install + +# When testing on CI, must build the project first +npm run build + +# Runs the end-to-end tests +npm run test:e2e +# Runs the tests only on Chromium +npm run test:e2e -- --project=chromium +# Runs the tests of a specific file +npm run test:e2e -- tests/example.spec.ts +# Runs the tests in debug mode +npm run test:e2e -- --debug +``` + +### Lint with [ESLint](https://eslint.org/) + +```sh +npm run lint +``` diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json new file mode 100644 index 00000000..f0bde359 --- /dev/null +++ b/frontend/e2e/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "include": ["./**/*"] +} diff --git a/frontend/e2e/vue.spec.ts b/frontend/e2e/vue.spec.ts new file mode 100644 index 00000000..fd4797b7 --- /dev/null +++ b/frontend/e2e/vue.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from "@playwright/test"; + +// See here how to get started: +// https://playwright.dev/docs/intro +test("visits the app root url", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("h1")).toHaveText("You did it!"); +}); diff --git a/frontend/env.d.ts b/frontend/env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/frontend/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts new file mode 100644 index 00000000..e9359af7 --- /dev/null +++ b/frontend/eslint.config.ts @@ -0,0 +1,42 @@ +import pluginVue from "eslint-plugin-vue"; +import { defineConfigWithVueTs, vueTsConfigs } from "@vue/eslint-config-typescript"; +import pluginVitest from "@vitest/eslint-plugin"; +import pluginPlaywright from "eslint-plugin-playwright"; +import skipFormatting from "@vue/eslint-config-prettier/skip-formatting"; +import rootConfig from "../eslint.config"; + +// To allow more languages other than `ts` in `.vue` files, uncomment the following lines: +// Import { configureVueProject } from '@vue/eslint-config-typescript' +// ConfigureVueProject({ scriptLangs: ['ts', 'tsx'] }) +// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup + +const vueConfig = defineConfigWithVueTs( + { + name: "app/files-to-lint", + files: ["**/*.{ts,mts,tsx,vue}"], + rules: { + "no-useless-assignment": "off", // Depend on `no-unused-vars` to catch this + }, + }, + + { + name: "app/files-to-ignore", + ignores: ["**/dist/**", "**/dist-ssr/**", "**/coverage/**"], + }, + + pluginVue.configs["flat/essential"], + vueTsConfigs.recommended, + + { + ...pluginVitest.configs.recommended, + files: ["src/**/__tests__/*"], + }, + + { + ...pluginPlaywright.configs["flat/recommended"], + files: ["e2e/**/*.{test,spec}.{js,ts,jsx,tsx}"], + }, + skipFormatting, +); + +export default [...rootConfig, ...vueConfig]; diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..3c1f2f07 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,22 @@ + + + + + + + Vite App + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..6fb13db7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,48 @@ +{ + "name": "dwengo-1-frontend", + "version": "0.0.1", + "description": "Frontend for Dwengo-1", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "type-check": "vue-tsc --build", + "format": "prettier --write src/", + "format-check": "prettier --check src/", + "lint": "eslint . --fix", + "test:unit": "vitest --run", + "test:e2e": "playwright test" + }, + "dependencies": { + "vue": "^3.5.13", + "vue-i18n": "^11.1.2", + "vue-router": "^4.5.0", + "vuetify": "^3.7.12", + "oidc-client-ts": "^3.1.0", + "axios": "^1.8.2" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tsconfig/node22": "^22.0.0", + "@types/jsdom": "^21.1.7", + "@types/node": "^22.13.4", + "@vitejs/plugin-vue": "^5.2.1", + "@vitest/eslint-plugin": "1.1.31", + "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.4.0", + "@vue/test-utils": "^2.4.6", + "@vue/tsconfig": "^0.7.0", + "eslint": "^9.20.1", + "eslint-plugin-playwright": "^2.2.0", + "eslint-plugin-vue": "^9.32.0", + "jsdom": "^26.0.0", + "npm-run-all2": "^7.0.2", + "typescript": "~5.7.3", + "vite": "^6.1.0", + "vite-plugin-vue-devtools": "^7.7.2", + "vitest": "^3.0.5", + "vue-tsc": "^2.2.2" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..06d60d89 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,110 @@ +import process from "node:process"; +import { defineConfig, devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// Require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./e2e", + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: Boolean(process.env.CI), + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.CI ? "http://localhost:4173" : "http://localhost:5173", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + + /* Only on CI systems run the tests headless */ + headless: Boolean(process.env.CI), + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + }, + }, + { + name: "firefox", + use: { + ...devices["Desktop Firefox"], + }, + }, + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + }, + }, + + /* Test against mobile viewports. */ + // { + // Name: 'Mobile Chrome', + // Use: { + // ...devices['Pixel 5'], + // }, + // }, + // { + // Name: 'Mobile Safari', + // Use: { + // ...devices['iPhone 12'], + // }, + // }, + + /* Test against branded browsers. */ + // { + // Name: 'Microsoft Edge', + // Use: { + // Channel: 'msedge', + // }, + // }, + // { + // Name: 'Google Chrome', + // Use: { + // Channel: 'chrome', + // }, + // }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // OutputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + webServer: { + /** + * Use the dev server by default for faster feedback loop. + * Use the preview server on CI for more realistic testing. + * Playwright will re-use the local server if there is already a dev-server running. + */ + command: process.env.CI ? "npm run preview" : "npm run dev", + port: process.env.CI ? 4173 : 5173, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/frontend/prettier.config.js b/frontend/prettier.config.js new file mode 100644 index 00000000..4bc9699b --- /dev/null +++ b/frontend/prettier.config.js @@ -0,0 +1,11 @@ +/** + * @type {import("prettier").Options} + */ + +const rootConfig = import("../prettier.config.js"); + +export default { + ...rootConfig, + vueIndentScriptAndStyle: true, + singleAttributePerLine: true, +}; diff --git a/frontend/public/assets/home/inclusive.png b/frontend/public/assets/home/inclusive.png new file mode 100644 index 00000000..0f0215fe Binary files /dev/null and b/frontend/public/assets/home/inclusive.png differ diff --git a/frontend/public/assets/home/innovative.png b/frontend/public/assets/home/innovative.png new file mode 100644 index 00000000..57fada35 Binary files /dev/null and b/frontend/public/assets/home/innovative.png differ diff --git a/frontend/public/assets/home/research_based.png b/frontend/public/assets/home/research_based.png new file mode 100644 index 00000000..a96d18ac Binary files /dev/null and b/frontend/public/assets/home/research_based.png differ diff --git a/frontend/public/assets/home/socially_relevant.png b/frontend/public/assets/home/socially_relevant.png new file mode 100644 index 00000000..cb63c227 Binary files /dev/null and b/frontend/public/assets/home/socially_relevant.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 00000000..df36fcfb Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 00000000..d355c43d --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,10 @@ + + + + + diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css new file mode 100644 index 00000000..f10adb8b --- /dev/null +++ b/frontend/src/assets/base.css @@ -0,0 +1,86 @@ +/* color palette from */ +:root { + --vt-c-white: #ffffff; + --vt-c-white-soft: #f8f8f8; + --vt-c-white-mute: #f2f2f2; + + --vt-c-black: #181818; + --vt-c-black-soft: #222222; + --vt-c-black-mute: #282828; + + --vt-c-indigo: #2c3e50; + + --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); + --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); + --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); + --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); + + --vt-c-text-light-1: var(--vt-c-indigo); + --vt-c-text-light-2: rgba(60, 60, 60, 0.66); + --vt-c-text-dark-1: var(--vt-c-white); + --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); +} + +/* semantic color variables for this project */ +:root { + --color-background: var(--vt-c-white); + --color-background-soft: var(--vt-c-white-soft); + --color-background-mute: var(--vt-c-white-mute); + + --color-border: var(--vt-c-divider-light-2); + --color-border-hover: var(--vt-c-divider-light-1); + + --color-heading: var(--vt-c-text-light-1); + --color-text: var(--vt-c-text-light-1); + + --section-gap: 160px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--vt-c-black); + --color-background-soft: var(--vt-c-black-soft); + --color-background-mute: var(--vt-c-black-mute); + + --color-border: var(--vt-c-divider-dark-2); + --color-border-hover: var(--vt-c-divider-dark-1); + + --color-heading: var(--vt-c-text-dark-1); + --color-text: var(--vt-c-text-dark-2); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: + color 0.5s, + background-color 0.5s; + line-height: 1.6; + font-family: + Inter, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Fira Sans", + "Droid Sans", + "Helvetica Neue", + sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg new file mode 100644 index 00000000..75656603 --- /dev/null +++ b/frontend/src/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 00000000..0304c6d1 --- /dev/null +++ b/frontend/src/assets/main.css @@ -0,0 +1,35 @@ +@import "./base.css"; + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + font-weight: normal; +} + +a, +.green { + text-decoration: none; + color: hsla(160, 100%, 37%, 1); + transition: 0.4s; + padding: 3px; +} + +@media (hover: hover) { + a:hover { + background-color: hsla(160, 100%, 37%, 0.2); + } +} + +@media (min-width: 1024px) { + body { + display: flex; + place-items: center; + } + + #app { + display: grid; + grid-template-columns: 1fr 1fr; + padding: 0 2rem; + } +} diff --git a/frontend/src/components/BrowseThemes.vue b/frontend/src/components/BrowseThemes.vue new file mode 100644 index 00000000..9575da5f --- /dev/null +++ b/frontend/src/components/BrowseThemes.vue @@ -0,0 +1,9 @@ + + + + + diff --git a/frontend/src/components/LearningPath.vue b/frontend/src/components/LearningPath.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/components/LearningPath.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue new file mode 100644 index 00000000..7d7c4d88 --- /dev/null +++ b/frontend/src/components/MenuBar.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/frontend/src/components/__tests__/HelloWorld.spec.ts b/frontend/src/components/__tests__/HelloWorld.spec.ts new file mode 100644 index 00000000..ff48c1de --- /dev/null +++ b/frontend/src/components/__tests__/HelloWorld.spec.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from "vitest"; + +import { mount } from "@vue/test-utils"; +import HelloWorld from "./HelloWorld.vue"; + +describe("HelloWorld", () => { + it("renders properly", () => { + const wrapper = mount(HelloWorld, { props: { msg: "Hello Vitest" } }); + expect(wrapper.text()).toContain("Hello Vitest"); + }); +}); diff --git a/frontend/src/components/__tests__/HelloWorld.vue b/frontend/src/components/__tests__/HelloWorld.vue new file mode 100644 index 00000000..0a6ee1fe --- /dev/null +++ b/frontend/src/components/__tests__/HelloWorld.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/frontend/src/components/errors/NotFound.vue b/frontend/src/components/errors/NotFound.vue new file mode 100644 index 00000000..99afde41 --- /dev/null +++ b/frontend/src/components/errors/NotFound.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 00000000..53d6f253 --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,8 @@ +export const apiConfig = { + baseUrl: + window.location.hostname === "localhost" && !(window.location.port === "80" || window.location.port === "") + ? "http://localhost:3000/api" + : window.location.origin + "/api", +}; + +export const loginRoute = "/login"; diff --git a/frontend/src/i18n/i18n.ts b/frontend/src/i18n/i18n.ts new file mode 100644 index 00000000..6e25cc19 --- /dev/null +++ b/frontend/src/i18n/i18n.ts @@ -0,0 +1,22 @@ +import { createI18n } from "vue-i18n"; + +// Import translations +import en from "@/i18n/locale/en.json"; +import nl from "@/i18n/locale/nl.json"; +import fr from "@/i18n/locale/fr.json"; +import de from "@/i18n/locale/de.json"; + +const savedLocale = localStorage.getItem("user-lang") || "en"; + +const i18n = createI18n({ + locale: savedLocale, + fallbackLocale: "en", + messages: { + en: en, + nl: nl, + fr: fr, + de: de, + }, +}); + +export default i18n; diff --git a/frontend/src/i18n/locale/de.json b/frontend/src/i18n/locale/de.json new file mode 100644 index 00000000..a1a699e5 --- /dev/null +++ b/frontend/src/i18n/locale/de.json @@ -0,0 +1,3 @@ +{ + "welcome": "Willkommen" +} diff --git a/frontend/src/i18n/locale/en.json b/frontend/src/i18n/locale/en.json new file mode 100644 index 00000000..c75bfc5d --- /dev/null +++ b/frontend/src/i18n/locale/en.json @@ -0,0 +1,9 @@ +{ + "welcome": "Welcome", + "student": "student", + "teacher": "teacher", + "assignments": "assignments", + "classes": "classes", + "discussions": "discussions", + "logout": "log out" +} diff --git a/frontend/src/i18n/locale/fr.json b/frontend/src/i18n/locale/fr.json new file mode 100644 index 00000000..86fe964d --- /dev/null +++ b/frontend/src/i18n/locale/fr.json @@ -0,0 +1,3 @@ +{ + "welcome": "Bienvenue" +} diff --git a/frontend/src/i18n/locale/nl.json b/frontend/src/i18n/locale/nl.json new file mode 100644 index 00000000..97ec9b49 --- /dev/null +++ b/frontend/src/i18n/locale/nl.json @@ -0,0 +1,9 @@ +{ + "welcome": "Welkom", + "student": "leerling", + "teacher": "leerkracht", + "assignments": "opdrachten", + "classes": "klassen", + "discussions": "discussies", + "logout": "log uit" +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 00000000..e4843dae --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,29 @@ +import { createApp } from "vue"; + +// Vuetify +import "vuetify/styles"; +import { createVuetify } from "vuetify"; +import * as components from "vuetify/components"; +import * as directives from "vuetify/directives"; +import i18n from "./i18n/i18n.ts"; + +// Components +import App from "./App.vue"; +import router from "./router"; + +const app = createApp(App); + +app.use(router); + +const link = document.createElement("link"); +link.rel = "stylesheet"; +link.href = "https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css"; +document.head.appendChild(link); + +const vuetify = createVuetify({ + components, + directives, +}); +app.use(vuetify); +app.use(i18n); +app.mount("#app"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 00000000..079c39ef --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,128 @@ +import { createRouter, createWebHistory } from "vue-router"; +import MenuBar from "@/components/MenuBar.vue"; +import StudentHomepage from "@/views/StudentHomepage.vue"; +import StudentAssignments from "@/views/assignments/StudentAssignments.vue"; +import StudentClasses from "@/views/classes/StudentClasses.vue"; +import StudentDiscussions from "@/views/discussions/StudentDiscussions.vue"; +import TeacherHomepage from "@/views/TeacherHomepage.vue"; +import TeacherAssignments from "@/views/assignments/TeacherAssignments.vue"; +import TeacherClasses from "@/views/classes/TeacherClasses.vue"; +import TeacherDiscussions from "@/views/discussions/TeacherDiscussions.vue"; +import SingleAssignment from "@/views/assignments/SingleAssignment.vue"; +import SingleClass from "@/views/classes/SingleClass.vue"; +import SingleDiscussion from "@/views/discussions/SingleDiscussion.vue"; +import NotFound from "@/components/errors/NotFound.vue"; +import CreateClass from "@/views/classes/CreateClass.vue"; +import CreateAssignment from "@/views/assignments/CreateAssignment.vue"; +import CreateDiscussion from "@/views/discussions/CreateDiscussion.vue"; +import CallbackPage from "@/views/CallbackPage.vue"; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: "/", + name: "home", + component: () => import("../views/HomePage.vue"), + }, + { + path: "/login", + name: "LoginPage", + component: () => import("../views/LoginPage.vue"), + }, + { + path: "/callback", + component: CallbackPage, + }, + { + path: "/student/:id", + component: MenuBar, + children: [ + { + path: "home", + name: "StudentHomePage", + component: StudentHomepage, + }, + { + path: "assignment", + name: "StudentAssignments", + component: StudentAssignments, + }, + { + path: "class", + name: "StudentClasses", + component: StudentClasses, + }, + { + path: "discussion", + name: "StudentDiscussions", + component: StudentDiscussions, + }, + ], + }, + + { + path: "/teacher/:id", + component: MenuBar, + children: [ + { + path: "home", + name: "TeacherHomepage", + component: TeacherHomepage, + }, + { + path: "assignment", + name: "TeacherAssignments", + component: TeacherAssignments, + }, + { + path: "class", + name: "TeacherClasses", + component: TeacherClasses, + }, + { + path: "discussion", + name: "TeacherDiscussions", + component: TeacherDiscussions, + }, + ], + }, + { + path: "/assignment/create", + name: "CreateAssigment", + component: CreateAssignment, + }, + { + path: "/assignment/:id", + name: "SingleAssigment", + component: SingleAssignment, + }, + { + path: "/class/create", + name: "CreateClass", + component: CreateClass, + }, + { + path: "/class/:id", + name: "SingleClass", + component: SingleClass, + }, + { + path: "/discussion/create", + name: "CreateDiscussion", + component: CreateDiscussion, + }, + { + path: "/discussion/:id", + name: "SingleDiscussion", + component: SingleDiscussion, + }, + { + path: "/:catchAll(.*)", + name: "NotFound", + component: NotFound, + }, + ], +}); + +export default router; diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts new file mode 100644 index 00000000..21134762 --- /dev/null +++ b/frontend/src/services/api-client.ts @@ -0,0 +1,10 @@ +import axios from "axios"; +import { apiConfig } from "@/config.ts"; + +const apiClient = axios.create({ + baseURL: apiConfig.baseUrl, + headers: { + "Content-Type": "application/json", + }, +}); +export default apiClient; diff --git a/frontend/src/services/auth/auth-config-loader.ts b/frontend/src/services/auth/auth-config-loader.ts new file mode 100644 index 00000000..ce8a33ca --- /dev/null +++ b/frontend/src/services/auth/auth-config-loader.ts @@ -0,0 +1,27 @@ +import apiClient from "@/services/api-client.ts"; +import type { FrontendAuthConfig } from "@/services/auth/auth.d.ts"; + +/** + * Fetch the authentication configuration from the backend. + */ +export async function loadAuthConfig() { + const authConfig = (await apiClient.get("auth/config")).data; + return { + student: { + authority: authConfig.student.authority, + client_id: authConfig.student.clientId, + redirect_uri: window.location.origin + "/callback", + response_type: authConfig.student.responseType, + scope: authConfig.student.scope, + post_logout_redirect_uri: window.location.origin, + }, + teacher: { + authority: authConfig.teacher.authority, + client_id: authConfig.teacher.clientId, + redirect_uri: window.location.origin + "/callback", + response_type: authConfig.teacher.responseType, + scope: authConfig.teacher.scope, + post_logout_redirect_uri: window.location.origin, + }, + }; +} diff --git a/frontend/src/services/auth/auth-service.ts b/frontend/src/services/auth/auth-service.ts new file mode 100644 index 00000000..53f8cb61 --- /dev/null +++ b/frontend/src/services/auth/auth-service.ts @@ -0,0 +1,135 @@ +/** + * Service for all authentication- and authorization-related tasks. + */ + +import { computed, reactive } from "vue"; +import type { AuthState, Role, UserManagersForRoles } from "@/services/auth/auth.d.ts"; +import { User, UserManager } from "oidc-client-ts"; +import { loadAuthConfig } from "@/services/auth/auth-config-loader.ts"; +import authStorage from "./auth-storage.ts"; +import { loginRoute } from "@/config.ts"; +import apiClient from "@/services/api-client.ts"; +import router from "@/router"; +import type { AxiosError } from "axios"; + +async function getUserManagers(): Promise { + const authConfig = await loadAuthConfig(); + return { + student: new UserManager(authConfig.student), + teacher: new UserManager(authConfig.teacher), + }; +} + +/** + * Load the information about who is currently logged in from the IDP. + */ +async function loadUser(): Promise { + const activeRole = authStorage.getActiveRole(); + if (!activeRole) { + return null; + } + const user = await (await getUserManagers())[activeRole].getUser(); + authState.user = user; + authState.accessToken = user?.access_token || null; + authState.activeRole = activeRole || null; + return user; +} + +/** + * Information about the current authentication state. + */ +const authState = reactive({ + user: null, + accessToken: null, + activeRole: authStorage.getActiveRole() || null, +}); + +const isLoggedIn = computed(() => authState.user !== null); + +/** + * Redirect the user to the login page where he/she can choose whether to log in as a student or teacher. + */ +async function initiateLogin() { + await router.push(loginRoute); +} + +/** + * Redirect the user to the IDP for the given role so that he can log in there. + * Only call this function when the user is not logged in yet! + */ +async function loginAs(role: Role): Promise { + // Storing it in local storage so that it won't be lost when redirecting outside of the app. + authStorage.setActiveRole(role); + await (await getUserManagers())[role].signinRedirect(); +} + +/** + * To be called when the user is redirected to the callback-endpoint by the IDP after a successful login. + */ +async function handleLoginCallback(): Promise { + const activeRole = authStorage.getActiveRole(); + if (!activeRole) { + throw new Error("Login callback received, but the user is not logging in!"); + } + authState.user = (await (await getUserManagers())[activeRole].signinCallback()) || null; +} + +/** + * Refresh an expired authorization token. + */ +async function renewToken() { + const activeRole = authStorage.getActiveRole(); + if (!activeRole) { + console.log("Can't renew the token: Not logged in!"); + await initiateLogin(); + return; + } + try { + return await (await getUserManagers())[activeRole].signinSilent(); + } catch (error) { + console.log("Can't renew the token:"); + console.log(error); + await initiateLogin(); + } +} + +/** + * End the session of the current user. + */ +async function logout(): Promise { + const activeRole = authStorage.getActiveRole(); + if (activeRole) { + await (await getUserManagers())[activeRole].signoutRedirect(); + authStorage.deleteActiveRole(); + } +} + +// Registering interceptor to add the authorization header to each request when the user is logged in. +apiClient.interceptors.request.use( + async (reqConfig) => { + const token = authState?.user?.access_token; + if (token) { + reqConfig.headers.Authorization = `Bearer ${token}`; + } + return reqConfig; + }, + (error) => Promise.reject(error), +); + +// Registering interceptor to refresh the token when a request failed because it was expired. +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError<{ message?: string }>) => { + if (error.response?.status === 401) { + if (error.response!.data.message === "token_expired") { + console.log("Access token expired, trying to refresh..."); + await renewToken(); + return apiClient(error.config!); // Retry the request + } // Apparently, the user got a 401 because he was not logged in yet at all. Redirect him to login. + await initiateLogin(); + } + return Promise.reject(error); + }, +); + +export default { authState, isLoggedIn, initiateLogin, loadUser, handleLoginCallback, loginAs, logout }; diff --git a/frontend/src/services/auth/auth-storage.ts b/frontend/src/services/auth/auth-storage.ts new file mode 100644 index 00000000..0f5eb43d --- /dev/null +++ b/frontend/src/services/auth/auth-storage.ts @@ -0,0 +1,26 @@ +import type { Role } from "@/services/auth/auth.d.ts"; + +export default { + /** + * Get the role the user is currently logged in as from the local persistent storage. + */ + getActiveRole(): Role | undefined { + return localStorage.getItem("activeRole") as Role | undefined; + }, + + /** + * Set the role the user is currently logged in as from the local persistent storage. + * This should happen when the user logs in with another account. + */ + setActiveRole(role: Role) { + localStorage.setItem("activeRole", role); + }, + + /** + * Remove the saved current role from the local persistent storage. + * This should happen when the user is logged out. + */ + deleteActiveRole() { + localStorage.removeItem("activeRole"); + }, +}; diff --git a/frontend/src/services/auth/auth.d.ts b/frontend/src/services/auth/auth.d.ts new file mode 100644 index 00000000..8b01e408 --- /dev/null +++ b/frontend/src/services/auth/auth.d.ts @@ -0,0 +1,22 @@ +import { type User, UserManager } from "oidc-client-ts"; + +export type AuthState = { + user: User | null; + accessToken: string | null; + activeRole: Role | null; +}; + +export type FrontendAuthConfig = { + student: FrontendIdpConfig; + teacher: FrontendIdpConfig; +}; + +export type FrontendIdpConfig = { + authority: string; + clientId: string; + scope: string; + responseType: string; +}; + +export type Role = "student" | "teacher"; +export type UserManagersForRoles = { student: UserManager; teacher: UserManager }; diff --git a/frontend/src/utils/base64ToImage.ts b/frontend/src/utils/base64ToImage.ts new file mode 100644 index 00000000..a5540ce5 --- /dev/null +++ b/frontend/src/utils/base64ToImage.ts @@ -0,0 +1,18 @@ +/** + * Converts a Base64 string to a valid image source URL. + * + * @param base64String - The "image" field from the learning path JSON response. + * @returns A properly formatted data URL for use in an tag. + * + * @example + * // Fetch the learning path data and extract the image + * const response = await fetch( learning path route ); + * const data = await response.json(); + * const base64String = data.image; + * + * // Use in an element + * Learning Path Image + */ +export function convertBase64ToImageSrc(base64String: string): string { + return base64String.startsWith("data:image") ? base64String : `data:image/png;base64,${base64String}`; +} diff --git a/frontend/src/views/CallbackPage.vue b/frontend/src/views/CallbackPage.vue new file mode 100644 index 00000000..306dfe10 --- /dev/null +++ b/frontend/src/views/CallbackPage.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/frontend/src/views/HomePage.vue b/frontend/src/views/HomePage.vue new file mode 100644 index 00000000..e9d53770 --- /dev/null +++ b/frontend/src/views/HomePage.vue @@ -0,0 +1,28 @@ + + + + diff --git a/frontend/src/views/LoginPage.vue b/frontend/src/views/LoginPage.vue new file mode 100644 index 00000000..a538a80c --- /dev/null +++ b/frontend/src/views/LoginPage.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/frontend/src/views/StudentHomepage.vue b/frontend/src/views/StudentHomepage.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/StudentHomepage.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/TeacherHomepage.vue b/frontend/src/views/TeacherHomepage.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/TeacherHomepage.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/assignments/CreateAssignment.vue b/frontend/src/views/assignments/CreateAssignment.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/assignments/CreateAssignment.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/assignments/SingleAssignment.vue b/frontend/src/views/assignments/SingleAssignment.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/assignments/SingleAssignment.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/assignments/StudentAssignments.vue b/frontend/src/views/assignments/StudentAssignments.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/assignments/StudentAssignments.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/assignments/TeacherAssignments.vue b/frontend/src/views/assignments/TeacherAssignments.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/assignments/TeacherAssignments.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/classes/CreateClass.vue b/frontend/src/views/classes/CreateClass.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/classes/CreateClass.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/classes/SingleClass.vue b/frontend/src/views/classes/SingleClass.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/classes/SingleClass.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/classes/StudentClasses.vue b/frontend/src/views/classes/StudentClasses.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/classes/StudentClasses.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/classes/TeacherClasses.vue b/frontend/src/views/classes/TeacherClasses.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/classes/TeacherClasses.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/discussions/CreateDiscussion.vue b/frontend/src/views/discussions/CreateDiscussion.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/discussions/CreateDiscussion.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/discussions/SingleDiscussion.vue b/frontend/src/views/discussions/SingleDiscussion.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/discussions/SingleDiscussion.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/discussions/StudentDiscussions.vue b/frontend/src/views/discussions/StudentDiscussions.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/discussions/StudentDiscussions.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/views/discussions/TeacherDiscussions.vue b/frontend/src/views/discussions/TeacherDiscussions.vue new file mode 100644 index 00000000..1a35a59f --- /dev/null +++ b/frontend/src/views/discussions/TeacherDiscussions.vue @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/tests/base64/base64Sample.txt b/frontend/tests/base64/base64Sample.txt new file mode 100644 index 00000000..2d5b7b46 --- /dev/null +++ b/frontend/tests/base64/base64Sample.txt @@ -0,0 +1 @@ +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zODJhMWUiCiAgIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6YjYyY2I4ZTktNjM3Yy00ZmMwLWE4ZDQtNDA3OWExMmEzMWVlIgogICB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6OGY5MTI2YzAtNWFjMS00NTFlLTk4ZmYtMmZjOTJkZTE1Mjg5IgogICBkYzpGb3JtYXQ9ImltYWdlL3BuZyIKICAgR0lNUDpBUEk9IjIuMCIKICAgR0lNUDpQbGF0Zm9ybT0iTGludXgiCiAgIEdJTVA6VGltZVN0YW1wPSIxNjc2Mzg4MTg4NjA2OTYyIgogICBHSU1QOlZlcnNpb249IjIuMTAuMjgiCiAgIHRpZmY6T3JpZW50YXRpb249IjEiCiAgIHhtcDpDcmVhdG9yVG9vbD0iR0lNUCAyLjEwIj4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiCiAgICAgIHN0RXZ0OmNoYW5nZWQ9Ii8iCiAgICAgIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6ZDZjMDg3MmUtMmQ0Yy00MzRmLTk1Y2ItNWQ1MjEwMzA0ZjFiIgogICAgICBzdEV2dDpzb2Z0d2FyZUFnZW50PSJHaW1wIDIuMTAgKExpbnV4KSIKICAgICAgc3RFdnQ6d2hlbj0iMjAyMy0wMi0xNFQxNjoyMzowOCswMTowMCIvPgogICAgPC9yZGY6U2VxPgogICA8L3htcE1NOkhpc3Rvcnk+CiAgPC9yZGY6RGVzY3JpcHRpb24+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICA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 diff --git a/frontend/tests/base64/base64ToImage.test.ts b/frontend/tests/base64/base64ToImage.test.ts new file mode 100644 index 00000000..d2ab4364 --- /dev/null +++ b/frontend/tests/base64/base64ToImage.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { convertBase64ToImageSrc } from "../../src/utils/base64ToImage.js"; +import fs from "fs"; +import path from "path"; + +let sampleBase64: string; + +beforeAll(() => { + // Load base64 sample from text file + const filePath = path.resolve(__dirname, "base64Sample.txt"); + sampleBase64 = fs.readFileSync(filePath, "utf8").trim(); +}); + +describe("convertBase64ToImageSrc", () => { + it("should return the same string if it is already a valid data URL", () => { + const base64Image = `data:image/png;base64,${sampleBase64}`; + expect(convertBase64ToImageSrc(base64Image)).toBe(base64Image); + }); + + it("should correctly format a raw Base64 string as a PNG image URL", () => { + expect(convertBase64ToImageSrc(sampleBase64)).toBe(`data:image/png;base64,${sampleBase64}`); + }); +}); diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 00000000..099adb2c --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..e0d34b13 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.vitest.json" + } + ], + "compilerOptions": { + "resolveJsonModule": true + } +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 00000000..d5d97cfa --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "nightwatch.conf.*", + "playwright.config.*", + "eslint.config.*" + ], + "compilerOptions": { + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} diff --git a/frontend/tsconfig.vitest.json b/frontend/tsconfig.vitest.json new file mode 100644 index 00000000..9beacba8 --- /dev/null +++ b/frontend/tsconfig.vitest.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.app.json", + "include": ["src/**/__tests__/*", "env.d.ts"], + "exclude": [], + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo", + + "lib": [], + "types": ["node", "jsdom"] + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..9ebf424b --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath, URL } from "node:url"; + +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import vueDevTools from "vite-plugin-vue-devtools"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..ba2d72b6 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url"; +import { mergeConfig, defineConfig, configDefaults } from "vitest/config"; +import viteConfig from "./vite.config"; + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: "jsdom", + exclude: [...configDefaults.exclude, "e2e/**"], + root: fileURLToPath(new URL("./", import.meta.url)), + }, + }), +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..0844e7b1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9560 @@ +{ + "name": "dwengo-1-monorepo", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dwengo-1-monorepo", + "version": "0.0.1", + "license": "MIT", + "workspaces": [ + "backend", + "frontend", + "docs" + ], + "devDependencies": { + "@eslint/compat": "^1.2.6", + "@eslint/js": "^9.20.0", + "@types/eslint-config-prettier": "^6.11.3", + "@typescript-eslint/eslint-plugin": "^8.24.1", + "@typescript-eslint/parser": "^8.24.1", + "eslint": "^9.20.1", + "eslint-config-prettier": "^10.0.1", + "jiti": "^2.4.2", + "typescript-eslint": "^8.24.1" + } + }, + "backend": { + "name": "dwengo-1-backend", + "version": "0.0.1", + "dependencies": { + "@mikro-orm/core": "6.4.9", + "@mikro-orm/knex": "6.4.9", + "@mikro-orm/postgresql": "6.4.9", + "@mikro-orm/reflection": "6.4.9", + "@mikro-orm/sqlite": "6.4.9", + "axios": "^1.8.2", + "cors": "^2.8.5", + "cross": "^1.0.0", + "cross-env": "^7.0.3", + "dotenv": "^16.4.7", + "express": "^5.0.1", + "express-jwt": "^8.5.1", + "gift-pegjs": "^1.0.2", + "isomorphic-dompurify": "^2.22.0", + "js-yaml": "^4.1.0", + "jsonpath-plus": "^10.3.0", + "jwks-rsa": "^3.1.0", + "loki-logger-ts": "^1.0.2", + "marked": "^15.0.7", + "response-time": "^2.3.3", + "swagger-ui-express": "^5.0.1", + "uuid": "^11.1.0", + "winston": "^3.17.0", + "winston-loki": "^6.1.3" + }, + "devDependencies": { + "@mikro-orm/cli": "6.4.9", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.13.4", + "@types/response-time": "^2.3.8", + "@types/swagger-ui-express": "^4.1.8", + "globals": "^15.15.0", + "ts-node": "^10.9.2", + "tsx": "^4.19.3", + "typescript": "^5.7.3", + "vitest": "^3.0.6" + } + }, + "backend/node_modules/globals": { + "version": "15.15.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "docs": { + "name": "dwengo-1-docs", + "version": "0.0.1", + "devDependencies": { + "swagger-autogen": "^2.23.7" + } + }, + "frontend": { + "name": "dwengo-1-frontend", + "version": "0.0.1", + "dependencies": { + "axios": "^1.8.2", + "oidc-client-ts": "^3.1.0", + "vue": "^3.5.13", + "vue-i18n": "^11.1.2", + "vue-router": "^4.5.0", + "vuetify": "^3.7.12" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tsconfig/node22": "^22.0.0", + "@types/jsdom": "^21.1.7", + "@types/node": "^22.13.4", + "@vitejs/plugin-vue": "^5.2.1", + "@vitest/eslint-plugin": "1.1.31", + "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.4.0", + "@vue/test-utils": "^2.4.6", + "@vue/tsconfig": "^0.7.0", + "eslint": "^9.20.1", + "eslint-plugin-playwright": "^2.2.0", + "eslint-plugin-vue": "^9.32.0", + "jsdom": "^26.0.0", + "npm-run-all2": "^7.0.2", + "typescript": "~5.7.3", + "vite": "^6.1.0", + "vite-plugin-vue-devtools": "^7.7.2", + "vitest": "^3.0.5", + "vue-tsc": "^2.2.2" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@ampproject/remapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@antfu/utils": { + "version": "0.7.10", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "2.8.3", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.1", + "@csstools/css-color-parser": "^3.0.7", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.8", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.26.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.26.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.26.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.26.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.9", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.9" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-syntax-decorators": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.26.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.26.9", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.7", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.1", + "@csstools/css-calc": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.4", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "version": "1.2.6", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.10.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/core": { + "version": "0.11.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.20.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.6", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.11.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "license": "MIT", + "optional": true + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@intlify/core-base": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.2.tgz", + "integrity": "sha512-nmG512G8QOABsserleechwHGZxzKSAlggGf9hQX0nltvSwyKNVuB/4o6iFeG2OnjXK253r8p8eSDOZf8PgFdWw==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "11.1.2", + "@intlify/shared": "11.1.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.2.tgz", + "integrity": "sha512-T/xbNDzi+Yv0Qn2Dfz2CWCAJiwNgU5d95EhhAEf4YmOgjCKktpfpiUSmLcBvK1CtLpPQ85AMMQk/2NCcXnNj1g==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "11.1.2", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.2.tgz", + "integrity": "sha512-dF2iMMy8P9uKVHV/20LA1ulFLL+MKSbfMiixSmn6fpwqzvix38OIc7ebgnFbBqElvghZCW9ACtzKTGKsTGTWGA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jercle/yargonaut": { + "version": "1.1.5", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "chalk": "^4.1.2", + "figlet": "^1.5.2", + "parent-require": "^1.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@mikro-orm/cli": { + "version": "6.4.9", + "dev": true, + "dependencies": { + "@jercle/yargonaut": "1.1.5", + "@mikro-orm/core": "6.4.9", + "@mikro-orm/knex": "6.4.9", + "fs-extra": "11.3.0", + "tsconfig-paths": "4.2.0", + "yargs": "17.7.2" + }, + "bin": { + "mikro-orm": "cli", + "mikro-orm-esm": "esm" + }, + "engines": { + "node": ">= 18.12.0" + } + }, + "node_modules/@mikro-orm/core": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mikro-orm/core/-/core-6.4.9.tgz", + "integrity": "sha512-osB2TbvSH4ZL1s62LCBQFAnxPqLycX5fakPHOoztudixqfbVD5QQydeGizJXMMh2zKP6vRCwIJy3MeSuFxPjHg==", + "license": "MIT", + "dependencies": { + "dataloader": "2.2.3", + "dotenv": "16.4.7", + "esprima": "4.0.1", + "fs-extra": "11.3.0", + "globby": "11.1.0", + "mikro-orm": "6.4.9", + "reflect-metadata": "0.2.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/b4nan" + } + }, + "node_modules/@mikro-orm/knex": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mikro-orm/knex/-/knex-6.4.9.tgz", + "integrity": "sha512-iGXJfe/TziVOQsWuxMIqkOpurysWzQA6kj3+FDtOkHJAijZhqhjSBnfUVHHY/JzU9o0M0rgLrDVJFry/uEaJEA==", + "license": "MIT", + "dependencies": { + "fs-extra": "11.3.0", + "knex": "3.1.0", + "sqlstring": "2.3.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^6.0.0", + "better-sqlite3": "*", + "libsql": "*", + "mariadb": "*" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "libsql": { + "optional": true + }, + "mariadb": { + "optional": true + } + } + }, + "node_modules/@mikro-orm/postgresql": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mikro-orm/postgresql/-/postgresql-6.4.9.tgz", + "integrity": "sha512-ZdVVFAL/TSbzpEmChGdH0oUpy2KiHLjNIeItZHRQgInn1X9p0qx28VVDR78p8qgRGkQ3LquxGTkvmWI0w7qi3A==", + "license": "MIT", + "dependencies": { + "@mikro-orm/knex": "6.4.9", + "pg": "8.13.3", + "postgres-array": "3.0.4", + "postgres-date": "2.1.0", + "postgres-interval": "4.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^6.0.0" + } + }, + "node_modules/@mikro-orm/reflection": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mikro-orm/reflection/-/reflection-6.4.9.tgz", + "integrity": "sha512-fgY7yLrcZm3J/8dv9reUC4PQo7C2muImU31jmzz1SxmNKPJFDJl7OzcDZlM5NOisXzsWUBrcNdCyuQiWViVc3A==", + "license": "MIT", + "dependencies": { + "globby": "11.1.0", + "ts-morph": "25.0.1" + }, + "engines": { + "node": ">= 18.12.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^6.0.0" + } + }, + "node_modules/@mikro-orm/sqlite": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mikro-orm/sqlite/-/sqlite-6.4.9.tgz", + "integrity": "sha512-O7Jy/5DrTWpJI/3qkhRJHl+OcECx1N625LHDODAAauOK3+MJB/bj80TrvQhe6d/CHZMmvxZ7m2GzaL1NulKxRw==", + "license": "MIT", + "dependencies": { + "@mikro-orm/knex": "6.4.9", + "fs-extra": "11.3.0", + "sqlite3": "5.1.7", + "sqlstring-sqlite": "0.1.1" + }, + "engines": { + "node": ">= 18.12.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^6.0.0" + } + }, + "node_modules/@napi-rs/snappy-android-arm-eabi": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-android-arm-eabi/-/snappy-android-arm-eabi-7.2.2.tgz", + "integrity": "sha512-H7DuVkPCK5BlAr1NfSU8bDEN7gYs+R78pSHhDng83QxRnCLmVIZk33ymmIwurmoA1HrdTxbkbuNl+lMvNqnytw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-android-arm64": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-android-arm64/-/snappy-android-arm64-7.2.2.tgz", + "integrity": "sha512-2R/A3qok+nGtpVK8oUMcrIi5OMDckGYNoBLFyli3zp8w6IArPRfg1yOfVUcHvpUDTo9T7LOS1fXgMOoC796eQw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-darwin-arm64": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-darwin-arm64/-/snappy-darwin-arm64-7.2.2.tgz", + "integrity": "sha512-USgArHbfrmdbuq33bD5ssbkPIoT7YCXCRLmZpDS6dMDrx+iM7eD2BecNbOOo7/v1eu6TRmQ0xOzeQ6I/9FIi5g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-darwin-x64": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-darwin-x64/-/snappy-darwin-x64-7.2.2.tgz", + "integrity": "sha512-0APDu8iO5iT0IJKblk2lH0VpWSl9zOZndZKnBYIc+ei1npw2L5QvuErFOTeTdHBtzvUHASB+9bvgaWnQo4PvTQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-freebsd-x64": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-freebsd-x64/-/snappy-freebsd-x64-7.2.2.tgz", + "integrity": "sha512-mRTCJsuzy0o/B0Hnp9CwNB5V6cOJ4wedDTWEthsdKHSsQlO7WU9W1yP7H3Qv3Ccp/ZfMyrmG98Ad7u7lG58WXA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-linux-arm-gnueabihf": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-linux-arm-gnueabihf/-/snappy-linux-arm-gnueabihf-7.2.2.tgz", + "integrity": "sha512-v1uzm8+6uYjasBPcFkv90VLZ+WhLzr/tnfkZ/iD9mHYiULqkqpRuC8zvc3FZaJy5wLQE9zTDkTJN1IvUcZ+Vcg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-linux-arm64-gnu": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-linux-arm64-gnu/-/snappy-linux-arm64-gnu-7.2.2.tgz", + "integrity": "sha512-LrEMa5pBScs4GXWOn6ZYXfQ72IzoolZw5txqUHVGs8eK4g1HR9HTHhb2oY5ySNaKakG5sOgMsb1rwaEnjhChmQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-linux-arm64-musl": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-linux-arm64-musl/-/snappy-linux-arm64-musl-7.2.2.tgz", + "integrity": "sha512-3orWZo9hUpGQcB+3aTLW7UFDqNCQfbr0+MvV67x8nMNYj5eAeUtMmUE/HxLznHO4eZ1qSqiTwLbVx05/Socdlw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-linux-x64-gnu": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-linux-x64-gnu/-/snappy-linux-x64-gnu-7.2.2.tgz", + "integrity": "sha512-jZt8Jit/HHDcavt80zxEkDpH+R1Ic0ssiVCoueASzMXa7vwPJeF4ZxZyqUw4qeSy7n8UUExomu8G8ZbP6VKhgw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-linux-x64-musl": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-linux-x64-musl/-/snappy-linux-x64-musl-7.2.2.tgz", + "integrity": "sha512-Dh96IXgcZrV39a+Tej/owcd9vr5ihiZ3KRix11rr1v0MWtVb61+H1GXXlz6+Zcx9y8jM1NmOuiIuJwkV4vZ4WA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-win32-arm64-msvc": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-win32-arm64-msvc/-/snappy-win32-arm64-msvc-7.2.2.tgz", + "integrity": "sha512-9No0b3xGbHSWv2wtLEn3MO76Yopn1U2TdemZpCaEgOGccz1V+a/1d16Piz3ofSmnA13HGFz3h9NwZH9EOaIgYA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-win32-ia32-msvc": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-win32-ia32-msvc/-/snappy-win32-ia32-msvc-7.2.2.tgz", + "integrity": "sha512-QiGe+0G86J74Qz1JcHtBwM3OYdTni1hX1PFyLRo3HhQUSpmi13Bzc1En7APn+6Pvo7gkrcy81dObGLDSxFAkQQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/snappy-win32-x64-msvc": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/snappy-win32-x64-msvc/-/snappy-win32-x64-msvc-7.2.2.tgz", + "integrity": "sha512-a43cyx1nK0daw6BZxVcvDEXxKMFLSBSDTAhsFD0VqSKcC7MGUBMaqyoWUcMiI7LBSz4bxUmxDWKfCYzpEmeb3w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@playwright/test": { + "version": "1.50.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.50.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.28", + "dev": true, + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.34.8", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.34.8", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.26.1", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.2", + "minimatch": "^9.0.4", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node22": { + "version": "22.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/eslint-config-prettier": { + "version": "6.11.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, + "node_modules/@types/node": { + "version": "22.13.4", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "license": "MIT" + }, + "node_modules/@types/response-time": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@types/response-time/-/response-time-2.3.8.tgz", + "integrity": "sha512-7qGaNYvdxc0zRab8oHpYx7AW17qj+G0xuag1eCrw3M2VWPJQ/HyKaaghWygiaOUl0y9x7QGQwppDpqLJ5V9pzw==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, + "node_modules/@types/send": { + "version": "0.17.4", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.24.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/type-utils": "8.24.1", + "@typescript-eslint/utils": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.24.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/typescript-estree": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.24.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.24.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.24.1", + "@typescript-eslint/utils": "8.24.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.24.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.24.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.24.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/typescript-estree": "8.24.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.24.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.24.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/eslint-plugin": { + "version": "1.1.31", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/utils": ">= 8.0", + "eslint": ">= 8.57.0", + "typescript": ">= 5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.6", + "@vitest/utils": "3.0.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.0.6", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.6", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.11" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.11", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/babel-helper-vue-transform-on": { + "version": "1.2.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/babel-plugin-jsx": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/plugin-syntax-jsx": "^7.24.7", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.6", + "@babel/types": "^7.25.6", + "@vue/babel-helper-vue-transform-on": "1.2.5", + "@vue/babel-plugin-resolve-type": "1.2.5", + "html-tags": "^3.3.1", + "svg-tags": "^1.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + } + } + }, + "node_modules/@vue/babel-plugin-resolve-type": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/parser": "^7.25.6", + "@vue/compiler-sfc": "^3.5.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.13", + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.48", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "license": "MIT" + }, + "node_modules/@vue/devtools-core": { + "version": "7.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.2", + "@vue/devtools-shared": "^7.7.2", + "mitt": "^3.0.1", + "nanoid": "^5.0.9", + "pathe": "^2.0.2", + "vite-hot-client": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/@vue/devtools-core/node_modules/nanoid": { + "version": "5.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.2", + "birpc": "^0.2.19", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.1" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/eslint-config-prettier": { + "version": "10.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2" + }, + "peerDependencies": { + "eslint": ">= 8.21.0", + "prettier": ">= 3.0.0" + } + }, + "node_modules/@vue/eslint-config-typescript": { + "version": "14.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.23.0", + "fast-glob": "^3.3.3", + "typescript-eslint": "^8.23.0", + "vue-eslint-parser": "^9.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.10.0", + "eslint-plugin-vue": "^9.28.0", + "typescript": ">=4.8.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~2.4.11", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/runtime-core": "3.5.13", + "@vue/shared": "3.5.13", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "vue": "3.5.13" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.13", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/@vue/tsconfig": { + "version": "0.7.0", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/alien-signals": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aproba": { + "version": "2.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz", + "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bindings": { + "version": "1.5.0", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/birpc": { + "version": "0.2.19", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.5.2", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.14.0", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "1.1.11", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "6.0.0", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/cacache/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache/node_modules/yallist": { + "version": "4.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001700", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "license": "MIT" + }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/colorette": { + "version": "2.0.19", + "license": "MIT" + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "devOptional": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "license": "ISC", + "optional": true + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/cross": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cross/-/cross-1.0.0.tgz", + "integrity": "sha512-p6hXbCnjuIB4bhKWFeztQd7VwffgQP9zOBzUoiA8Lvi01RzQY0e7PbPFU/uqVPTM2stY7uCpVck1UTPpxhinMQ==", + "license": "BSD" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^2.8.2", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/dataloader": { + "version": "2.2.3", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.5.0", + "license": "MIT" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "license": "MIT", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dwengo-1-backend": { + "resolved": "backend", + "link": true + }, + "node_modules/dwengo-1-docs": { + "resolved": "docs", + "link": true + }, + "node_modules/dwengo-1-frontend": { + "resolved": "frontend", + "link": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editorconfig": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.102", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "license": "MIT", + "optional": true + }, + "node_modules/error-stack-parser-es": { + "version": "0.1.5", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.0", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.20.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.11.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.20.0", + "@eslint/plugin-kit": "^0.2.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.0.1", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "build/bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-playwright": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "workspaces": [ + "examples" + ], + "dependencies": { + "globals": "^13.23.0" + }, + "engines": { + "node": ">=16.6.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/eslint-plugin-playwright/node_modules/globals": { + "version": "13.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.9.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "9.32.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "globals": "^13.24.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.15", + "semver": "^7.6.3", + "vue-eslint-parser": "^9.4.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-vue/node_modules/globals": { + "version": "13.24.0", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.2.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/esm": { + "version": "3.2.25", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "9.5.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.3", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.0", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.1.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.0.1", + "content-disposition": "^1.0.0", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "^1.2.1", + "debug": "4.3.6", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "^2.0.0", + "fresh": "2.0.0", + "http-errors": "2.0.0", + "merge-descriptors": "^2.0.0", + "methods": "~1.1.2", + "mime-types": "^3.0.0", + "on-finished": "2.4.1", + "once": "1.4.0", + "parseurl": "~1.3.3", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "router": "^2.0.0", + "safe-buffer": "5.2.1", + "send": "^1.1.0", + "serve-static": "^2.1.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "^2.0.0", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/express-jwt": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-jwt/-/express-jwt-8.5.1.tgz", + "integrity": "sha512-Dv6QjDLpR2jmdb8M6XQXiCcpEom7mK8TOqnr0/TngDKsG2DHVkO8+XnVxkJVN7BuS1I3OrGw6N8j5DaaGgkDRQ==", + "dependencies": { + "@types/jsonwebtoken": "^9", + "express-unless": "^2.1.3", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/express-unless": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/express-unless/-/express-unless-2.1.3.tgz", + "integrity": "sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ==" + }, + "node_modules/express/node_modules/debug": { + "version": "4.3.6", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.0", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + }, + "node_modules/figlet": { + "version": "1.8.0", + "dev": true, + "license": "MIT", + "bin": { + "figlet": "bin/index.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "dev": true, + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "devOptional": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT", + "optional": true + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "license": "ISC", + "optional": true + }, + "node_modules/gauge/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.7", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "get-proto": "^1.0.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/getopts": { + "version": "2.3.0", + "license": "MIT" + }, + "node_modules/gift-pegjs": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/gift-pegjs/-/gift-pegjs-1.0.2.tgz", + "integrity": "sha512-S/A2wBDdia2QWKpB5FtASx1gguep1wg5If5glDWJgUMiABICJT7ogArGfsdgozevhBdbdOiHhrykJP86hbgvRw==", + "license": "MIT", + "dependencies": { + "pegjs": "^0.10.x" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "license": "MIT" + }, + "node_modules/glob": { + "version": "10.4.5", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "license": "ISC", + "optional": true + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-tags": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.5.2", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "devOptional": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "license": "ISC" + }, + "node_modules/interpret": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "license": "MIT", + "optional": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "4.1.16", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/isomorphic-dompurify": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.22.0.tgz", + "integrity": "sha512-A2xsDNST1yB94rErEnwqlzSvGllCJ4e8lDMe1OWBH2hvpfc/2qzgMEiDshTO1HwO+PIDTiYeOc7ZDB7Ds49BOg==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.2.4", + "jsdom": "^26.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.4.2", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-beautify": { + "version": "1.15.3", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^8.0.0" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "license": "MIT", + "optional": true + }, + "node_modules/jsdom": { + "version": "26.0.0", + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.1", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/xml-name-validator": { + "version": "5.0.0", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpath-plus": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", + "integrity": "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.1.0.tgz", + "integrity": "sha512-v7nqlfezb9YfHHzYII3ef2a2j1XnGeSE/bK3WfumaYCqONAIstJbrEGapz4kadScZzEt7zYCN7bucj8C0Mv/Rg==", + "dependencies": { + "@types/express": "^4.17.17", + "@types/jsonwebtoken": "^9.0.2", + "debug": "^4.3.4", + "jose": "^4.14.6", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/knex": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "colorette": "2.0.19", + "commander": "^10.0.0", + "debug": "4.3.4", + "escalade": "^3.1.1", + "esm": "^3.2.25", + "get-package-type": "^0.1.0", + "getopts": "2.3.0", + "interpret": "^2.2.0", + "lodash": "^4.17.21", + "pg-connection-string": "2.6.2", + "rechoir": "^0.8.0", + "resolve-from": "^5.0.0", + "tarn": "^3.0.2", + "tildify": "2.0.0" + }, + "bin": { + "knex": "bin/cli.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "mysql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/debug": { + "version": "4.3.4", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/ms": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/knex/node_modules/resolve-from": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "dev": true, + "license": "MIT" + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/loki-logger-ts": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/loki-logger-ts/-/loki-logger-ts-1.0.2.tgz", + "integrity": "sha512-SV/B5o+9jaxiThcU5N3LUxCNTx20IgR9xjCjx/ED/pVc/097mqKSRpmvSjvx9ezFcjJlUF7GBkrBBpR6veNp7Q==", + "dependencies": { + "axios": "^1.4.0" + } + }, + "node_modules/long": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.1.tgz", + "integrity": "sha512-ka87Jz3gcx/I7Hal94xaN2tZEOPoUOEVftkQqZx2EeQRN7LGdfLlI3FvZ+7WDplm+vK2Urx9ULrvSowtdCieng==" + }, + "node_modules/loupe": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "license": "ISC" + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/magic-string": { + "version": "0.30.17", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "dev": true, + "license": "ISC" + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "4.0.1", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "6.0.0", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "0.6.4", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/make-fetch-happen/node_modules/yallist": { + "version": "4.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/marked": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.7.tgz", + "integrity": "sha512-dgLIeKGLx5FwziAnsk4ONoGwHwGPJzselimvlVskE9XLN4Orv9u2VA3GWw/lYUqjfA0rUT/6fqKwfZJapP9BEg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mikro-orm": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/mikro-orm/-/mikro-orm-6.4.9.tgz", + "integrity": "sha512-XwVrWNT4NNwS6kHIKFNDfvy8L1eWcBBEHeTVzFFYcnb2ummATaLxqeVkNEmKA68jmdtfQdUmWBqGdbcIPwtL2Q==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + } + }, + "node_modules/mime-db": { + "version": "1.53.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "mime-db": "^1.53.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-collect/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect/node_modules/yallist": { + "version": "4.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-fetch/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-fetch/node_modules/yallist": { + "version": "4.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/minizlib": { + "version": "2.1.2", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "license": "ISC" + }, + "node_modules/mitt": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.8", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.74.0", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "license": "MIT" + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-gyp/node_modules/abbrev": { + "version": "1.1.1", + "license": "ISC", + "optional": true + }, + "node_modules/node-gyp/node_modules/brace-expansion": { + "version": "1.1.11", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/node-gyp/node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/node-gyp/node_modules/nopt": { + "version": "5.0.0", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "8.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-all2": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "cross-spawn": "^7.0.6", + "memorystream": "^0.3.1", + "minimatch": "^9.0.0", + "pidtree": "^0.6.0", + "read-package-json-fast": "^4.0.0", + "shell-quote": "^1.7.3", + "which": "^5.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "npm-run-all2": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0", + "npm": ">= 9" + } + }, + "node_modules/npm-run-all2/node_modules/ansi-styles": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm-run-all2/node_modules/isexe": { + "version": "3.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/npm-run-all2/node_modules/which": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.16", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/oidc-client-ts": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.0.tgz", + "integrity": "sha512-wUvVcG3SXzZDKHxi/VGQGaTUk9qguMKfYh26Y1zOVrQsu1zp85JWx/SjZzKSXK5j3NA1RcasgMoaHe6gt1WNtw==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/open": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-require": { + "version": "1.0.0", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.2.1", + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pegjs": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/pegjs/-/pegjs-0.10.0.tgz", + "integrity": "sha512-qI5+oFNEGi3L5HAxDwN2LA4Gg7irF70Zs25edhjld9QemOgp0CbvMtbFcMvFtEo1OityPrcCzkQFB8JP/hxgow==", + "license": "MIT", + "bin": { + "pegjs": "bin/pegjs" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.13.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz", + "integrity": "sha512-P6tPt9jXbL9HVu/SSRERNYaYG++MjnscnegFh9pPHihfoBSujsrka0hyuymMzeJKFWrcG8wvCKy8rCe8e5nDUQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.1", + "pg-protocol": "^1.7.1", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.2", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", + "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", + "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg-types/node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/pg-connection-string": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "license": "MIT" + }, + "node_modules/pgpass": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/playwright": { + "version": "1.50.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.50.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.50.1", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-interval": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.5.1", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-ms": { + "version": "9.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "dev": true, + "license": "ISC" + }, + "node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.6.3", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-package-json-fast": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/response-time": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/response-time/-/response-time-2.3.3.tgz", + "integrity": "sha512-SsjjOPHl/FfrTQNgmc5oen8Hr1Jxpn6LlHNXxCIFdYMHuK1kMeYMobb9XN3mvxaGQm3dbegqYFMX4+GDORfbWg==", + "dependencies": { + "depd": "~2.0.0", + "on-headers": "~1.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/rollup": { + "version": "4.34.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.34.8", + "@rollup/rollup-android-arm64": "4.34.8", + "@rollup/rollup-darwin-arm64": "4.34.8", + "@rollup/rollup-darwin-x64": "4.34.8", + "@rollup/rollup-freebsd-arm64": "4.34.8", + "@rollup/rollup-freebsd-x64": "4.34.8", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", + "@rollup/rollup-linux-arm-musleabihf": "4.34.8", + "@rollup/rollup-linux-arm64-gnu": "4.34.8", + "@rollup/rollup-linux-arm64-musl": "4.34.8", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", + "@rollup/rollup-linux-riscv64-gnu": "4.34.8", + "@rollup/rollup-linux-s390x-gnu": "4.34.8", + "@rollup/rollup-linux-x64-gnu": "4.34.8", + "@rollup/rollup-linux-x64-musl": "4.34.8", + "@rollup/rollup-win32-arm64-msvc": "4.34.8", + "@rollup/rollup-win32-ia32-msvc": "4.34.8", + "@rollup/rollup-win32-x64-msvc": "4.34.8", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "license": "MIT" + }, + "node_modules/run-applescript": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^0.5.2", + "http-errors": "^2.0.0", + "mime-types": "^2.1.35", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/send/node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/sirv": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/snappy": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/snappy/-/snappy-7.2.2.tgz", + "integrity": "sha512-iADMq1kY0v3vJmGTuKcFWSXt15qYUz7wFkArOrsSg0IFfI3nJqIJvK2/ZbEIndg7erIJLtAVX2nSOqPz7DcwbA==", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/snappy-android-arm-eabi": "7.2.2", + "@napi-rs/snappy-android-arm64": "7.2.2", + "@napi-rs/snappy-darwin-arm64": "7.2.2", + "@napi-rs/snappy-darwin-x64": "7.2.2", + "@napi-rs/snappy-freebsd-x64": "7.2.2", + "@napi-rs/snappy-linux-arm-gnueabihf": "7.2.2", + "@napi-rs/snappy-linux-arm64-gnu": "7.2.2", + "@napi-rs/snappy-linux-arm64-musl": "7.2.2", + "@napi-rs/snappy-linux-x64-gnu": "7.2.2", + "@napi-rs/snappy-linux-x64-musl": "7.2.2", + "@napi-rs/snappy-win32-arm64-msvc": "7.2.2", + "@napi-rs/snappy-win32-ia32-msvc": "7.2.2", + "@napi-rs/snappy-win32-x64-msvc": "7.2.2" + } + }, + "node_modules/socks": { + "version": "2.8.4", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/sqlstring-sqlite": { + "version": "0.1.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ssri/node_modules/minipass": { + "version": "3.3.6", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ssri/node_modules/yallist": { + "version": "4.0.0", + "license": "ISC", + "optional": true + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "engines": { + "node": "*" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.8.0", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superjson": { + "version": "2.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-tags": { + "version": "1.0.0", + "dev": true + }, + "node_modules/swagger-autogen": { + "version": "2.23.7", + "resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.7.tgz", + "integrity": "sha512-vr7uRmuV0DCxWc0wokLJAwX3GwQFJ0jwN+AWk0hKxre2EZwusnkGSGdVFd82u7fQLgwSTnbWkxUL7HXuz5LTZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^7.4.1", + "deepmerge": "^4.2.2", + "glob": "^7.1.7", + "json5": "^2.2.3" + } + }, + "node_modules/swagger-autogen/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/swagger-autogen/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/swagger-autogen/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-autogen/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.20.1.tgz", + "integrity": "sha512-qBPCis2w8nP4US7SvUxdJD3OwKcqiWeZmjN2VWhq2v+ESZEXOP/7n4DeiOiiZcGYTKMHAHUUrroHaTsjUWTEGw==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.9.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.2", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "license": "ISC" + }, + "node_modules/tarn": { + "version": "3.0.2", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, + "node_modules/tildify": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.77", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.77" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.77", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.1", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-morph": { + "version": "25.0.1", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.26.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.19.3", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.24.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.24.1", + "@typescript-eslint/parser": "8.24.1", + "@typescript-eslint/utils": "8.24.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-polyfill": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.13.tgz", + "integrity": "sha512-tXzkojrv2SujumYthZ/WjF7jaSfNhSXlYMpE5AYdL2I3D7DCeo+mch8KtW2rUuKjDg+3VXODXHVgipt8yGY/eQ==" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.24.2", + "postcss": "^8.5.2", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-hot-client": { + "version": "0.2.4", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0" + } + }, + "node_modules/vite-node": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-plugin-inspect": { + "version": "0.8.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.3", + "debug": "^4.3.7", + "error-stack-parser-es": "^0.1.5", + "fs-extra": "^11.2.0", + "open": "^10.1.0", + "perfect-debounce": "^1.0.0", + "picocolors": "^1.1.1", + "sirv": "^3.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.1" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/vite-plugin-vue-devtools": { + "version": "7.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-core": "^7.7.2", + "@vue/devtools-kit": "^7.7.2", + "@vue/devtools-shared": "^7.7.2", + "execa": "^9.5.1", + "sirv": "^3.0.0", + "vite-plugin-inspect": "0.8.9", + "vite-plugin-vue-inspector": "^5.3.1" + }, + "engines": { + "node": ">=v14.21.3" + }, + "peerDependencies": { + "vite": "^3.1.0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0" + } + }, + "node_modules/vite-plugin-vue-inspector": { + "version": "5.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.0", + "@babel/plugin-proposal-decorators": "^7.23.0", + "@babel/plugin-syntax-import-attributes": "^7.22.5", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-transform-typescript": "^7.22.15", + "@vue/babel-plugin-jsx": "^1.1.5", + "@vue/compiler-dom": "^3.3.4", + "kolorist": "^1.8.0", + "magic-string": "^0.30.4" + }, + "peerDependencies": { + "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.24.2", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/vitest": { + "version": "3.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "3.0.6", + "@vitest/mocker": "3.0.6", + "@vitest/pretty-format": "^3.0.6", + "@vitest/runner": "3.0.6", + "@vitest/snapshot": "3.0.6", + "@vitest/spy": "3.0.6", + "@vitest/utils": "3.0.6", + "chai": "^5.2.0", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.6", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.6", + "@vitest/ui": "3.0.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.13", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-sfc": "3.5.13", + "@vue/runtime-dom": "3.5.13", + "@vue/server-renderer": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-eslint-parser": { + "version": "9.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "eslint-scope": "^7.1.1", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.1", + "esquery": "^1.4.0", + "lodash": "^4.17.21", + "semver": "^7.3.6" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-scope": { + "version": "7.2.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-eslint-parser/node_modules/espree": { + "version": "9.6.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-i18n": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.2.tgz", + "integrity": "sha512-MfdkdKGUHN+jkkaMT5Zbl4FpRmN7kfelJIwKoUpJ32ONIxdFhzxZiLTVaAXkAwvH3y9GmWpoiwjDqbPIkPIMFA==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.1.2", + "@intlify/shared": "11.1.2", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-router": { + "version": "4.5.0", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~2.4.11", + "@vue/language-core": "2.2.2" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vuetify": { + "version": "3.7.12", + "license": "MIT", + "engines": { + "node": "^12.20 || >=14.13" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/johnleider" + }, + "peerDependencies": { + "typescript": ">=4.7", + "vite-plugin-vuetify": ">=1.0.0", + "vue": "^3.3.0", + "webpack-plugin-vuetify": ">=2.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vite-plugin-vuetify": { + "optional": true + }, + "webpack-plugin-vuetify": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { + "version": "5.0.0", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.1.1", + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT", + "optional": true + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-loki": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/winston-loki/-/winston-loki-6.1.3.tgz", + "integrity": "sha512-DjWtJ230xHyYQWr9mZJa93yhwHttn3JEtSYWP8vXZWJOahiQheUhf+88dSIidbGXB3u0oLweV6G1vkL/ouT62Q==", + "dependencies": { + "async-exit-hook": "2.0.1", + "btoa": "^1.2.1", + "protobufjs": "^7.2.4", + "url-polyfill": "^1.1.12", + "winston-transport": "^4.3.0" + }, + "optionalDependencies": { + "snappy": "^7.2.2" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.0", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..09703f20 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "dwengo-1-monorepo", + "version": "0.0.1", + "description": "Monorepo for Dwengo-1", + "private": true, + "type": "module", + "scripts": { + "build": "npm run build --ws", + "format": "npm run format --ws", + "format-check": "npm run format-check --ws", + "lint": "npm run lint --ws", + "test:unit": "npm run test:unit --ws" + }, + "workspaces": [ + "backend", + "frontend", + "docs" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/SELab-2/Dwengo-1.git" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/SELab-2/Dwengo-1/issues" + }, + "homepage": "https://sel2-1.ugent.be/", + "devDependencies": { + "@eslint/compat": "^1.2.6", + "@eslint/js": "^9.20.0", + "@types/eslint-config-prettier": "^6.11.3", + "@typescript-eslint/eslint-plugin": "^8.24.1", + "@typescript-eslint/parser": "^8.24.1", + "eslint": "^9.20.1", + "eslint-config-prettier": "^10.0.1", + "jiti": "^2.4.2", + "typescript-eslint": "^8.24.1" + } +} diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 00000000..175599a4 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,13 @@ +/** + * @type {import("prettier").Options} + */ +export default { + printWidth: 150, + semi: true, + singleQuote: true, + trailingComma: 'es5', + bracketSpacing: true, + objectWrap: 'preserve', + bracketSameLine: false, + arrowParens: 'always', +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..b41449cf --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,125 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ESNext", + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + "experimentalDecorators": true, + /* Enable experimental support for legacy experimental decorators. */ + "emitDecoratorMetadata": true, + /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + "moduleDetection": "force", + /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ESNext", + /* Specify what module code is generated. */ + // "rootDir": "./src", + /* Specify the root folder within your source files. */ + "moduleResolution": "node", + /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true, + /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./dist", + /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, + /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, + /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, + /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true, + /* Skip type checking all .d.ts files. */ + "resolveJsonModule": true + } +}