From 580789ac83a4529ee34bb8471f2ef200b7fb8735 Mon Sep 17 00:00:00 2001 From: gabdevele Date: Tue, 2 Jun 2026 19:22:00 +0200 Subject: [PATCH] Add Visione backend foundation --- .env.example | 7 + .gitignore | 565 +++++++++++++ .vscode/settings.json | 6 + AGENTS.md | 34 + CMakeLists.txt | 71 ++ README.md | 91 ++ cmake/CPM.cmake | 1291 +++++++++++++++++++++++++++++ common/Auth.cc | 66 ++ common/Auth.h | 23 + common/Crypto.cc | 68 ++ common/Crypto.h | 14 + common/Email.cc | 119 +++ common/Email.h | 13 + common/GeoJson.cc | 59 ++ common/GeoJson.h | 9 + common/Http.cc | 35 + common/Http.h | 17 + common/Validation.cc | 45 + common/Validation.h | 16 + config.json | 62 ++ controllers/AdminController.cc | 116 +++ controllers/AdminController.h | 46 + controllers/ApiController.cc | 72 ++ controllers/ApiController.h | 18 + controllers/AuthController.cc | 98 +++ controllers/AuthController.h | 39 + controllers/ReportController.cc | 176 ++++ controllers/ReportController.h | 57 ++ docker-compose.yml | 27 + docs/API_SPEC.md | 37 + docs/ARCHITECTURE.md | 23 + docs/DATABASE_SCHEMA.md | 24 + docs/LLM_CONTEXT.md | 20 + docs/MILESTONE_01.md | 20 + docs/PROJECT_SPEC.md | 36 + main.cc | 28 + migrations/001_initial_schema.sql | 244 ++++++ plugins/RequestLogger.cc | 33 + plugins/RequestLogger.h | 13 + prompt.md | 185 +++++ repositories/MigrationRunner.cc | 103 +++ repositories/MigrationRunner.h | 13 + repositories/Sql.cc | 44 + repositories/Sql.h | 14 + services/AdminService.cc | 141 ++++ services/AdminService.h | 21 + services/AuthService.cc | 233 ++++++ services/AuthService.h | 19 + services/ReportService.cc | 376 +++++++++ services/ReportService.h | 35 + test/CMakeLists.txt | 16 + test/test_main.cc | 70 ++ 52 files changed, 5008 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 cmake/CPM.cmake create mode 100644 common/Auth.cc create mode 100644 common/Auth.h create mode 100644 common/Crypto.cc create mode 100644 common/Crypto.h create mode 100644 common/Email.cc create mode 100644 common/Email.h create mode 100644 common/GeoJson.cc create mode 100644 common/GeoJson.h create mode 100644 common/Http.cc create mode 100644 common/Http.h create mode 100644 common/Validation.cc create mode 100644 common/Validation.h create mode 100644 config.json create mode 100644 controllers/AdminController.cc create mode 100644 controllers/AdminController.h create mode 100644 controllers/ApiController.cc create mode 100644 controllers/ApiController.h create mode 100644 controllers/AuthController.cc create mode 100644 controllers/AuthController.h create mode 100644 controllers/ReportController.cc create mode 100644 controllers/ReportController.h create mode 100644 docker-compose.yml create mode 100644 docs/API_SPEC.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DATABASE_SCHEMA.md create mode 100644 docs/LLM_CONTEXT.md create mode 100644 docs/MILESTONE_01.md create mode 100644 docs/PROJECT_SPEC.md create mode 100644 main.cc create mode 100644 migrations/001_initial_schema.sql create mode 100644 plugins/RequestLogger.cc create mode 100644 plugins/RequestLogger.h create mode 100644 prompt.md create mode 100644 repositories/MigrationRunner.cc create mode 100644 repositories/MigrationRunner.h create mode 100644 repositories/Sql.cc create mode 100644 repositories/Sql.h create mode 100644 services/AdminService.cc create mode 100644 services/AdminService.h create mode 100644 services/AuthService.cc create mode 100644 services/AuthService.h create mode 100644 services/ReportService.cc create mode 100644 services/ReportService.h create mode 100644 test/CMakeLists.txt create mode 100644 test/test_main.cc diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e945bc --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +MYSQL_PORT=3306 +MYSQL_DATABASE=visione +MYSQL_USER=visione +MYSQL_PASSWORD=visione +MYSQL_ROOT_PASSWORD=root +MAILHOG_SMTP_PORT=1025 +MAILHOG_WEB_PORT=8025 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b08cb8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,565 @@ +# Created by https://www.toptal.com/developers/gitignore/api/intellij+all,visualstudio,visualstudiocode,cmake,c,c++ +# Edit at https://www.toptal.com/developers/gitignore?templates=intellij+all,visualstudio,visualstudiocode,cmake,c,c++ + +### C ### +# Prerequisites +*.d + +# Object files +*.o +*.ko +*.obj +*.elf + +# Linker output +*.ilk +*.map +*.exp + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex + +# Debug files +*.dSYM/ +*.su +*.idb +*.pdb + +# Kernel Module Compile Results +*.mod* +*.cmd +.tmp_versions/ +modules.order +Module.symvers +Mkfile.old +dkms.conf + +### C++ ### +# Prerequisites + +# Compiled Object files +*.slo + +# Precompiled Headers + +# Linker files + +# Debugger Files + +# Compiled Dynamic libraries + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai + +# Executables + +### CMake ### +CMakeLists.txt.user +CMakeCache.txt +CMakeFiles +CMakeScripts +Testing +Makefile +cmake_install.cmake +install_manifest.txt +compile_commands.json +CTestTestfile.cmake +_deps +CMakeUserPresets.json +build/ +.cache/ +.env + +### CMake Patch ### +# External projects +*-prefix/ + +### Intellij+all ### +# 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 + +# 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 + +# 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 + +### Intellij+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +*.code-workspace + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### 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/master/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 +*.meta +*.iobj +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.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, .xml, .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 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/ + +# 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 + +### VisualStudio Patch ### +# Additional files built by Visual Studio +*.tlog + +# End of https://www.toptal.com/developers/gitignore/api/intellij+all,visualstudio,visualstudiocode,cmake,c,c++ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2d44003 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "C_Cpp.default.compileCommands": "${workspaceFolder}/build/compile_commands.json", + "C_Cpp.default.cppStandard": "c++20", + "C_Cpp.default.intelliSenseMode": "linux-gcc-x64", + "C_Cpp.errorSquiggles": "enabled" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..19570bc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Visione Backend Agent Guide + +## Project Context +- This repository is the backend for **Visione**, an urban issue reporting webapp. +- The backend is C++20 with Drogon. Keep code, symbols, API fields, database names and comments in English. +- User-facing product labels may be Italian when they represent UI/business vocabulary. +- Docker is for local external services only: MySQL, MailHog and future Redis-like dependencies. + +## Authoritative References +- Local Drogon source: `/home/gabdevele/Projects/snitchitaly/backend/drogon` +- Official Drogon docs: `https://drogonframework.github.io/drogon-docs/` +- Project docs start from `docs/LLM_CONTEXT.md`. + +## Architecture Rules +- Controllers parse HTTP input and shape HTTP responses only. +- Services own business rules. +- Repositories/database helpers own SQL access. +- Prefer small, readable C++ over clever abstractions. +- Use parameterized SQL; do not concatenate user input into SQL. +- Keep local development simple. Do not introduce production infrastructure unless requested. + +## Commands +- Configure: `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug` +- Build: `cmake --build build` +- Test: `ctest --test-dir build --output-on-failure` +- Local services: `docker compose up -d` +- App: `./build/visione-backend config.json` + +## Development Workflow +- Update docs when behavior, schema, setup or API contracts change. +- After each completed and verified feature, create a short commit. +- Keep commit messages concise, for example `Add MailHog config` or `Add report API`. +- Do not spend excessive time on exhaustive tests; cover high-risk behavior and regressions. + diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..039545d --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,71 @@ +cmake_minimum_required(VERSION 3.20) + +project(visione-backend VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +option(USE_SYSTEM_DROGON "Use an installed Drogon package instead of CPM" OFF) + +if (USE_SYSTEM_DROGON) + find_package(Drogon CONFIG REQUIRED) +else () + set(CPM_SOURCE_CACHE "${CMAKE_CURRENT_SOURCE_DIR}/.cache/cpm" CACHE PATH "CPM dependency cache") + include(cmake/CPM.cmake) + + CPMAddPackage( + NAME Drogon + GITHUB_REPOSITORY drogonframework/drogon + GIT_TAG v1.9.13 + OPTIONS + "BUILD_CTL OFF" + "BUILD_EXAMPLES OFF" + "BUILD_ORM ON" + "BUILD_MYSQL ON" + "BUILD_POSTGRESQL OFF" + "BUILD_REDIS OFF" + "BUILD_SQLITE OFF" + "BUILD_BROTLI OFF" + "BUILD_YAML_CONFIG OFF" + "BUILD_TESTING OFF" + ) +endif () + +set(APP_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/controllers/ApiController.cc + ${CMAKE_CURRENT_SOURCE_DIR}/controllers/AdminController.cc + ${CMAKE_CURRENT_SOURCE_DIR}/controllers/AuthController.cc + ${CMAKE_CURRENT_SOURCE_DIR}/controllers/ReportController.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/Auth.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/Crypto.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/Email.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/GeoJson.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/Http.cc + ${CMAKE_CURRENT_SOURCE_DIR}/common/Validation.cc + ${CMAKE_CURRENT_SOURCE_DIR}/plugins/RequestLogger.cc + ${CMAKE_CURRENT_SOURCE_DIR}/repositories/MigrationRunner.cc + ${CMAKE_CURRENT_SOURCE_DIR}/repositories/Sql.cc + ${CMAKE_CURRENT_SOURCE_DIR}/services/AdminService.cc + ${CMAKE_CURRENT_SOURCE_DIR}/services/AuthService.cc + ${CMAKE_CURRENT_SOURCE_DIR}/services/ReportService.cc +) + +add_executable(${PROJECT_NAME} + main.cc + ${APP_SOURCES} +) + +target_include_directories(${PROJECT_NAME} + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_link_libraries(${PROJECT_NAME} + PRIVATE + Drogon::Drogon +) + +enable_testing() +add_subdirectory(test) diff --git a/README.md b/README.md new file mode 100644 index 0000000..77710ed --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Visione Backend + +C++20 API backend built with Drogon for the Visione urban reporting project. +Docker is used only for local external services: MySQL and MailHog. + +## Setup + +Install native build dependencies first. On Ubuntu: + +```bash +sudo apt install build-essential cmake ninja-build git pkg-config \ + libjsoncpp-dev uuid-dev zlib1g-dev libssl-dev \ + libmariadb-dev libmariadb-dev-compat +``` + +If CMake prints `MySql was not found`, the backend will compile without MySQL +support and `/api/v1/ready` will not be useful. Install the MariaDB/MySQL client +development packages above and re-run CMake. + +```bash +cp .env.example .env +docker compose up -d + +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build +./build/visione-backend config.json +``` + +The API listens on `http://localhost:5555`. +MailHog listens on `http://localhost:8025`. + +```bash +curl http://localhost:5555/api/v1/health +curl http://localhost:5555/api/v1/ready +curl -X POST http://localhost:5555/api/v1/echo \ + -H 'Content-Type: application/json' \ + -d '{"message":"hello"}' +``` + +Use `-DUSE_SYSTEM_DROGON=ON` only if Drogon is already installed and +discoverable by CMake. The default build uses CPM.cmake and downloads Drogon +`v1.9.13`. + +## Project Docs + +- `AGENTS.md`: durable instructions for coding agents. +- `docs/LLM_CONTEXT.md`: short project context for future LLM sessions. +- `docs/MILESTONE_01.md`: what this milestone implemented and what comes next. +- `docs/API_SPEC.md`: API surface. +- `docs/DATABASE_SCHEMA.md`: schema overview. +- `docs/ARCHITECTURE.md`: backend structure. + +## Development Workflow + +1. Keep MySQL running with `docker compose up -d`. +2. Add API endpoints in `controllers/` using `HttpController`. +3. Register routes in the controller `METHOD_LIST_BEGIN` block with + `ADD_METHOD_TO(Controller::handler, "/api/v1/path", drogon::Get)`. +4. Implement handlers with the Drogon signature: + `void handler(const HttpRequestPtr &, std::function &&callback)`. +5. Return JSON with `HttpResponse::newHttpJsonResponse(...)`. +6. Read app/database settings from `config.json`; do not hardcode connection + details in controllers. +7. Add focused endpoint or helper tests for high-risk behavior, then run: + +```bash +cmake --build build +ctest --test-dir build --output-on-failure +``` + +The `RequestLogger` plugin logs every response as method, path, status and +client address. + +## Current Endpoints + +- `GET /api/v1/health`: process health check, no external dependency. +- `GET /api/v1/ready`: checks MySQL with `SELECT 1`. +- Auth, reports and admin endpoints are documented in `docs/API_SPEC.md`. + +## Drogon Docs + +Official docs entrypoint: https://drogonframework.github.io/drogon-docs/ + +Useful mirrored sections: + +- Install and CPM: https://github.com/drogonframework/drogon/wiki/ENG-02-Installation +- Controller introduction: https://github.com/drogonframework/drogon/wiki/ENG-04-0-Controller-Introduction +- HttpController: https://github.com/drogonframework/drogon/wiki/ENG-04-2-Controller-HttpController +- Configuration file: https://github.com/drogonframework/drogon/wiki/ENG-11-Configuration-File +- Database client: https://github.com/drogonframework/drogon/wiki/ENG-08-1-Database-DbClient +- Testing: https://github.com/drogonframework/drogon/wiki/ENG-19-Testing-Framework diff --git a/cmake/CPM.cmake b/cmake/CPM.cmake new file mode 100644 index 0000000..eea2921 --- /dev/null +++ b/cmake/CPM.cmake @@ -0,0 +1,1291 @@ +# CPM.cmake - CMake's missing package manager +# =========================================== +# See https://github.com/cpm-cmake/CPM.cmake for usage and update instructions. +# +# MIT License +# ----------- +#[[ + Copyright (c) 2019-2023 Lars Melchior and contributors + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +]] + +cmake_minimum_required(VERSION 3.14 FATAL_ERROR) + +# Initialize logging prefix +if(NOT CPM_INDENT) + set(CPM_INDENT + "CPM:" + CACHE INTERNAL "" + ) +endif() + +if(NOT COMMAND cpm_message) + function(cpm_message) + message(${ARGV}) + endfunction() +endif() + +if(DEFINED EXTRACTED_CPM_VERSION) + set(CURRENT_CPM_VERSION "${EXTRACTED_CPM_VERSION}${CPM_DEVELOPMENT}") +else() + set(CURRENT_CPM_VERSION 0.40.8) +endif() + +get_filename_component(CPM_CURRENT_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}" REALPATH) +if(CPM_DIRECTORY) + if(NOT CPM_DIRECTORY STREQUAL CPM_CURRENT_DIRECTORY) + if(CPM_VERSION VERSION_LESS CURRENT_CPM_VERSION) + message( + AUTHOR_WARNING + "${CPM_INDENT} \ +A dependency is using a more recent CPM version (${CURRENT_CPM_VERSION}) than the current project (${CPM_VERSION}). \ +It is recommended to upgrade CPM to the most recent version. \ +See https://github.com/cpm-cmake/CPM.cmake for more information." + ) + endif() + if(${CMAKE_VERSION} VERSION_LESS "3.17.0") + include(FetchContent) + endif() + return() + endif() + + get_property( + CPM_INITIALIZED GLOBAL "" + PROPERTY CPM_INITIALIZED + SET + ) + if(CPM_INITIALIZED) + return() + endif() +endif() + +if(CURRENT_CPM_VERSION MATCHES "development-version") + message( + WARNING "${CPM_INDENT} Your project is using an unstable development version of CPM.cmake. \ +Please update to a recent release if possible. \ +See https://github.com/cpm-cmake/CPM.cmake for details." + ) +endif() + +set_property(GLOBAL PROPERTY CPM_INITIALIZED true) + +macro(cpm_set_policies) + # the policy allows us to change options without caching + cmake_policy(SET CMP0077 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) + + # the policy allows us to change set(CACHE) without caching + if(POLICY CMP0126) + cmake_policy(SET CMP0126 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0126 NEW) + endif() + + # The policy uses the download time for timestamp, instead of the timestamp in the archive. This + # allows for proper rebuilds when a projects url changes + if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0135 NEW) + endif() + + # treat relative git repository paths as being relative to the parent project's remote + if(POLICY CMP0150) + cmake_policy(SET CMP0150 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0150 NEW) + endif() +endmacro() +cpm_set_policies() + +option(CPM_USE_LOCAL_PACKAGES "Always try to use `find_package` to get dependencies" + $ENV{CPM_USE_LOCAL_PACKAGES} +) +option(CPM_LOCAL_PACKAGES_ONLY "Only use `find_package` to get dependencies" + $ENV{CPM_LOCAL_PACKAGES_ONLY} +) +option(CPM_DOWNLOAD_ALL "Always download dependencies from source" $ENV{CPM_DOWNLOAD_ALL}) +option(CPM_DONT_UPDATE_MODULE_PATH "Don't update the module path to allow using find_package" + $ENV{CPM_DONT_UPDATE_MODULE_PATH} +) +option(CPM_DONT_CREATE_PACKAGE_LOCK "Don't create a package lock file in the binary path" + $ENV{CPM_DONT_CREATE_PACKAGE_LOCK} +) +option(CPM_INCLUDE_ALL_IN_PACKAGE_LOCK + "Add all packages added through CPM.cmake to the package lock" + $ENV{CPM_INCLUDE_ALL_IN_PACKAGE_LOCK} +) +option(CPM_USE_NAMED_CACHE_DIRECTORIES + "Use additional directory of package name in cache on the most nested level." + $ENV{CPM_USE_NAMED_CACHE_DIRECTORIES} +) + +set(CPM_VERSION + ${CURRENT_CPM_VERSION} + CACHE INTERNAL "" +) +set(CPM_DIRECTORY + ${CPM_CURRENT_DIRECTORY} + CACHE INTERNAL "" +) +set(CPM_FILE + ${CMAKE_CURRENT_LIST_FILE} + CACHE INTERNAL "" +) +set(CPM_PACKAGES + "" + CACHE INTERNAL "" +) +set(CPM_DRY_RUN + OFF + CACHE INTERNAL "Don't download or configure dependencies (for testing)" +) + +if(DEFINED ENV{CPM_SOURCE_CACHE}) + set(CPM_SOURCE_CACHE_DEFAULT $ENV{CPM_SOURCE_CACHE}) +else() + set(CPM_SOURCE_CACHE_DEFAULT OFF) +endif() + +set(CPM_SOURCE_CACHE + ${CPM_SOURCE_CACHE_DEFAULT} + CACHE PATH "Directory to download CPM dependencies" +) + +if(NOT CPM_DONT_UPDATE_MODULE_PATH AND NOT DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) + set(CPM_MODULE_PATH + "${CMAKE_BINARY_DIR}/CPM_modules" + CACHE INTERNAL "" + ) + # remove old modules + file(REMOVE_RECURSE ${CPM_MODULE_PATH}) + file(MAKE_DIRECTORY ${CPM_MODULE_PATH}) + # locally added CPM modules should override global packages + set(CMAKE_MODULE_PATH "${CPM_MODULE_PATH};${CMAKE_MODULE_PATH}") +endif() + +if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + set(CPM_PACKAGE_LOCK_FILE + "${CMAKE_BINARY_DIR}/cpm-package-lock.cmake" + CACHE INTERNAL "" + ) + file(WRITE ${CPM_PACKAGE_LOCK_FILE} + "# CPM Package Lock\n# This file should be committed to version control\n\n" + ) +endif() + +include(FetchContent) + +# Try to infer package name from git repository uri (path or url) +function(cpm_package_name_from_git_uri URI RESULT) + if("${URI}" MATCHES "([^/:]+)/?.git/?$") + set(${RESULT} + ${CMAKE_MATCH_1} + PARENT_SCOPE + ) + else() + unset(${RESULT} PARENT_SCOPE) + endif() +endfunction() + +# Try to infer package name and version from a url +function(cpm_package_name_and_ver_from_url url outName outVer) + if(url MATCHES "[/\\?]([a-zA-Z0-9_\\.-]+)\\.(tar|tar\\.gz|tar\\.bz2|zip|ZIP)(\\?|/|$)") + # We matched an archive + set(filename "${CMAKE_MATCH_1}") + + if(filename MATCHES "([a-zA-Z0-9_\\.-]+)[_-]v?(([0-9]+\\.)*[0-9]+[a-zA-Z0-9]*)") + # We matched - (ie foo-1.2.3) + set(${outName} + "${CMAKE_MATCH_1}" + PARENT_SCOPE + ) + set(${outVer} + "${CMAKE_MATCH_2}" + PARENT_SCOPE + ) + elseif(filename MATCHES "(([0-9]+\\.)+[0-9]+[a-zA-Z0-9]*)") + # We couldn't find a name, but we found a version + # + # In many cases (which we don't handle here) the url would look something like + # `irrelevant/ACTUAL_PACKAGE_NAME/irrelevant/1.2.3.zip`. In such a case we can't possibly + # distinguish the package name from the irrelevant bits. Moreover if we try to match the + # package name from the filename, we'd get bogus at best. + unset(${outName} PARENT_SCOPE) + set(${outVer} + "${CMAKE_MATCH_1}" + PARENT_SCOPE + ) + else() + # Boldly assume that the file name is the package name. + # + # Yes, something like `irrelevant/ACTUAL_NAME/irrelevant/download.zip` will ruin our day, but + # such cases should be quite rare. No popular service does this... we think. + set(${outName} + "${filename}" + PARENT_SCOPE + ) + unset(${outVer} PARENT_SCOPE) + endif() + else() + # No ideas yet what to do with non-archives + unset(${outName} PARENT_SCOPE) + unset(${outVer} PARENT_SCOPE) + endif() +endfunction() + +function(cpm_find_package NAME VERSION) + string(REPLACE " " ";" EXTRA_ARGS "${ARGN}") + find_package(${NAME} ${VERSION} ${EXTRA_ARGS} QUIET) + if(${CPM_ARGS_NAME}_FOUND) + if(DEFINED ${CPM_ARGS_NAME}_VERSION) + set(VERSION ${${CPM_ARGS_NAME}_VERSION}) + endif() + cpm_message(STATUS "${CPM_INDENT} Using local package ${CPM_ARGS_NAME}@${VERSION}") + CPMRegisterPackage(${CPM_ARGS_NAME} "${VERSION}") + set(CPM_PACKAGE_FOUND + YES + PARENT_SCOPE + ) + else() + set(CPM_PACKAGE_FOUND + NO + PARENT_SCOPE + ) + endif() +endfunction() + +# Create a custom FindXXX.cmake module for a CPM package This prevents `find_package(NAME)` from +# finding the system library +function(cpm_create_module_file Name) + if(NOT CPM_DONT_UPDATE_MODULE_PATH) + if(DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) + # Redirect find_package calls to the CPM package. This is what FetchContent does when you set + # OVERRIDE_FIND_PACKAGE. The CMAKE_FIND_PACKAGE_REDIRECTS_DIR works for find_package in CONFIG + # mode, unlike the Find${Name}.cmake fallback. CMAKE_FIND_PACKAGE_REDIRECTS_DIR is not defined + # in script mode, or in CMake < 3.24. + # https://cmake.org/cmake/help/latest/module/FetchContent.html#fetchcontent-find-package-integration-examples + string(TOLOWER ${Name} NameLower) + file(WRITE ${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/${NameLower}-config.cmake + "include(\"\${CMAKE_CURRENT_LIST_DIR}/${NameLower}-extra.cmake\" OPTIONAL)\n" + "include(\"\${CMAKE_CURRENT_LIST_DIR}/${Name}Extra.cmake\" OPTIONAL)\n" + ) + file(WRITE ${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/${NameLower}-config-version.cmake + "set(PACKAGE_VERSION_COMPATIBLE TRUE)\n" "set(PACKAGE_VERSION_EXACT TRUE)\n" + ) + else() + file(WRITE ${CPM_MODULE_PATH}/Find${Name}.cmake + "include(\"${CPM_FILE}\")\n${ARGN}\nset(${Name}_FOUND TRUE)" + ) + endif() + endif() +endfunction() + +# Find a package locally or fallback to CPMAddPackage +function(CPMFindPackage) + set(oneValueArgs NAME VERSION GIT_TAG FIND_PACKAGE_ARGUMENTS) + + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "" ${ARGN}) + + if(NOT DEFINED CPM_ARGS_VERSION) + if(DEFINED CPM_ARGS_GIT_TAG) + cpm_get_version_from_git_tag("${CPM_ARGS_GIT_TAG}" CPM_ARGS_VERSION) + endif() + endif() + + set(downloadPackage ${CPM_DOWNLOAD_ALL}) + if(DEFINED CPM_DOWNLOAD_${CPM_ARGS_NAME}) + set(downloadPackage ${CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + elseif(DEFINED ENV{CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + set(downloadPackage $ENV{CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + endif() + if(downloadPackage) + CPMAddPackage(${ARGN}) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + cpm_find_package(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}" ${CPM_ARGS_FIND_PACKAGE_ARGUMENTS}) + + if(NOT CPM_PACKAGE_FOUND) + CPMAddPackage(${ARGN}) + cpm_export_variables(${CPM_ARGS_NAME}) + endif() + +endfunction() + +# checks if a package has been added before +function(cpm_check_if_package_already_added CPM_ARGS_NAME CPM_ARGS_VERSION) + if("${CPM_ARGS_NAME}" IN_LIST CPM_PACKAGES) + CPMGetPackageVersion(${CPM_ARGS_NAME} CPM_PACKAGE_VERSION) + if("${CPM_PACKAGE_VERSION}" VERSION_LESS "${CPM_ARGS_VERSION}") + message( + WARNING + "${CPM_INDENT} Requires a newer version of ${CPM_ARGS_NAME} (${CPM_ARGS_VERSION}) than currently included (${CPM_PACKAGE_VERSION})." + ) + endif() + cpm_get_fetch_properties(${CPM_ARGS_NAME}) + set(${CPM_ARGS_NAME}_ADDED NO) + set(CPM_PACKAGE_ALREADY_ADDED + YES + PARENT_SCOPE + ) + cpm_export_variables(${CPM_ARGS_NAME}) + else() + set(CPM_PACKAGE_ALREADY_ADDED + NO + PARENT_SCOPE + ) + endif() +endfunction() + +# Parse the argument of CPMAddPackage in case a single one was provided and convert it to a list of +# arguments which can then be parsed idiomatically. For example gh:foo/bar@1.2.3 will be converted +# to: GITHUB_REPOSITORY;foo/bar;VERSION;1.2.3 +function(cpm_parse_add_package_single_arg arg outArgs) + # Look for a scheme + if("${arg}" MATCHES "^([a-zA-Z]+):(.+)$") + string(TOLOWER "${CMAKE_MATCH_1}" scheme) + set(uri "${CMAKE_MATCH_2}") + + # Check for CPM-specific schemes + if(scheme STREQUAL "gh") + set(out "GITHUB_REPOSITORY;${uri}") + set(packageType "git") + elseif(scheme STREQUAL "gl") + set(out "GITLAB_REPOSITORY;${uri}") + set(packageType "git") + elseif(scheme STREQUAL "bb") + set(out "BITBUCKET_REPOSITORY;${uri}") + set(packageType "git") + # A CPM-specific scheme was not found. Looks like this is a generic URL so try to determine + # type + elseif(arg MATCHES ".git/?(@|#|$)") + set(out "GIT_REPOSITORY;${arg}") + set(packageType "git") + else() + # Fall back to a URL + set(out "URL;${arg}") + set(packageType "archive") + + # We could also check for SVN since FetchContent supports it, but SVN is so rare these days. + # We just won't bother with the additional complexity it will induce in this function. SVN is + # done by multi-arg + endif() + else() + if(arg MATCHES ".git/?(@|#|$)") + set(out "GIT_REPOSITORY;${arg}") + set(packageType "git") + else() + # Give up + message(FATAL_ERROR "${CPM_INDENT} Can't determine package type of '${arg}'") + endif() + endif() + + # For all packages we interpret @... as version. Only replace the last occurrence. Thus URIs + # containing '@' can be used + string(REGEX REPLACE "@([^@]+)$" ";VERSION;\\1" out "${out}") + + # Parse the rest according to package type + if(packageType STREQUAL "git") + # For git repos we interpret #... as a tag or branch or commit hash + string(REGEX REPLACE "#([^#]+)$" ";GIT_TAG;\\1" out "${out}") + elseif(packageType STREQUAL "archive") + # For archives we interpret #... as a URL hash. + string(REGEX REPLACE "#([^#]+)$" ";URL_HASH;\\1" out "${out}") + # We don't try to parse the version if it's not provided explicitly. cpm_get_version_from_url + # should do this at a later point + else() + # We should never get here. This is an assertion and hitting it means there's a problem with the + # code above. A packageType was set, but not handled by this if-else. + message(FATAL_ERROR "${CPM_INDENT} Unsupported package type '${packageType}' of '${arg}'") + endif() + + set(${outArgs} + ${out} + PARENT_SCOPE + ) +endfunction() + +# Check that the working directory for a git repo is clean +function(cpm_check_git_working_dir_is_clean repoPath gitTag isClean) + + find_package(Git REQUIRED) + + if(NOT GIT_EXECUTABLE) + # No git executable, assume directory is clean + set(${isClean} + TRUE + PARENT_SCOPE + ) + return() + endif() + + # check for uncommitted changes + execute_process( + COMMAND ${GIT_EXECUTABLE} status --porcelain + RESULT_VARIABLE resultGitStatus + OUTPUT_VARIABLE repoStatus + OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET + WORKING_DIRECTORY ${repoPath} + ) + if(resultGitStatus) + # not supposed to happen, assume clean anyway + message(WARNING "${CPM_INDENT} Calling git status on folder ${repoPath} failed") + set(${isClean} + TRUE + PARENT_SCOPE + ) + return() + endif() + + if(NOT "${repoStatus}" STREQUAL "") + set(${isClean} + FALSE + PARENT_SCOPE + ) + return() + endif() + + # check for committed changes + execute_process( + COMMAND ${GIT_EXECUTABLE} diff -s --exit-code ${gitTag} + RESULT_VARIABLE resultGitDiff + OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_QUIET + WORKING_DIRECTORY ${repoPath} + ) + + if(${resultGitDiff} EQUAL 0) + set(${isClean} + TRUE + PARENT_SCOPE + ) + else() + set(${isClean} + FALSE + PARENT_SCOPE + ) + endif() + +endfunction() + +# Add PATCH_COMMAND to CPM_ARGS_UNPARSED_ARGUMENTS. This method consumes a list of files in ARGN +# then generates a `PATCH_COMMAND` appropriate for `ExternalProject_Add()`. This command is appended +# to the parent scope's `CPM_ARGS_UNPARSED_ARGUMENTS`. +function(cpm_add_patches) + # Return if no patch files are supplied. + if(NOT ARGN) + return() + endif() + + # Find the patch program. + find_program(PATCH_EXECUTABLE patch) + if(CMAKE_HOST_WIN32 AND NOT PATCH_EXECUTABLE) + # The Windows git executable is distributed with patch.exe. Find the path to the executable, if + # it exists, then search `../usr/bin` and `../../usr/bin` for patch.exe. + find_package(Git QUIET) + if(GIT_EXECUTABLE) + get_filename_component(extra_search_path ${GIT_EXECUTABLE} DIRECTORY) + get_filename_component(extra_search_path_1up ${extra_search_path} DIRECTORY) + get_filename_component(extra_search_path_2up ${extra_search_path_1up} DIRECTORY) + find_program( + PATCH_EXECUTABLE patch HINTS "${extra_search_path_1up}/usr/bin" + "${extra_search_path_2up}/usr/bin" + ) + endif() + endif() + if(NOT PATCH_EXECUTABLE) + message(FATAL_ERROR "Couldn't find `patch` executable to use with PATCHES keyword.") + endif() + + # Create a temporary + set(temp_list ${CPM_ARGS_UNPARSED_ARGUMENTS}) + + # Ensure each file exists (or error out) and add it to the list. + set(first_item True) + foreach(PATCH_FILE ${ARGN}) + # Make sure the patch file exists, if we can't find it, try again in the current directory. + if(NOT EXISTS "${PATCH_FILE}") + if(NOT EXISTS "${CMAKE_CURRENT_LIST_DIR}/${PATCH_FILE}") + message(FATAL_ERROR "Couldn't find patch file: '${PATCH_FILE}'") + endif() + set(PATCH_FILE "${CMAKE_CURRENT_LIST_DIR}/${PATCH_FILE}") + endif() + + # Convert to absolute path for use with patch file command. + get_filename_component(PATCH_FILE "${PATCH_FILE}" ABSOLUTE) + + # The first patch entry must be preceded by "PATCH_COMMAND" while the following items are + # preceded by "&&". + if(first_item) + set(first_item False) + list(APPEND temp_list "PATCH_COMMAND") + else() + list(APPEND temp_list "&&") + endif() + # Add the patch command to the list + list(APPEND temp_list "${PATCH_EXECUTABLE}" "-p1" "<" "${PATCH_FILE}") + endforeach() + + # Move temp out into parent scope. + set(CPM_ARGS_UNPARSED_ARGUMENTS + ${temp_list} + PARENT_SCOPE + ) + +endfunction() + +# method to overwrite internal FetchContent properties, to allow using CPM.cmake to overload +# FetchContent calls. As these are internal cmake properties, this method should be used carefully +# and may need modification in future CMake versions. Source: +# https://github.com/Kitware/CMake/blob/dc3d0b5a0a7d26d43d6cfeb511e224533b5d188f/Modules/FetchContent.cmake#L1152 +function(cpm_override_fetchcontent contentName) + cmake_parse_arguments(PARSE_ARGV 1 arg "" "SOURCE_DIR;BINARY_DIR" "") + if(NOT "${arg_UNPARSED_ARGUMENTS}" STREQUAL "") + message(FATAL_ERROR "${CPM_INDENT} Unsupported arguments: ${arg_UNPARSED_ARGUMENTS}") + endif() + + string(TOLOWER ${contentName} contentNameLower) + set(prefix "_FetchContent_${contentNameLower}") + + set(propertyName "${prefix}_sourceDir") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} "${arg_SOURCE_DIR}") + + set(propertyName "${prefix}_binaryDir") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} "${arg_BINARY_DIR}") + + set(propertyName "${prefix}_populated") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} TRUE) +endfunction() + +# Download and add a package from source +function(CPMAddPackage) + cpm_set_policies() + + list(LENGTH ARGN argnLength) + if(argnLength EQUAL 1) + cpm_parse_add_package_single_arg("${ARGN}" ARGN) + + # The shorthand syntax implies EXCLUDE_FROM_ALL and SYSTEM + set(ARGN "${ARGN};EXCLUDE_FROM_ALL;YES;SYSTEM;YES;") + endif() + + set(oneValueArgs + NAME + FORCE + VERSION + GIT_TAG + DOWNLOAD_ONLY + GITHUB_REPOSITORY + GITLAB_REPOSITORY + BITBUCKET_REPOSITORY + GIT_REPOSITORY + SOURCE_DIR + FIND_PACKAGE_ARGUMENTS + NO_CACHE + SYSTEM + GIT_SHALLOW + EXCLUDE_FROM_ALL + SOURCE_SUBDIR + CUSTOM_CACHE_KEY + ) + + set(multiValueArgs URL OPTIONS DOWNLOAD_COMMAND PATCHES) + + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "${multiValueArgs}" "${ARGN}") + + # Set default values for arguments + + if(NOT DEFINED CPM_ARGS_VERSION) + if(DEFINED CPM_ARGS_GIT_TAG) + cpm_get_version_from_git_tag("${CPM_ARGS_GIT_TAG}" CPM_ARGS_VERSION) + endif() + endif() + + if(CPM_ARGS_DOWNLOAD_ONLY) + set(DOWNLOAD_ONLY ${CPM_ARGS_DOWNLOAD_ONLY}) + else() + set(DOWNLOAD_ONLY NO) + endif() + + if(DEFINED CPM_ARGS_GITHUB_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://github.com/${CPM_ARGS_GITHUB_REPOSITORY}.git") + elseif(DEFINED CPM_ARGS_GITLAB_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://gitlab.com/${CPM_ARGS_GITLAB_REPOSITORY}.git") + elseif(DEFINED CPM_ARGS_BITBUCKET_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://bitbucket.org/${CPM_ARGS_BITBUCKET_REPOSITORY}.git") + endif() + + if(DEFINED CPM_ARGS_GIT_REPOSITORY) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_REPOSITORY ${CPM_ARGS_GIT_REPOSITORY}) + if(NOT DEFINED CPM_ARGS_GIT_TAG) + set(CPM_ARGS_GIT_TAG v${CPM_ARGS_VERSION}) + endif() + + # If a name wasn't provided, try to infer it from the git repo + if(NOT DEFINED CPM_ARGS_NAME) + cpm_package_name_from_git_uri(${CPM_ARGS_GIT_REPOSITORY} CPM_ARGS_NAME) + endif() + endif() + + set(CPM_SKIP_FETCH FALSE) + + if(DEFINED CPM_ARGS_GIT_TAG) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_TAG ${CPM_ARGS_GIT_TAG}) + # If GIT_SHALLOW is explicitly specified, honor the value. + if(DEFINED CPM_ARGS_GIT_SHALLOW) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_SHALLOW ${CPM_ARGS_GIT_SHALLOW}) + endif() + endif() + + if(DEFINED CPM_ARGS_URL) + # If a name or version aren't provided, try to infer them from the URL + list(GET CPM_ARGS_URL 0 firstUrl) + cpm_package_name_and_ver_from_url(${firstUrl} nameFromUrl verFromUrl) + # If we fail to obtain name and version from the first URL, we could try other URLs if any. + # However multiple URLs are expected to be quite rare, so for now we won't bother. + + # If the caller provided their own name and version, they trump the inferred ones. + if(NOT DEFINED CPM_ARGS_NAME) + set(CPM_ARGS_NAME ${nameFromUrl}) + endif() + if(NOT DEFINED CPM_ARGS_VERSION) + set(CPM_ARGS_VERSION ${verFromUrl}) + endif() + + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS URL "${CPM_ARGS_URL}") + endif() + + # Check for required arguments + + if(NOT DEFINED CPM_ARGS_NAME) + message( + FATAL_ERROR + "${CPM_INDENT} 'NAME' was not provided and couldn't be automatically inferred for package added with arguments: '${ARGN}'" + ) + endif() + + # Check if package has been added before + cpm_check_if_package_already_added(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}") + if(CPM_PACKAGE_ALREADY_ADDED) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + # Check for manual overrides + if(NOT CPM_ARGS_FORCE AND NOT "${CPM_${CPM_ARGS_NAME}_SOURCE}" STREQUAL "") + set(PACKAGE_SOURCE ${CPM_${CPM_ARGS_NAME}_SOURCE}) + set(CPM_${CPM_ARGS_NAME}_SOURCE "") + CPMAddPackage( + NAME "${CPM_ARGS_NAME}" + SOURCE_DIR "${PACKAGE_SOURCE}" + EXCLUDE_FROM_ALL "${CPM_ARGS_EXCLUDE_FROM_ALL}" + SYSTEM "${CPM_ARGS_SYSTEM}" + PATCHES "${CPM_ARGS_PATCHES}" + OPTIONS "${CPM_ARGS_OPTIONS}" + SOURCE_SUBDIR "${CPM_ARGS_SOURCE_SUBDIR}" + DOWNLOAD_ONLY "${DOWNLOAD_ONLY}" + FORCE True + ) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + # Check for available declaration + if(NOT CPM_ARGS_FORCE AND NOT "${CPM_DECLARATION_${CPM_ARGS_NAME}}" STREQUAL "") + set(declaration ${CPM_DECLARATION_${CPM_ARGS_NAME}}) + set(CPM_DECLARATION_${CPM_ARGS_NAME} "") + CPMAddPackage(${declaration}) + cpm_export_variables(${CPM_ARGS_NAME}) + # checking again to ensure version and option compatibility + cpm_check_if_package_already_added(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}") + return() + endif() + + if(NOT CPM_ARGS_FORCE) + if(CPM_USE_LOCAL_PACKAGES OR CPM_LOCAL_PACKAGES_ONLY) + cpm_find_package(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}" ${CPM_ARGS_FIND_PACKAGE_ARGUMENTS}) + + if(CPM_PACKAGE_FOUND) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + if(CPM_LOCAL_PACKAGES_ONLY) + message( + SEND_ERROR + "${CPM_INDENT} ${CPM_ARGS_NAME} not found via find_package(${CPM_ARGS_NAME} ${CPM_ARGS_VERSION})" + ) + endif() + endif() + endif() + + CPMRegisterPackage("${CPM_ARGS_NAME}" "${CPM_ARGS_VERSION}") + + if(DEFINED CPM_ARGS_GIT_TAG) + set(PACKAGE_INFO "${CPM_ARGS_GIT_TAG}") + elseif(DEFINED CPM_ARGS_SOURCE_DIR) + set(PACKAGE_INFO "${CPM_ARGS_SOURCE_DIR}") + else() + set(PACKAGE_INFO "${CPM_ARGS_VERSION}") + endif() + + if(DEFINED FETCHCONTENT_BASE_DIR) + # respect user's FETCHCONTENT_BASE_DIR if set + set(CPM_FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR}) + else() + set(CPM_FETCHCONTENT_BASE_DIR ${CMAKE_BINARY_DIR}/_deps) + endif() + + cpm_add_patches(${CPM_ARGS_PATCHES}) + + if(DEFINED CPM_ARGS_DOWNLOAD_COMMAND) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS DOWNLOAD_COMMAND ${CPM_ARGS_DOWNLOAD_COMMAND}) + elseif(DEFINED CPM_ARGS_SOURCE_DIR) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS SOURCE_DIR ${CPM_ARGS_SOURCE_DIR}) + if(NOT IS_ABSOLUTE ${CPM_ARGS_SOURCE_DIR}) + # Expand `CPM_ARGS_SOURCE_DIR` relative path. This is important because EXISTS doesn't work + # for relative paths. + get_filename_component( + source_directory ${CPM_ARGS_SOURCE_DIR} REALPATH BASE_DIR ${CMAKE_CURRENT_BINARY_DIR} + ) + else() + set(source_directory ${CPM_ARGS_SOURCE_DIR}) + endif() + if(NOT EXISTS ${source_directory}) + string(TOLOWER ${CPM_ARGS_NAME} lower_case_name) + # remove timestamps so CMake will re-download the dependency + file(REMOVE_RECURSE "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild") + endif() + elseif(CPM_SOURCE_CACHE AND NOT CPM_ARGS_NO_CACHE) + string(TOLOWER ${CPM_ARGS_NAME} lower_case_name) + set(origin_parameters ${CPM_ARGS_UNPARSED_ARGUMENTS}) + list(SORT origin_parameters) + if(CPM_ARGS_CUSTOM_CACHE_KEY) + # Application set a custom unique directory name + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${CPM_ARGS_CUSTOM_CACHE_KEY}) + elseif(CPM_USE_NAMED_CACHE_DIRECTORIES) + string(SHA1 origin_hash "${origin_parameters};NEW_CACHE_STRUCTURE_TAG") + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${origin_hash}/${CPM_ARGS_NAME}) + else() + string(SHA1 origin_hash "${origin_parameters}") + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${origin_hash}) + endif() + # Expand `download_directory` relative path. This is important because EXISTS doesn't work for + # relative paths. + get_filename_component(download_directory ${download_directory} ABSOLUTE) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS SOURCE_DIR ${download_directory}) + + if(CPM_SOURCE_CACHE) + file(LOCK ${download_directory}/../cmake.lock) + endif() + + if(EXISTS ${download_directory}) + if(CPM_SOURCE_CACHE) + file(LOCK ${download_directory}/../cmake.lock RELEASE) + endif() + + cpm_store_fetch_properties( + ${CPM_ARGS_NAME} "${download_directory}" + "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-build" + ) + cpm_get_fetch_properties("${CPM_ARGS_NAME}") + + if(DEFINED CPM_ARGS_GIT_TAG AND NOT (PATCH_COMMAND IN_LIST CPM_ARGS_UNPARSED_ARGUMENTS)) + # warn if cache has been changed since checkout + cpm_check_git_working_dir_is_clean(${download_directory} ${CPM_ARGS_GIT_TAG} IS_CLEAN) + if(NOT ${IS_CLEAN}) + message( + WARNING "${CPM_INDENT} Cache for ${CPM_ARGS_NAME} (${download_directory}) is dirty" + ) + endif() + endif() + + cpm_add_subdirectory( + "${CPM_ARGS_NAME}" + "${DOWNLOAD_ONLY}" + "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + "${${CPM_ARGS_NAME}_BINARY_DIR}" + "${CPM_ARGS_EXCLUDE_FROM_ALL}" + "${CPM_ARGS_SYSTEM}" + "${CPM_ARGS_OPTIONS}" + ) + set(PACKAGE_INFO "${PACKAGE_INFO} at ${download_directory}") + + # As the source dir is already cached/populated, we override the call to FetchContent. + set(CPM_SKIP_FETCH TRUE) + cpm_override_fetchcontent( + "${lower_case_name}" SOURCE_DIR "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + BINARY_DIR "${${CPM_ARGS_NAME}_BINARY_DIR}" + ) + + else() + # Enable shallow clone when GIT_TAG is not a commit hash. Our guess may not be accurate, but + # it should guarantee no commit hash get mis-detected. + if(NOT DEFINED CPM_ARGS_GIT_SHALLOW) + cpm_is_git_tag_commit_hash("${CPM_ARGS_GIT_TAG}" IS_HASH) + if(NOT ${IS_HASH}) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_SHALLOW TRUE) + endif() + endif() + + # remove timestamps so CMake will re-download the dependency + file(REMOVE_RECURSE ${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild) + set(PACKAGE_INFO "${PACKAGE_INFO} to ${download_directory}") + endif() + endif() + + if(NOT "${DOWNLOAD_ONLY}") + cpm_create_module_file(${CPM_ARGS_NAME} "CPMAddPackage(\"${ARGN}\")") + endif() + + if(CPM_PACKAGE_LOCK_ENABLED) + if((CPM_ARGS_VERSION AND NOT CPM_ARGS_SOURCE_DIR) OR CPM_INCLUDE_ALL_IN_PACKAGE_LOCK) + cpm_add_to_package_lock(${CPM_ARGS_NAME} "${ARGN}") + elseif(CPM_ARGS_SOURCE_DIR) + cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "local directory") + else() + cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "${ARGN}") + endif() + endif() + + cpm_message( + STATUS "${CPM_INDENT} Adding package ${CPM_ARGS_NAME}@${CPM_ARGS_VERSION} (${PACKAGE_INFO})" + ) + + if(NOT CPM_SKIP_FETCH) + # CMake 3.28 added EXCLUDE, SYSTEM (3.25), and SOURCE_SUBDIR (3.18) to FetchContent_Declare. + # Calling FetchContent_MakeAvailable will then internally forward these options to + # add_subdirectory. Up until these changes, we had to call FetchContent_Populate and + # add_subdirectory separately, which is no longer necessary and has been deprecated as of 3.30. + # A Bug in CMake prevents us to use the non-deprecated functions until 3.30.3. + set(fetchContentDeclareExtraArgs "") + if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.30.3") + if(${CPM_ARGS_EXCLUDE_FROM_ALL}) + list(APPEND fetchContentDeclareExtraArgs EXCLUDE_FROM_ALL) + endif() + if(${CPM_ARGS_SYSTEM}) + list(APPEND fetchContentDeclareExtraArgs SYSTEM) + endif() + if(DEFINED CPM_ARGS_SOURCE_SUBDIR) + list(APPEND fetchContentDeclareExtraArgs SOURCE_SUBDIR ${CPM_ARGS_SOURCE_SUBDIR}) + endif() + # For CMake version <3.28 OPTIONS are parsed in cpm_add_subdirectory + if(CPM_ARGS_OPTIONS AND NOT DOWNLOAD_ONLY) + foreach(OPTION ${CPM_ARGS_OPTIONS}) + cpm_parse_option("${OPTION}") + set(${OPTION_KEY} "${OPTION_VALUE}") + endforeach() + endif() + endif() + cpm_declare_fetch( + "${CPM_ARGS_NAME}" ${fetchContentDeclareExtraArgs} "${CPM_ARGS_UNPARSED_ARGUMENTS}" + ) + + cpm_fetch_package("${CPM_ARGS_NAME}" ${DOWNLOAD_ONLY} populated ${CPM_ARGS_UNPARSED_ARGUMENTS}) + if(CPM_SOURCE_CACHE AND download_directory) + file(LOCK ${download_directory}/../cmake.lock RELEASE) + endif() + if(${populated} AND ${CMAKE_VERSION} VERSION_LESS "3.30.3") + cpm_add_subdirectory( + "${CPM_ARGS_NAME}" + "${DOWNLOAD_ONLY}" + "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + "${${CPM_ARGS_NAME}_BINARY_DIR}" + "${CPM_ARGS_EXCLUDE_FROM_ALL}" + "${CPM_ARGS_SYSTEM}" + "${CPM_ARGS_OPTIONS}" + ) + endif() + cpm_get_fetch_properties("${CPM_ARGS_NAME}") + endif() + + set(${CPM_ARGS_NAME}_ADDED YES) + cpm_export_variables("${CPM_ARGS_NAME}") +endfunction() + +# Fetch a previously declared package +macro(CPMGetPackage Name) + if(DEFINED "CPM_DECLARATION_${Name}") + CPMAddPackage(NAME ${Name}) + else() + message(SEND_ERROR "${CPM_INDENT} Cannot retrieve package ${Name}: no declaration available") + endif() +endmacro() + +# export variables available to the caller to the parent scope expects ${CPM_ARGS_NAME} to be set +macro(cpm_export_variables name) + set(${name}_SOURCE_DIR + "${${name}_SOURCE_DIR}" + PARENT_SCOPE + ) + set(${name}_BINARY_DIR + "${${name}_BINARY_DIR}" + PARENT_SCOPE + ) + set(${name}_ADDED + "${${name}_ADDED}" + PARENT_SCOPE + ) + set(CPM_LAST_PACKAGE_NAME + "${name}" + PARENT_SCOPE + ) +endmacro() + +# declares a package, so that any call to CPMAddPackage for the package name will use these +# arguments instead. Previous declarations will not be overridden. +macro(CPMDeclarePackage Name) + if(NOT DEFINED "CPM_DECLARATION_${Name}") + set("CPM_DECLARATION_${Name}" "${ARGN}") + endif() +endmacro() + +function(cpm_add_to_package_lock Name) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + cpm_prettify_package_arguments(PRETTY_ARGN false ${ARGN}) + file(APPEND ${CPM_PACKAGE_LOCK_FILE} "# ${Name}\nCPMDeclarePackage(${Name}\n${PRETTY_ARGN})\n") + endif() +endfunction() + +function(cpm_add_comment_to_package_lock Name) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + cpm_prettify_package_arguments(PRETTY_ARGN true ${ARGN}) + file(APPEND ${CPM_PACKAGE_LOCK_FILE} + "# ${Name} (unversioned)\n# CPMDeclarePackage(${Name}\n${PRETTY_ARGN}#)\n" + ) + endif() +endfunction() + +# includes the package lock file if it exists and creates a target `cpm-update-package-lock` to +# update it +macro(CPMUsePackageLock file) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + get_filename_component(CPM_ABSOLUTE_PACKAGE_LOCK_PATH ${file} ABSOLUTE) + if(EXISTS ${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}) + include(${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}) + endif() + if(NOT TARGET cpm-update-package-lock) + add_custom_target( + cpm-update-package-lock COMMAND ${CMAKE_COMMAND} -E copy ${CPM_PACKAGE_LOCK_FILE} + ${CPM_ABSOLUTE_PACKAGE_LOCK_PATH} + ) + endif() + set(CPM_PACKAGE_LOCK_ENABLED true) + endif() +endmacro() + +# registers a package that has been added to CPM +function(CPMRegisterPackage PACKAGE VERSION) + list(APPEND CPM_PACKAGES ${PACKAGE}) + set(CPM_PACKAGES + ${CPM_PACKAGES} + CACHE INTERNAL "" + ) + set("CPM_PACKAGE_${PACKAGE}_VERSION" + ${VERSION} + CACHE INTERNAL "" + ) +endfunction() + +# retrieve the current version of the package to ${OUTPUT} +function(CPMGetPackageVersion PACKAGE OUTPUT) + set(${OUTPUT} + "${CPM_PACKAGE_${PACKAGE}_VERSION}" + PARENT_SCOPE + ) +endfunction() + +# declares a package in FetchContent_Declare +function(cpm_declare_fetch PACKAGE) + if(${CPM_DRY_RUN}) + cpm_message(STATUS "${CPM_INDENT} Package not declared (dry run)") + return() + endif() + + FetchContent_Declare(${PACKAGE} ${ARGN}) +endfunction() + +# returns properties for a package previously defined by cpm_declare_fetch +function(cpm_get_fetch_properties PACKAGE) + if(${CPM_DRY_RUN}) + return() + endif() + + set(${PACKAGE}_SOURCE_DIR + "${CPM_PACKAGE_${PACKAGE}_SOURCE_DIR}" + PARENT_SCOPE + ) + set(${PACKAGE}_BINARY_DIR + "${CPM_PACKAGE_${PACKAGE}_BINARY_DIR}" + PARENT_SCOPE + ) +endfunction() + +function(cpm_store_fetch_properties PACKAGE source_dir binary_dir) + if(${CPM_DRY_RUN}) + return() + endif() + + set(CPM_PACKAGE_${PACKAGE}_SOURCE_DIR + "${source_dir}" + CACHE INTERNAL "" + ) + set(CPM_PACKAGE_${PACKAGE}_BINARY_DIR + "${binary_dir}" + CACHE INTERNAL "" + ) +endfunction() + +# adds a package as a subdirectory if viable, according to provided options +function( + cpm_add_subdirectory + PACKAGE + DOWNLOAD_ONLY + SOURCE_DIR + BINARY_DIR + EXCLUDE + SYSTEM + OPTIONS +) + + if(NOT DOWNLOAD_ONLY AND EXISTS ${SOURCE_DIR}/CMakeLists.txt) + set(addSubdirectoryExtraArgs "") + if(EXCLUDE) + list(APPEND addSubdirectoryExtraArgs EXCLUDE_FROM_ALL) + endif() + if("${SYSTEM}" AND "${CMAKE_VERSION}" VERSION_GREATER_EQUAL "3.25") + # https://cmake.org/cmake/help/latest/prop_dir/SYSTEM.html#prop_dir:SYSTEM + list(APPEND addSubdirectoryExtraArgs SYSTEM) + endif() + if(OPTIONS) + foreach(OPTION ${OPTIONS}) + cpm_parse_option("${OPTION}") + set(${OPTION_KEY} "${OPTION_VALUE}") + endforeach() + endif() + set(CPM_OLD_INDENT "${CPM_INDENT}") + set(CPM_INDENT "${CPM_INDENT} ${PACKAGE}:") + add_subdirectory(${SOURCE_DIR} ${BINARY_DIR} ${addSubdirectoryExtraArgs}) + set(CPM_INDENT "${CPM_OLD_INDENT}") + endif() +endfunction() + +# downloads a previously declared package via FetchContent and exports the variables +# `${PACKAGE}_SOURCE_DIR` and `${PACKAGE}_BINARY_DIR` to the parent scope +function(cpm_fetch_package PACKAGE DOWNLOAD_ONLY populated) + set(${populated} + FALSE + PARENT_SCOPE + ) + if(${CPM_DRY_RUN}) + cpm_message(STATUS "${CPM_INDENT} Package ${PACKAGE} not fetched (dry run)") + return() + endif() + + FetchContent_GetProperties(${PACKAGE}) + + string(TOLOWER "${PACKAGE}" lower_case_name) + + if(NOT ${lower_case_name}_POPULATED) + if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.30.3") + if(DOWNLOAD_ONLY) + # MakeAvailable will call add_subdirectory internally which is not what we want when + # DOWNLOAD_ONLY is set. Populate will only download the dependency without adding it to the + # build + FetchContent_Populate( + ${PACKAGE} + SOURCE_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-src" + BINARY_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-build" + SUBBUILD_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild" + ${ARGN} + ) + else() + FetchContent_MakeAvailable(${PACKAGE}) + endif() + else() + FetchContent_Populate(${PACKAGE}) + endif() + set(${populated} + TRUE + PARENT_SCOPE + ) + endif() + + cpm_store_fetch_properties( + ${CPM_ARGS_NAME} ${${lower_case_name}_SOURCE_DIR} ${${lower_case_name}_BINARY_DIR} + ) + + set(${PACKAGE}_SOURCE_DIR + ${${lower_case_name}_SOURCE_DIR} + PARENT_SCOPE + ) + set(${PACKAGE}_BINARY_DIR + ${${lower_case_name}_BINARY_DIR} + PARENT_SCOPE + ) +endfunction() + +# splits a package option +function(cpm_parse_option OPTION) + string(REGEX MATCH "^[^ ]+" OPTION_KEY "${OPTION}") + string(LENGTH "${OPTION}" OPTION_LENGTH) + string(LENGTH "${OPTION_KEY}" OPTION_KEY_LENGTH) + if(OPTION_KEY_LENGTH STREQUAL OPTION_LENGTH) + # no value for key provided, assume user wants to set option to "ON" + set(OPTION_VALUE "ON") + else() + math(EXPR OPTION_KEY_LENGTH "${OPTION_KEY_LENGTH}+1") + string(SUBSTRING "${OPTION}" "${OPTION_KEY_LENGTH}" "-1" OPTION_VALUE) + endif() + set(OPTION_KEY + "${OPTION_KEY}" + PARENT_SCOPE + ) + set(OPTION_VALUE + "${OPTION_VALUE}" + PARENT_SCOPE + ) +endfunction() + +# guesses the package version from a git tag +function(cpm_get_version_from_git_tag GIT_TAG RESULT) + string(LENGTH ${GIT_TAG} length) + if(length EQUAL 40) + # GIT_TAG is probably a git hash + set(${RESULT} + 0 + PARENT_SCOPE + ) + else() + string(REGEX MATCH "v?([0123456789.]*).*" _ ${GIT_TAG}) + set(${RESULT} + ${CMAKE_MATCH_1} + PARENT_SCOPE + ) + endif() +endfunction() + +# guesses if the git tag is a commit hash or an actual tag or a branch name. +function(cpm_is_git_tag_commit_hash GIT_TAG RESULT) + string(LENGTH "${GIT_TAG}" length) + # full hash has 40 characters, and short hash has at least 7 characters. + if(length LESS 7 OR length GREATER 40) + set(${RESULT} + 0 + PARENT_SCOPE + ) + else() + if(${GIT_TAG} MATCHES "^[a-fA-F0-9]+$") + set(${RESULT} + 1 + PARENT_SCOPE + ) + else() + set(${RESULT} + 0 + PARENT_SCOPE + ) + endif() + endif() +endfunction() + +function(cpm_prettify_package_arguments OUT_VAR IS_IN_COMMENT) + set(oneValueArgs + NAME + FORCE + VERSION + GIT_TAG + DOWNLOAD_ONLY + GITHUB_REPOSITORY + GITLAB_REPOSITORY + BITBUCKET_REPOSITORY + GIT_REPOSITORY + SOURCE_DIR + FIND_PACKAGE_ARGUMENTS + NO_CACHE + SYSTEM + GIT_SHALLOW + EXCLUDE_FROM_ALL + SOURCE_SUBDIR + ) + set(multiValueArgs URL OPTIONS DOWNLOAD_COMMAND) + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + foreach(oneArgName ${oneValueArgs}) + if(DEFINED CPM_ARGS_${oneArgName}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + if(${oneArgName} STREQUAL "SOURCE_DIR") + string(REPLACE ${CMAKE_SOURCE_DIR} "\${CMAKE_SOURCE_DIR}" CPM_ARGS_${oneArgName} + ${CPM_ARGS_${oneArgName}} + ) + endif() + string(APPEND PRETTY_OUT_VAR " ${oneArgName} ${CPM_ARGS_${oneArgName}}\n") + endif() + endforeach() + foreach(multiArgName ${multiValueArgs}) + if(DEFINED CPM_ARGS_${multiArgName}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " ${multiArgName}\n") + foreach(singleOption ${CPM_ARGS_${multiArgName}}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " \"${singleOption}\"\n") + endforeach() + endif() + endforeach() + + if(NOT "${CPM_ARGS_UNPARSED_ARGUMENTS}" STREQUAL "") + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " ") + foreach(CPM_ARGS_UNPARSED_ARGUMENT ${CPM_ARGS_UNPARSED_ARGUMENTS}) + string(APPEND PRETTY_OUT_VAR " ${CPM_ARGS_UNPARSED_ARGUMENT}") + endforeach() + string(APPEND PRETTY_OUT_VAR "\n") + endif() + + set(${OUT_VAR} + ${PRETTY_OUT_VAR} + PARENT_SCOPE + ) + +endfunction() diff --git a/common/Auth.cc b/common/Auth.cc new file mode 100644 index 0000000..156c43c --- /dev/null +++ b/common/Auth.cc @@ -0,0 +1,66 @@ +#include "common/Auth.h" + +#include "common/Crypto.h" + +#include + +namespace visione::auth +{ +std::optional bearerToken(const drogon::HttpRequestPtr &request) +{ + const auto header = request->getHeader("Authorization"); + constexpr std::string_view prefix = "Bearer "; + if (!header.starts_with(prefix)) + { + return std::nullopt; + } + return header.substr(prefix.size()); +} + +std::optional authenticate(const drogon::HttpRequestPtr &request) +{ + const auto token = bearerToken(request); + if (!token) + { + return std::nullopt; + } + + auto db = drogon::app().getDbClient(); + const auto result = db->execSqlSync( + "SELECT u.id, u.email, COALESCE(MIN(r.name), 'citizen') AS role " + "FROM api_tokens t " + "JOIN users u ON u.id = t.user_id " + "LEFT JOIN user_roles ur ON ur.user_id = u.id " + "LEFT JOIN roles r ON r.id = ur.role_id " + "WHERE t.token_hash = ? AND t.revoked_at IS NULL " + "AND t.expires_at > NOW() AND u.banned = FALSE " + "GROUP BY u.id, u.email", + crypto::hashSecret(*token)); + + if (result.empty()) + { + return std::nullopt; + } + + const auto &row = result[0]; + return UserContext{row["id"].as(), + row["email"].as(), + row["role"].as()}; +} + +bool hasRole(const UserContext &user, const std::string &role) +{ + return user.role == role; +} + +bool canManageReports(const UserContext &user) +{ + return user.role == "admin" || user.role == "operator"; +} + +bool isAdmin(const UserContext &user) +{ + return user.role == "admin"; +} +} // namespace visione::auth + diff --git a/common/Auth.h b/common/Auth.h new file mode 100644 index 0000000..99ce4af --- /dev/null +++ b/common/Auth.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include +#include + +namespace visione::auth +{ +struct UserContext +{ + long long id{0}; + std::string email; + std::string role; +}; + +std::optional bearerToken(const drogon::HttpRequestPtr &request); +std::optional authenticate(const drogon::HttpRequestPtr &request); +bool hasRole(const UserContext &user, const std::string &role); +bool canManageReports(const UserContext &user); +bool isAdmin(const UserContext &user); +} // namespace visione::auth + diff --git a/common/Crypto.cc b/common/Crypto.cc new file mode 100644 index 0000000..a7e4dbb --- /dev/null +++ b/common/Crypto.cc @@ -0,0 +1,68 @@ +#include "common/Crypto.h" + +#include + +#include +#include +#include +#include + +namespace visione::crypto +{ +std::string sha256(const std::string &value) +{ + return drogon::utils::getSha256(value); +} + +std::string randomNumericCode(int digits) +{ + std::random_device device; + std::mt19937 generator(device()); + std::uniform_int_distribution distribution(0, 9); + + std::string code; + code.reserve(static_cast(digits)); + for (int i = 0; i < digits; ++i) + { + code.push_back(static_cast('0' + distribution(generator))); + } + return code; +} + +std::string randomToken() +{ + return drogon::utils::getUuid() + drogon::utils::getUuid(); +} + +std::string hashPassword(const std::string &password) +{ + const auto salt = drogon::utils::getUuid(); + return "sha256$" + salt + "$" + sha256(salt + ":" + password); +} + +bool verifyPassword(const std::string &password, const std::string &hash) +{ + constexpr std::string_view prefix = "sha256$"; + if (!hash.starts_with(prefix)) + { + return false; + } + + const auto saltStart = prefix.size(); + const auto hashStart = hash.find('$', saltStart); + if (hashStart == std::string::npos) + { + return false; + } + + const auto salt = hash.substr(saltStart, hashStart - saltStart); + const auto expected = "sha256$" + salt + "$" + sha256(salt + ":" + password); + return expected == hash; +} + +std::string hashSecret(const std::string &secret) +{ + return sha256("visione:" + secret); +} +} // namespace visione::crypto + diff --git a/common/Crypto.h b/common/Crypto.h new file mode 100644 index 0000000..f184d8c --- /dev/null +++ b/common/Crypto.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace visione::crypto +{ +std::string sha256(const std::string &value); +std::string randomNumericCode(int digits); +std::string randomToken(); +std::string hashPassword(const std::string &password); +bool verifyPassword(const std::string &password, const std::string &hash); +std::string hashSecret(const std::string &secret); +} // namespace visione::crypto + diff --git a/common/Email.cc b/common/Email.cc new file mode 100644 index 0000000..ca42e6f --- /dev/null +++ b/common/Email.cc @@ -0,0 +1,119 @@ +#include "common/Email.h" + +#include + +#include +#include +#include + +#include +#include +#include + +namespace visione::email +{ +namespace +{ +std::string configString(const std::string &key, const std::string &fallback) +{ + const auto &mail = drogon::app().getCustomConfig()["mail"]; + return mail.get(key, fallback).asString(); +} + +int configInt(const std::string &key, int fallback) +{ + const auto &mail = drogon::app().getCustomConfig()["mail"]; + return mail.get(key, fallback).asInt(); +} + +bool readResponse(int socket) +{ + std::array buffer{}; + const auto count = recv(socket, buffer.data(), buffer.size() - 1, 0); + return count > 0 && buffer[0] >= '2' && buffer[0] <= '3'; +} + +bool sendLine(int socket, const std::string &line) +{ + const auto payload = line + "\r\n"; + return send(socket, payload.data(), payload.size(), 0) == + static_cast(payload.size()); +} + +bool sendCommand(int socket, const std::string &line) +{ + return sendLine(socket, line) && readResponse(socket); +} +} // namespace + +bool EmailService::sendOtp(const std::string &to, const std::string &code) const +{ + const auto host = configString("smtp_host", "127.0.0.1"); + const auto port = std::to_string(configInt("smtp_port", 1025)); + const auto from = configString("from", "noreply@visione.local"); + + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + + addrinfo *addresses = nullptr; + if (getaddrinfo(host.c_str(), port.c_str(), &hints, &addresses) != 0) + { + return false; + } + + int smtp = -1; + for (auto *address = addresses; address != nullptr; address = address->ai_next) + { + smtp = socket(address->ai_family, address->ai_socktype, address->ai_protocol); + if (smtp < 0) + { + continue; + } + if (connect(smtp, address->ai_addr, address->ai_addrlen) == 0) + { + break; + } + close(smtp); + smtp = -1; + } + freeaddrinfo(addresses); + + if (smtp < 0) + { + return false; + } + + const auto closeSocket = [&smtp]() { + if (smtp >= 0) + { + close(smtp); + smtp = -1; + } + }; + + if (!readResponse(smtp) || !sendCommand(smtp, "HELO visione.local") || + !sendCommand(smtp, "MAIL FROM:<" + from + ">") || + !sendCommand(smtp, "RCPT TO:<" + to + ">") || + !sendCommand(smtp, "DATA")) + { + closeSocket(); + return false; + } + + const std::string body = + "From: Visione <" + from + ">\r\n" + "To: " + to + "\r\n" + "Subject: Codice OTP Visione\r\n" + "Content-Type: text/plain; charset=UTF-8\r\n" + "\r\n" + "Il tuo codice OTP per Visione e': " + code + "\r\n" + "Scade tra 10 minuti.\r\n" + "."; + + const auto ok = sendCommand(smtp, body) && sendCommand(smtp, "QUIT"); + closeSocket(); + return ok; +} +} // namespace visione::email + diff --git a/common/Email.h b/common/Email.h new file mode 100644 index 0000000..5ba9d6e --- /dev/null +++ b/common/Email.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace visione::email +{ +class EmailService +{ + public: + bool sendOtp(const std::string &to, const std::string &code) const; +}; +} // namespace visione::email + diff --git a/common/GeoJson.cc b/common/GeoJson.cc new file mode 100644 index 0000000..02f69ec --- /dev/null +++ b/common/GeoJson.cc @@ -0,0 +1,59 @@ +#include "common/GeoJson.h" + +#include + +namespace visione::geojson +{ +namespace +{ +struct Point +{ + double x; + double y; +}; +} // namespace + +bool pointInPolygon(double latitude, double longitude, const Json::Value &geojson) +{ + if (!geojson.isObject() || geojson["type"].asString() != "Polygon" || + !geojson["coordinates"].isArray() || geojson["coordinates"].empty()) + { + return false; + } + + const auto &ring = geojson["coordinates"][0]; + if (!ring.isArray() || ring.size() < 4) + { + return false; + } + + std::vector points; + points.reserve(ring.size()); + for (const auto &coordinate : ring) + { + if (!coordinate.isArray() || coordinate.size() < 2) + { + return false; + } + points.push_back({coordinate[0].asDouble(), coordinate[1].asDouble()}); + } + + const auto x = longitude; + const auto y = latitude; + bool inside = false; + for (size_t i = 0, j = points.size() - 1; i < points.size(); j = i++) + { + const auto &pi = points[i]; + const auto &pj = points[j]; + const bool intersects = + ((pi.y > y) != (pj.y > y)) && + (x < (pj.x - pi.x) * (y - pi.y) / (pj.y - pi.y) + pi.x); + if (intersects) + { + inside = !inside; + } + } + return inside; +} +} // namespace visione::geojson + diff --git a/common/GeoJson.h b/common/GeoJson.h new file mode 100644 index 0000000..45a817e --- /dev/null +++ b/common/GeoJson.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace visione::geojson +{ +bool pointInPolygon(double latitude, double longitude, const Json::Value &geojson); +} // namespace visione::geojson + diff --git a/common/Http.cc b/common/Http.cc new file mode 100644 index 0000000..dfd56cd --- /dev/null +++ b/common/Http.cc @@ -0,0 +1,35 @@ +#include "common/Http.h" + +namespace visione::http +{ +drogon::HttpResponsePtr json(Json::Value body, drogon::HttpStatusCode status) +{ + auto response = drogon::HttpResponse::newHttpJsonResponse(std::move(body)); + response->setStatusCode(status); + return response; +} + +drogon::HttpResponsePtr error(const std::string &code, + drogon::HttpStatusCode status, + const std::string &message) +{ + Json::Value body; + body["error"] = code; + if (!message.empty()) + { + body["message"] = message; + } + return json(std::move(body), status); +} + +Json::Value requireJson(const drogon::HttpRequestPtr &request) +{ + const auto &body = request->getJsonObject(); + if (!body) + { + return Json::Value(Json::nullValue); + } + return *body; +} +} // namespace visione::http + diff --git a/common/Http.h b/common/Http.h new file mode 100644 index 0000000..678c240 --- /dev/null +++ b/common/Http.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +#include +#include + +namespace visione::http +{ +drogon::HttpResponsePtr json(Json::Value body, + drogon::HttpStatusCode status = drogon::k200OK); +drogon::HttpResponsePtr error(const std::string &code, + drogon::HttpStatusCode status, + const std::string &message = {}); +Json::Value requireJson(const drogon::HttpRequestPtr &request); +} // namespace visione::http + diff --git a/common/Validation.cc b/common/Validation.cc new file mode 100644 index 0000000..b14efcd --- /dev/null +++ b/common/Validation.cc @@ -0,0 +1,45 @@ +#include "common/Validation.h" + +#include + +namespace visione::validation +{ +bool hasString(const Json::Value &body, const std::string &key) +{ + return body.isMember(key) && body[key].isString() && + !body[key].asString().empty(); +} + +bool hasNumber(const Json::Value &body, const std::string &key) +{ + return body.isMember(key) && body[key].isNumeric(); +} + +std::vector missingStrings( + const Json::Value &body, + std::initializer_list keys) +{ + std::vector missing; + for (const auto *key : keys) + { + if (!hasString(body, key)) + { + missing.emplace_back(key); + } + } + return missing; +} + +bool isValidEmail(const std::string &email) +{ + static const std::regex pattern(R"(^[^@\s]+@[^@\s]+\.[^@\s]+$)"); + return std::regex_match(email, pattern); +} + +bool isValidFiscalCode(const std::string &fiscalCode) +{ + static const std::regex pattern(R"(^[A-Za-z0-9]{16}$)"); + return std::regex_match(fiscalCode, pattern); +} +} // namespace visione::validation + diff --git a/common/Validation.h b/common/Validation.h new file mode 100644 index 0000000..a83825f --- /dev/null +++ b/common/Validation.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +namespace visione::validation +{ +bool hasString(const Json::Value &body, const std::string &key); +bool hasNumber(const Json::Value &body, const std::string &key); +std::vector missingStrings(const Json::Value &body, + std::initializer_list keys); +bool isValidEmail(const std::string &email); +bool isValidFiscalCode(const std::string &fiscalCode); +} // namespace visione::validation + diff --git a/config.json b/config.json new file mode 100644 index 0000000..d5de575 --- /dev/null +++ b/config.json @@ -0,0 +1,62 @@ +{ + "listeners": [ + { + "address": "0.0.0.0", + "port": 5555, + "https": false + } + ], + "db_clients": [ + { + "name": "default", + "rdbms": "mysql", + "host": "127.0.0.1", + "port": 3306, + "dbname": "visione", + "user": "visione", + "passwd": "visione", + "is_fast": false, + "number_of_connections": 1, + "timeout": 5.0 + } + ], + "app": { + "number_of_threads": 2, + "enable_session": false, + "document_root": "./", + "use_implicit_page": false, + "load_dynamic_views": false, + "json_parser_stack_limit": 1000, + "log": { + "use_spdlog": false, + "logfile_base_name": "", + "log_size_limit": 100000000, + "max_files": 0, + "log_level": "DEBUG", + "display_local_time": true + }, + "run_as_daemon": false, + "handle_sig_term": true, + "use_gzip": true, + "use_brotli": false, + "client_max_body_size": "1M", + "client_max_memory_body_size": "64K" + }, + "plugins": [], + "custom_config": { + "service_name": "visione-backend", + "migrations_path": "migrations", + "auth": { + "token_ttl_hours": 24, + "otp_ttl_minutes": 10 + }, + "mail": { + "smtp_host": "127.0.0.1", + "smtp_port": 1025, + "from": "noreply@visione.local" + }, + "cors": { + "allowed_origin": "http://localhost:5173" + } + } +} diff --git a/controllers/AdminController.cc b/controllers/AdminController.cc new file mode 100644 index 0000000..954cd86 --- /dev/null +++ b/controllers/AdminController.cc @@ -0,0 +1,116 @@ +#include "controllers/AdminController.h" + +#include "common/Auth.h" +#include "common/Http.h" +#include "services/AdminService.h" + +using drogon::HttpResponsePtr; + +namespace +{ +std::optional requireUser( + const drogon::HttpRequestPtr &req, + std::function &callback) +{ + const auto user = visione::auth::authenticate(req); + if (!user) + { + callback(visione::http::error("unauthorized", drogon::k401Unauthorized)); + } + return user; +} + +void sendAdmin(Json::Value body, + std::function &&callback) +{ + if (body.get("ok", false).asBool()) + { + callback(visione::http::json(std::move(body))); + return; + } + const auto error = body.get("error", "").asString(); + const auto status = error == "forbidden" ? drogon::k403Forbidden + : drogon::k400BadRequest; + callback(visione::http::json(std::move(body), status)); +} +} // namespace + +void AdminController::listCategories( + const drogon::HttpRequestPtr &, + std::function &&callback) +{ + visione::services::AdminService service; + callback(visione::http::json(service.listCategories())); +} + +void AdminController::createCategory( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + visione::services::AdminService service; + sendAdmin(service.createCategory(*user, visione::http::requireJson(req)), + std::move(callback)); +} + +void AdminController::listAreas( + const drogon::HttpRequestPtr &, + std::function &&callback) +{ + visione::services::AdminService service; + callback(visione::http::json(service.listAreas())); +} + +void AdminController::createArea( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + visione::services::AdminService service; + sendAdmin(service.createArea(*user, visione::http::requireJson(req)), + std::move(callback)); +} + +void AdminController::listTeams( + const drogon::HttpRequestPtr &, + std::function &&callback) +{ + visione::services::AdminService service; + callback(visione::http::json(service.listTeams())); +} + +void AdminController::createTeam( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + visione::services::AdminService service; + sendAdmin(service.createTeam(*user, visione::http::requireJson(req)), + std::move(callback)); +} + +void AdminController::overviewStats( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + visione::services::AdminService service; + sendAdmin(service.overviewStats(*user), std::move(callback)); +} + diff --git a/controllers/AdminController.h b/controllers/AdminController.h new file mode 100644 index 0000000..552a3c1 --- /dev/null +++ b/controllers/AdminController.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +class AdminController : public drogon::HttpController +{ + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(AdminController::listCategories, + "/api/v1/admin/categories", + drogon::Get); + ADD_METHOD_TO(AdminController::createCategory, + "/api/v1/admin/categories", + drogon::Post); + ADD_METHOD_TO(AdminController::listAreas, "/api/v1/admin/areas", drogon::Get); + ADD_METHOD_TO(AdminController::createArea, "/api/v1/admin/areas", drogon::Post); + ADD_METHOD_TO(AdminController::listTeams, "/api/v1/admin/teams", drogon::Get); + ADD_METHOD_TO(AdminController::createTeam, "/api/v1/admin/teams", drogon::Post); + ADD_METHOD_TO(AdminController::overviewStats, + "/api/v1/admin/stats/overview", + drogon::Get); + METHOD_LIST_END + + void listCategories( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void createCategory( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void listAreas( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void createArea( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void listTeams( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void createTeam( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void overviewStats( + const drogon::HttpRequestPtr &req, + std::function &&callback); +}; + diff --git a/controllers/ApiController.cc b/controllers/ApiController.cc new file mode 100644 index 0000000..0f8e533 --- /dev/null +++ b/controllers/ApiController.cc @@ -0,0 +1,72 @@ +#include "ApiController.h" + +#include +#include + +#include +#include + +using drogon::HttpResponse; +using drogon::HttpResponsePtr; +using drogon::k503ServiceUnavailable; + +namespace +{ +HttpResponsePtr jsonResponse(Json::Value body, + drogon::HttpStatusCode status = drogon::k200OK) +{ + auto response = HttpResponse::newHttpJsonResponse(std::move(body)); + response->setStatusCode(status); + return response; +} + +Json::Value mysqlReadyBody(bool ready) +{ + Json::Value body; + body["status"] = ready ? "ready" : "not_ready"; + body["dependencies"]["mysql"] = ready; + return body; +} +} + +void ApiController::health( + const drogon::HttpRequestPtr &, + std::function &&callback) +{ + Json::Value body; + body["status"] = "ok"; + body["service"] = "visione-backend"; + + callback(jsonResponse(std::move(body))); +} + +void ApiController::ready( + const drogon::HttpRequestPtr &, + std::function &&callback) +{ + auto responseCallback = std::make_shared< + std::function>(std::move(callback)); + + try + { + auto dbClient = drogon::app().getDbClient(); + dbClient->execSqlAsync( + "SELECT 1", + [responseCallback](const drogon::orm::Result &) { + (*responseCallback)(jsonResponse(mysqlReadyBody(true))); + }, + [responseCallback](const drogon::orm::DrogonDbException &error) { + auto body = mysqlReadyBody(false); + body["errors"]["mysql"] = error.base().what(); + (*responseCallback)( + jsonResponse(std::move(body), k503ServiceUnavailable)); + }); + } + catch (const std::exception &error) + { + auto body = mysqlReadyBody(false); + body["errors"]["mysql"] = error.what(); + (*responseCallback)( + jsonResponse(std::move(body), k503ServiceUnavailable)); + } +} diff --git a/controllers/ApiController.h b/controllers/ApiController.h new file mode 100644 index 0000000..05a27b7 --- /dev/null +++ b/controllers/ApiController.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +class ApiController : public drogon::HttpController +{ + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(ApiController::health, "/api/v1/health", drogon::Get); + ADD_METHOD_TO(ApiController::ready, "/api/v1/ready", drogon::Get); + METHOD_LIST_END + + void health(const drogon::HttpRequestPtr &req, + std::function &&callback); + + void ready(const drogon::HttpRequestPtr &req, + std::function &&callback); +}; diff --git a/controllers/AuthController.cc b/controllers/AuthController.cc new file mode 100644 index 0000000..8f8a675 --- /dev/null +++ b/controllers/AuthController.cc @@ -0,0 +1,98 @@ +#include "controllers/AuthController.h" + +#include "common/Auth.h" +#include "common/Http.h" +#include "services/AuthService.h" + +using drogon::HttpResponsePtr; + +namespace +{ +void serviceResponse( + Json::Value body, + std::function &&callback) +{ + if (body.get("ok", false).asBool()) + { + callback(visione::http::json(std::move(body))); + return; + } + callback(visione::http::json(std::move(body), drogon::k400BadRequest)); +} +} // namespace + +void AuthController::registerCitizen( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + const auto body = visione::http::requireJson(req); + if (body.isNull()) + { + callback(visione::http::error("invalid_json", drogon::k400BadRequest)); + return; + } + + visione::services::AuthService service; + serviceResponse(service.registerCitizen(body), std::move(callback)); +} + +void AuthController::requestOtp( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + const auto body = visione::http::requireJson(req); + if (body.isNull()) + { + callback(visione::http::error("invalid_json", drogon::k400BadRequest)); + return; + } + + visione::services::AuthService service; + serviceResponse(service.requestOtp(body), std::move(callback)); +} + +void AuthController::verifyOtp( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + const auto body = visione::http::requireJson(req); + if (body.isNull()) + { + callback(visione::http::error("invalid_json", drogon::k400BadRequest)); + return; + } + + visione::services::AuthService service; + serviceResponse(service.verifyOtp(body), std::move(callback)); +} + +void AuthController::loginWithPassword( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + const auto body = visione::http::requireJson(req); + if (body.isNull()) + { + callback(visione::http::error("invalid_json", drogon::k400BadRequest)); + return; + } + + visione::services::AuthService service; + serviceResponse(service.loginWithPassword(body), std::move(callback)); +} + +void AuthController::me( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + const auto user = visione::auth::authenticate(req); + if (!user) + { + callback(visione::http::error("unauthorized", drogon::k401Unauthorized)); + return; + } + + visione::services::AuthService service; + callback(visione::http::json(service.currentUser(*user))); +} + diff --git a/controllers/AuthController.h b/controllers/AuthController.h new file mode 100644 index 0000000..41839df --- /dev/null +++ b/controllers/AuthController.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +class AuthController : public drogon::HttpController +{ + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(AuthController::registerCitizen, + "/api/v1/auth/register", + drogon::Post); + ADD_METHOD_TO(AuthController::requestOtp, + "/api/v1/auth/otp/request", + drogon::Post); + ADD_METHOD_TO(AuthController::verifyOtp, + "/api/v1/auth/otp/verify", + drogon::Post); + ADD_METHOD_TO(AuthController::loginWithPassword, + "/api/v1/auth/login/password", + drogon::Post); + ADD_METHOD_TO(AuthController::me, "/api/v1/me", drogon::Get); + METHOD_LIST_END + + void registerCitizen( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void requestOtp( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void verifyOtp( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void loginWithPassword( + const drogon::HttpRequestPtr &req, + std::function &&callback); + void me(const drogon::HttpRequestPtr &req, + std::function &&callback); +}; + diff --git a/controllers/ReportController.cc b/controllers/ReportController.cc new file mode 100644 index 0000000..ec2c792 --- /dev/null +++ b/controllers/ReportController.cc @@ -0,0 +1,176 @@ +#include "controllers/ReportController.h" + +#include "common/Auth.h" +#include "common/Http.h" +#include "services/ReportService.h" + +#include + +using drogon::HttpResponsePtr; + +namespace +{ +std::optional queryValue(const drogon::HttpRequestPtr &req, + const std::string &key) +{ + const auto value = req->getParameter(key); + if (value.empty()) + { + return std::nullopt; + } + return value; +} + +std::optional requireUser( + const drogon::HttpRequestPtr &req, + std::function &callback) +{ + const auto user = visione::auth::authenticate(req); + if (!user) + { + callback(visione::http::error("unauthorized", drogon::k401Unauthorized)); + } + return user; +} + +void sendService(Json::Value body, + std::function &&callback) +{ + if (body.get("ok", false).asBool()) + { + callback(visione::http::json(std::move(body))); + return; + } + const auto error = body.get("error", "").asString(); + const auto status = error == "forbidden" ? drogon::k403Forbidden + : drogon::k400BadRequest; + callback(visione::http::json(std::move(body), status)); +} +} // namespace + +void ReportController::create( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + const auto body = visione::http::requireJson(req); + if (body.isNull()) + { + callback(visione::http::error("invalid_json", drogon::k400BadRequest)); + return; + } + + visione::services::ReportService service; + sendService(service.createReport(*user, body), std::move(callback)); +} + +void ReportController::list( + const drogon::HttpRequestPtr &req, + std::function &&callback) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + + visione::services::ReportService service; + sendService(service.listReports(*user, + queryValue(req, "status"), + queryValue(req, "category_id"), + queryValue(req, "area_id")), + std::move(callback)); +} + +void ReportController::detail( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + visione::services::ReportService service; + sendService(service.reportDetail(*user, reportId), std::move(callback)); +} + +void ReportController::updateStatus( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + const auto body = visione::http::requireJson(req); + visione::services::ReportService service; + sendService(service.updateStatus(*user, reportId, body), std::move(callback)); +} + +void ReportController::addMessage( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + const auto body = visione::http::requireJson(req); + visione::services::ReportService service; + sendService(service.addMessage(*user, reportId, body), std::move(callback)); +} + +void ReportController::listMessages( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + visione::services::ReportService service; + sendService(service.listMessages(*user, reportId), std::move(callback)); +} + +void ReportController::assignTeam( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + const auto body = visione::http::requireJson(req); + visione::services::ReportService service; + sendService(service.assignTeam(*user, reportId, body), std::move(callback)); +} + +void ReportController::addFeedback( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId) +{ + auto user = requireUser(req, callback); + if (!user) + { + return; + } + const auto body = visione::http::requireJson(req); + visione::services::ReportService service; + sendService(service.addFeedback(*user, reportId, body), std::move(callback)); +} + diff --git a/controllers/ReportController.h b/controllers/ReportController.h new file mode 100644 index 0000000..83df6ac --- /dev/null +++ b/controllers/ReportController.h @@ -0,0 +1,57 @@ +#pragma once + +#include + +class ReportController : public drogon::HttpController +{ + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(ReportController::create, "/api/v1/reports", drogon::Post); + ADD_METHOD_TO(ReportController::list, "/api/v1/reports", drogon::Get); + ADD_METHOD_TO(ReportController::detail, "/api/v1/reports/{1}", drogon::Get); + ADD_METHOD_TO(ReportController::updateStatus, + "/api/v1/reports/{1}/status", + drogon::Patch); + ADD_METHOD_TO(ReportController::addMessage, + "/api/v1/reports/{1}/messages", + drogon::Post); + ADD_METHOD_TO(ReportController::listMessages, + "/api/v1/reports/{1}/messages", + drogon::Get); + ADD_METHOD_TO(ReportController::assignTeam, + "/api/v1/reports/{1}/assignments", + drogon::Post); + ADD_METHOD_TO(ReportController::addFeedback, + "/api/v1/reports/{1}/feedback", + drogon::Post); + METHOD_LIST_END + + void create(const drogon::HttpRequestPtr &req, + std::function &&callback); + void list(const drogon::HttpRequestPtr &req, + std::function &&callback); + void detail(const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); + void updateStatus( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); + void addMessage( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); + void listMessages( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); + void assignTeam( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); + void addFeedback( + const drogon::HttpRequestPtr &req, + std::function &&callback, + long long reportId); +}; + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..80c0364 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + mysql: + image: mysql:8.4 + environment: + MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:-root}" + MYSQL_DATABASE: "${MYSQL_DATABASE:-snitch}" + MYSQL_USER: "${MYSQL_USER:-snitch}" + MYSQL_PASSWORD: "${MYSQL_PASSWORD:-snitch}" + ports: + - "${MYSQL_PORT:-3306}:3306" + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 20s + + mailhog: + image: mailhog/mailhog:v1.0.1 + ports: + - "${MAILHOG_SMTP_PORT:-1025}:1025" + - "${MAILHOG_WEB_PORT:-8025}:8025" + +volumes: + mysql-data: diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md new file mode 100644 index 0000000..c7dc13e --- /dev/null +++ b/docs/API_SPEC.md @@ -0,0 +1,37 @@ +# Visione API Spec + +Base path: `/api/v1`. + +## Technical +- `GET /health`: process health. +- `GET /ready`: MySQL readiness. + +## Auth +- `POST /auth/register`: create a citizen account. +- `POST /auth/otp/request`: send an OTP email through MailHog. +- `POST /auth/otp/verify`: verify OTP and create an API token. +- `POST /auth/login/password`: password login fallback. +- `GET /me`: return the authenticated user. + +Protected endpoints use: + +```http +Authorization: Bearer +``` + +## Reports +- `POST /reports`: create a report. +- `GET /reports`: list reports with optional `status`, `category_id`, `area_id`. +- `GET /reports/{id}`: report detail. +- `PATCH /reports/{id}/status`: update status. +- `POST /reports/{id}/messages`: write a report message. +- `GET /reports/{id}/messages`: list report messages. +- `POST /reports/{id}/assignments`: assign a team. +- `POST /reports/{id}/feedback`: add citizen feedback. + +## Admin Reference Data +- `GET|POST /admin/categories` +- `GET|POST /admin/areas` +- `GET|POST /admin/teams` +- `GET /admin/stats/overview` + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6c4b869 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,23 @@ +# Visione Backend Architecture + +## Layout +- `controllers/`: Drogon `HttpController` classes. +- `services/`: application/business logic. +- `repositories/`: database helpers and migrations. +- `common/`: reusable HTTP, auth, crypto, email and validation utilities. +- `migrations/`: versioned SQL files. +- `docs/`: product, API, schema and milestone documentation. + +## Drogon Usage +Controllers use Drogon route macros and return JSON with +`HttpResponse::newHttpJsonResponse`. Database access uses Drogon's MySQL client +with parameterized SQL placeholders. + +The codebase starts with raw SQL repositories because the schema is still +evolving. Drogon ORM/codegen can be introduced later after the database model +stabilizes. + +## Local Email +OTP emails are sent to MailHog over SMTP. MailHog keeps messages local and +inspectable through its web UI. + diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md new file mode 100644 index 0000000..fd7ca52 --- /dev/null +++ b/docs/DATABASE_SCHEMA.md @@ -0,0 +1,24 @@ +# Visione Database Schema + +The schema is stored in `migrations/001_initial_schema.sql`. + +Important tables: + +- `users`: identity, personal data, password hash, points and moderation flags. +- `roles`, `permissions`, `role_permissions`, `user_roles`: authorization. +- `otp_challenges`: local OTP login flow. +- `api_tokens`: bearer tokens for the local API. +- `report_categories`: report classification. +- `areas`: named city areas with GeoJSON polygon boundaries. +- `team_categories`, `teams`: municipal intervention teams. +- `reports`: citizen reports with coordinates, address, status and area. +- `report_status_history`: immutable status transition log. +- `assignments`: report-to-team assignment history. +- `report_photos`: uploaded photo metadata. +- `messages`: citizen/operator report conversation. +- `feedback`: citizen rating and comment after resolution. +- `reward_events`: indirect gamification points ledger. + +GeoJSON is intentionally used instead of MySQL Spatial in milestone 1 to keep +the local setup simple and portable. + diff --git a/docs/LLM_CONTEXT.md b/docs/LLM_CONTEXT.md new file mode 100644 index 0000000..8327aeb --- /dev/null +++ b/docs/LLM_CONTEXT.md @@ -0,0 +1,20 @@ +# Visione Backend LLM Context + +Visione is a C++20/Drogon backend for a municipal urban issue reporting system. +Citizens create reports with category, position, address, description and photos. +Operators manage reports, assign teams, update status and chat with citizens. +Admins manage roles, users, areas, teams and reference data. + +The project runs locally on the host machine. Docker is used only for external +services such as MySQL and MailHog. Real production deployment and Dockerizing +the backend are later delivery tasks. + +Important conventions: + +- Code and database identifiers are English. +- The webapp is Italian-facing, so labels or seeded category names may be Italian. +- Use Drogon references from `/home/gabdevele/Projects/snitchitaly/backend/drogon` + and `https://drogonframework.github.io/drogon-docs/`. +- `AGENTS.md` is the durable agent instruction entrypoint. +- `docs/MILESTONE_01.md` describes what has been implemented and what comes next. + diff --git a/docs/MILESTONE_01.md b/docs/MILESTONE_01.md new file mode 100644 index 0000000..5c0ed70 --- /dev/null +++ b/docs/MILESTONE_01.md @@ -0,0 +1,20 @@ +# Milestone 01: Local Backend Foundation + +## Implemented in this milestone +- Project identity and documentation for Visione. +- Root `AGENTS.md` for future coding agents. +- Local Docker services for MySQL and MailHog. +- Initial MySQL schema and seed data. +- Drogon backend structure for auth, reports, admin data and stats. +- OTP email delivery through MailHog. +- Focused regression tests for technical endpoints and core validation helpers. + +## To do next +- Harden password hashing with Argon2id or bcrypt before production. +- Add real email provider configuration for non-local environments. +- Add CIE/SPID integration design and provider adapters. +- Expand frontend-driven API coverage as Vue screens are implemented. +- Add file upload handling for real multipart photo upload. +- Add notification channels, route optimization and open-data exports. +- Dockerize the backend only for final delivery/deployment. + diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md new file mode 100644 index 0000000..2735b3b --- /dev/null +++ b/docs/PROJECT_SPEC.md @@ -0,0 +1,36 @@ +# Visione Project Spec + +## Goal +Visione lets citizens report urban problems and lets municipal staff manage, +assign and resolve interventions while preserving history and basic analytics. + +## Roles +- `citizen`: registers, requests OTP login, creates reports, follows own reports, + sends messages and leaves feedback after resolution. +- `operator`: views reports, takes open reports, assigns teams, updates managed + reports and messages citizens. +- `admin`: manages users, roles, reference data, teams, areas and all reports. + +## Report Statuses +- `OPEN`: created and waiting for review. +- `UNDER_REVIEW`: an operator is checking the report. +- `ASSIGNED`: a team has been assigned. +- `IN_PROGRESS`: intervention is active. +- `RESOLVED`: intervention was completed. +- `REJECTED`: report was invalid or abusive. +- `CLOSED`: administrative closure after feedback or final review. + +## MVP Scope +- Local auth with email OTP delivered through MailHog. +- MySQL persistence with migrations and seed data. +- Report creation/list/detail/status/message/assignment/feedback flows. +- Area polygons stored as GeoJSON and matched by backend logic. +- Basic stats for administration. + +## Out of Scope for Milestone 1 +- Real CIE integration. +- Real SMTP provider, SMS, Telegram or WhatsApp notifications. +- Route optimization. +- Public open data export. +- Full Docker packaging of the backend. + diff --git a/main.cc b/main.cc new file mode 100644 index 0000000..1667b8b --- /dev/null +++ b/main.cc @@ -0,0 +1,28 @@ +#include + +#include "plugins/RequestLogger.h" +#include "repositories/MigrationRunner.h" + +#include + +int main(int argc, char *argv[]) +{ + setvbuf(stdout, nullptr, _IONBF, 0); + (void)RequestLogger::classTypeName(); + + const auto configPath = argc > 1 ? argv[1] : "config.json"; + Json::Value requestLoggerConfig; + requestLoggerConfig["include_query"] = true; + + drogon::app().loadConfigFile(configPath); + drogon::app().addPlugin("RequestLogger", {}, requestLoggerConfig); + + const auto migrationsPath = drogon::app() + .getCustomConfig() + .get("migrations_path", "migrations") + .asString(); + visione::repositories::MigrationRunner::run(migrationsPath); + + drogon::app().run(); + return 0; +} diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..315caf1 --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -0,0 +1,244 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + fiscal_code VARCHAR(16) NOT NULL, + birth_date DATE NULL, + birth_place VARCHAR(150) NULL, + phone VARCHAR(30) NULL, + points INT NOT NULL DEFAULT 0, + banned BOOLEAN NOT NULL DEFAULT FALSE, + must_change_password BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY users_email_unique (email), + UNIQUE KEY users_fiscal_code_unique (fiscal_code) +); + +CREATE TABLE IF NOT EXISTS roles ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL, + description TEXT NULL, + UNIQUE KEY roles_name_unique (name) +); + +CREATE TABLE IF NOT EXISTS permissions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT NULL, + UNIQUE KEY permissions_name_unique (name) +); + +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (role_id, permission_id), + CONSTRAINT role_permissions_role_fk FOREIGN KEY (role_id) REFERENCES roles(id), + CONSTRAINT role_permissions_permission_fk FOREIGN KEY (permission_id) REFERENCES permissions(id) +); + +CREATE TABLE IF NOT EXISTS user_roles ( + user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (user_id, role_id), + CONSTRAINT user_roles_user_fk FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT user_roles_role_fk FOREIGN KEY (role_id) REFERENCES roles(id) +); + +CREATE TABLE IF NOT EXISTS otp_challenges ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + code_hash VARCHAR(255) NOT NULL, + expires_at DATETIME NOT NULL, + consumed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT otp_challenges_user_fk FOREIGN KEY (user_id) REFERENCES users(id), + KEY otp_challenges_user_expires_idx (user_id, expires_at) +); + +CREATE TABLE IF NOT EXISTS api_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + token_hash VARCHAR(255) NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at DATETIME NULL, + UNIQUE KEY api_tokens_hash_unique (token_hash), + CONSTRAINT api_tokens_user_fk FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS report_categories ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + description TEXT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + UNIQUE KEY report_categories_name_unique (name) +); + +CREATE TABLE IF NOT EXISTS areas ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + description TEXT NULL, + boundary_geojson JSON NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + UNIQUE KEY areas_name_unique (name) +); + +CREATE TABLE IF NOT EXISTS team_categories ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + description TEXT NULL, + UNIQUE KEY team_categories_name_unique (name) +); + +CREATE TABLE IF NOT EXISTS teams ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + team_category_id BIGINT UNSIGNED NOT NULL, + area_id BIGINT UNSIGNED NULL, + name VARCHAR(120) NOT NULL, + description TEXT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + UNIQUE KEY teams_name_unique (name), + CONSTRAINT teams_category_fk FOREIGN KEY (team_category_id) REFERENCES team_categories(id), + CONSTRAINT teams_area_fk FOREIGN KEY (area_id) REFERENCES areas(id) +); + +CREATE TABLE IF NOT EXISTS reports ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + category_id BIGINT UNSIGNED NOT NULL, + area_id BIGINT UNSIGNED NULL, + latitude DECIMAL(10, 7) NOT NULL, + longitude DECIMAL(10, 7) NOT NULL, + address VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + status ENUM('OPEN','UNDER_REVIEW','ASSIGNED','IN_PROGRESS','RESOLVED','REJECTED','CLOSED') NOT NULL DEFAULT 'OPEN', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT reports_user_fk FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT reports_category_fk FOREIGN KEY (category_id) REFERENCES report_categories(id), + CONSTRAINT reports_area_fk FOREIGN KEY (area_id) REFERENCES areas(id), + KEY reports_status_idx (status), + KEY reports_category_idx (category_id), + KEY reports_area_idx (area_id) +); + +CREATE TABLE IF NOT EXISTS report_status_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + report_id BIGINT UNSIGNED NOT NULL, + old_status VARCHAR(30) NULL, + new_status VARCHAR(30) NOT NULL, + changed_by_user_id BIGINT UNSIGNED NULL, + note TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT status_history_report_fk FOREIGN KEY (report_id) REFERENCES reports(id), + CONSTRAINT status_history_user_fk FOREIGN KEY (changed_by_user_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS assignments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + report_id BIGINT UNSIGNED NOT NULL, + team_id BIGINT UNSIGNED NOT NULL, + assigned_by_user_id BIGINT UNSIGNED NOT NULL, + assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + ended_at DATETIME NULL, + CONSTRAINT assignments_report_fk FOREIGN KEY (report_id) REFERENCES reports(id), + CONSTRAINT assignments_team_fk FOREIGN KEY (team_id) REFERENCES teams(id), + CONSTRAINT assignments_user_fk FOREIGN KEY (assigned_by_user_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS report_photos ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + report_id BIGINT UNSIGNED NOT NULL, + url VARCHAR(500) NOT NULL, + uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT report_photos_report_fk FOREIGN KEY (report_id) REFERENCES reports(id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + report_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + body TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT messages_report_fk FOREIGN KEY (report_id) REFERENCES reports(id), + CONSTRAINT messages_user_fk FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS feedback ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + report_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + rating TINYINT UNSIGNED NOT NULL, + comment TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY feedback_report_unique (report_id), + CONSTRAINT feedback_report_fk FOREIGN KEY (report_id) REFERENCES reports(id), + CONSTRAINT feedback_user_fk FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT feedback_rating_check CHECK (rating BETWEEN 1 AND 5) +); + +CREATE TABLE IF NOT EXISTS reward_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + report_id BIGINT UNSIGNED NULL, + points INT NOT NULL, + reason VARCHAR(120) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT reward_events_user_fk FOREIGN KEY (user_id) REFERENCES users(id), + CONSTRAINT reward_events_report_fk FOREIGN KEY (report_id) REFERENCES reports(id) +); + +INSERT IGNORE INTO roles (name, description) VALUES + ('admin', 'System administrator'), + ('operator', 'Municipal operator'), + ('citizen', 'Registered citizen'); + +INSERT IGNORE INTO permissions (name, description) VALUES + ('reports.read_all', 'Read every report'), + ('reports.manage', 'Manage report state and assignments'), + ('admin.manage', 'Manage administrative data'); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r JOIN permissions p +WHERE r.name = 'admin'; + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r JOIN permissions p +WHERE r.name = 'operator' AND p.name IN ('reports.read_all', 'reports.manage'); + +INSERT IGNORE INTO report_categories (name, description) VALUES + ('Buche stradali', 'Problemi relativi al manto stradale'), + ('Illuminazione guasta', 'Lampioni o impianti di illuminazione non funzionanti'), + ('Rifiuti abbandonati', 'Rifiuti fuori dalle aree autorizzate'), + ('Altro', 'Altri disservizi urbani'); + +INSERT IGNORE INTO team_categories (name, description) VALUES + ('Manutenzione strade', 'Squadre per interventi stradali'), + ('Illuminazione pubblica', 'Squadre per rete elettrica e lampioni'), + ('Igiene urbana', 'Squadre per rifiuti e decoro'); + +INSERT IGNORE INTO users ( + first_name, + last_name, + email, + password_hash, + fiscal_code, + phone, + must_change_password +) VALUES ( + 'System', + 'Admin', + 'admin@visione.local', + 'sha256$local-dev-salt$3a85ed70535399cc6c9b41aa26f464f650b52b36c3c62850146d1c7aca26862b', + 'ADMINVISIONE0000', + '+390000000000', + TRUE +); + +INSERT IGNORE INTO user_roles (user_id, role_id) +SELECT u.id, r.id FROM users u JOIN roles r +WHERE u.email = 'admin@visione.local' AND r.name = 'admin'; + diff --git a/plugins/RequestLogger.cc b/plugins/RequestLogger.cc new file mode 100644 index 0000000..c3623b9 --- /dev/null +++ b/plugins/RequestLogger.cc @@ -0,0 +1,33 @@ +#include "RequestLogger.h" + +#include + +namespace +{ +const auto registeredClassName = RequestLogger::classTypeName(); +} + +void RequestLogger::initAndStart(const Json::Value &config) +{ + includeQuery_ = config.get("include_query", true).asBool(); + LOG_INFO << "RequestLogger plugin initialized"; + + drogon::app().registerPreSendingAdvice( + [this](const drogon::HttpRequestPtr &req, + const drogon::HttpResponsePtr &resp) { + auto url = req->path(); + if (includeQuery_ && !req->query().empty()) + { + url += "?"; + url += req->query(); + } + + LOG_INFO << req->methodString() << " " << url << " -> " + << static_cast(resp->statusCode()) << " from " + << req->getPeerAddr().toIpPort(); + }); +} + +void RequestLogger::shutdown() +{ +} diff --git a/plugins/RequestLogger.h b/plugins/RequestLogger.h new file mode 100644 index 0000000..4937bed --- /dev/null +++ b/plugins/RequestLogger.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +class RequestLogger : public drogon::Plugin +{ + public: + void initAndStart(const Json::Value &config) override; + void shutdown() override; + + private: + bool includeQuery_{true}; +}; diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000..bc52437 --- /dev/null +++ b/prompt.md @@ -0,0 +1,185 @@ +devo sviluppare questo progetto, il focus di questa repo è il backend del progetto. +Il backend deve essere necessariamente sviluppato in C++, per questo ho scelto il framework drogon. +Il focus è lo sviluppo del backend in locale su questa macchina, solo alla consegna andrà setuppato la parte docker. +docker viene utilizzato per servizi esterni quali mysql o in futuro per altri servizi quali redis ecc... + +Il nome del progetto non è quello della cartella, chiaramente ironico, ma è "Visione". + +Traccia principale del progetto: +Caso di studio +Segnalazioni urbane +Un’amministrazione comunale desidera sviluppare un sistema digitale per permettere ai cittadini di segnalare problemi presenti sul territorio, come buche stradali, illuminazione guasta, rifiuti abbandonati o altri disservizi urbani, con l’obiettivo di migliorare la gestione degli interventi e la qualità della vita in città. + +Il sistema deve prevedere due principali tipologie di utenti: da un lato i cittadini, che inviano segnalazioni, dall’altro gli operatori comunali, responsabili della gestione e risoluzione degli interventi. + +I cittadini devono potersi registrare e accedere tramite credenziali personali. Una volta autenticati, devono poter inserire una nuova segnalazione specificando il tipo di problema, la posizione e una breve descrizione. Il sistema deve memorizzare le segnalazioni e permettere ai cittadini di visualizzarne lo stato nel tempo, ad esempio in attesa, in lavorazione o risolta. + +Gli utenti devono inoltre poter consultare le segnalazioni già presenti, eventualmente filtrandole per categoria o area, e fornire un feedback sull’efficacia degli interventi effettuati. + +Dal lato degli operatori comunali, il sistema deve consentire di visualizzare tutte le segnalazioni ricevute, assegnarle a specifiche squadre di intervento e aggiornarne lo stato man mano che vengono gestite. Deve essere possibile monitorare i tempi di risposta e individuare le aree della città con maggior concentrazione di problemi. + +Il sistema deve mantenere uno storico delle segnalazioni e degli interventi effettuati, permettendo all’amministrazione di analizzare i dati raccolti per pianificare interventi futuri e migliorare l’efficienza dei servizi. + +Dopodichè ho elaborato una serie di feature che il sistema deve implementare anche rielaborazione e idee. ovvero: +Feature Segnalazioni urbane +– Lo sviluppo del della webapp avverrà su una istanza self hosted di Gitea come gesto di ribellione nei confronti di github che userà i dati sulle repo per trainare i modelli di IA (lo faceva già prima ma ora lo dice) +– Il progetto sarà diviso tra frontend e backend. Frontend in probabilmente Vue.js e backend in C++, nello specifico Dragon è stato scelto come framework. Abbiamo scelto di sviluppare una webapp perchè il punto importante di un progetto del genere è la versatilità, la compatibilità su tutti i sistemi e la velocità. +– Deve esistere un account amministratore generato dal sistema, appena ci si logga la prima volta ci chiederà di cambiare password, e questo profilo lo dovrà teoricamente avere solo chi il tecnico che gestisce la piattaforma di quel comune, dato che da questo profilo si può fare tutto. E’ diverso da un account operatore perchè l’operatore ha meno permessi rispetto ad un admin, l’admin può decidere chi sono gli operatori. (NOTA: eventualmente ci possono essere i permessi customizzabili, quindi l’admin può creare anche altri utenti che hanno permessi in più) +– Deve essere possibile registrarsi alla piattaforma con tutti i quanti i dati anagrafici possibili e immaginabili, sarebbe figo avere un sistema che dato il codice fiscale ti auto completa i dati deducibili da quest’ultimo. Integrazione frontend con google maps per l’indirizzo. Deve essere possibile anche collegare CIE. Nella registrazione si richiede verifica a due fattori +– Il login deve essere effettuato con email o CIE. Nel caso di email la modalità di default non richiede una password ma un OTP, in quanto dimostrato essere più sicuro. (La password è comunque obbligatoria da settare) +– Una volta registrati, se siamo un operatore (comunale, poliziotto, pompiere o chiunque) sarà un admin o un’altro operatore con abbastanza permessi ad assegnarci a quel ruolo e sbloccare i permessi per accedere ad altre parti della piattaforma. +– Admin/operatori vari devono poter creare le varie zone in base al perimetro, se questo è fattibile tramite una mappa sarebbe l’ideale, assegnare i vari nomi ecc.. Questo serve sia per le segnalazioni e per l’assegnazione dell’agente/squadra che deve intervenire sia per fini statistici +– Al sito non si registrano solo gli agenti che intervengono o gli operatori ma anche i cittadini, che possono effettuare delle segnalazioni. Le segnalazioni devono avere una foto, la posizione con coordinate geografiche e via con numero. Il sistema automaticamente capisce a che zona del comune fa parte +– Quando viene creata una segnalazione c’è un indicatore dello stato, tipo: APERTA, IN REVISIONE, AGENTI IN ARRIVO, RISOLTA (sono esempi, vanno migliorati). Di default viene assegnato APERTA. +– Nella segnalazione c’è una chat che viene fatta tra l’operatore e il cittadino per fare eventuali domande. L’operatore quindi può aggiornare lo stato, scrivere messaggi, segnalare il cittadino perchè magari ha inviato un pene come segnalazione. +– L’agente riceverà un summary della segnalazione + numero di telefono di chi ha segnalato, dato che intervengono sul posto non serve che loro chattano, chiamano direttamente. +– La webapp deve avere una pagina con una serie di statistiche +– Si può implementare anche una specie di gamification (indiretta, non diciamolo in maniera ovvia nel sito) del sistema per renderlo più interattivo, tipo ogni segnalazione valida vale dei punti che se accumulati portano a buoni / sconti. +– L’utente e gli operatori devono poter aggiungere anche dei filtri quando si cercano le varie segnalazioni. Inoltre il cittadino deve anche poter lasciare un feedback sull’intervento che hanno fatto, questo sarà collegato quindi anche all’operatore e agli agenti della squadra che sono intervenuti. +– L’operatore deve poter vedere le varie segnalazioni che sta gestendo e anche quelle di altri operatori ma senza poterci interagire / modificarle. +– Tutti vedono e possono prendere in carico le segnalazioni aperte. + +In aggiunta come feature opzionali e secondarie sarebbe carino implementare: +- Notifiche Multicanale: Obbligare il cittadino a rientrare nell'app per controllare lo stato della segnalazione genera frustrazione. Potresti implementare notifiche via Email o l'integrazione con un Bot Telegram/WhatsApp che avvisa automaticamente l'utente quando lo stato passa da "In Lavorazione" a "Risolta". +- Routing Intelligente: Sfruttando le API delle mappe, il sistema potrebbe calcolare per la squadra operativa il percorso ottimale per risolvere 4 o 5 segnalazioni assegnate nella stessa giornata, minimizzando i tempi di spostamento +- Portale Open Data: Le amministrazioni amano promuovere la trasparenza. Potresti creare una pagina pubblica dove chiunque (cittadini, giornalisti, ricercatori) può scaricare un dataset anonimizzato con l'elenco dei disservizi risolti nell'ultimo anno, esportabile in formati standard come JSON o CSV. + +Un mio collega ha definito questo schema per il database, possiamo e dobbiamo sicuramente modificarlo ma può essere un buon punto di partenza: +erDiagram + UTENTE ||--o{ SEGNALAZIONE : "manda" + %%citatdino + UTENTE ||--o{ STORICO_STATO : "aggiorna" + %%operatore + UTENTE ||--o{ ASSEGNAZIONE : "assegna " + %%operatore + UTENTE ||--o{ FEEDBACK : "lascia " + %%citatdino + UTENTE ||--o{ MESSAGGIO : "scrive" + + CATEGORIA_SEGNALAZIONE ||--o{ SEGNALAZIONE : "classifica" + AREA ||--o{ SEGNALAZIONE : "localizza" + AREA ||--o{ SQUADRA : "ha" + %%che relaziobne diu merda + + CATEGORIA_SQUADRA ||--o{ SQUADRA : "ha" + + SEGNALAZIONE ||--o{ STORICO_STATO : "traccia" + SEGNALAZIONE ||--o{ ASSEGNAZIONE : "riceve" + SEGNALAZIONE ||--o| FEEDBACK : "valutata" + SEGNALAZIONE ||--o{ FOTO_SEGNALAZIONE : "documentata" + SEGNALAZIONE ||--o{ MESSAGGIO : "discussa" + + SQUADRA ||--o{ ASSEGNAZIONE : "incaricata" + + UTENTE { + int id PK + string nome + string cognome + string email UK + string password_hash + string codice_fiscale UK + date data_nascita + string luogo_nascita + enum ruolo + string telefono + datetime data_registrazione + int punti + boolean bannato + } + + CATEGORIA_SEGNALAZIONE { + int id PK + string nome + text descrizione + } + + AREA { + int id PK + string nome_quartiere + text descrizione + geometry coordinate_poligono + %%bho + } + + CATEGORIA_SQUADRA { + int id PK + string nome + text descrizione + } + + SEGNALAZIONE { + int id PK + int utente_id FK + int categoria_id FK + int area_id FK + decimal latitudine + decimal longitudine + string indirizzo + text descrizione + enum stato + datetime data_creazione + datetime data_ultima_modifica + } + + STORICO_STATO { + int id PK + int segnalazione_id FK + datetime data + int operatore_id FK + text note + } + + SQUADRA { + int id PK + int categoria_squadra_id FK + int area_id FK + string nome + text descrizione + boolean attiva + } + + + ASSEGNAZIONE { + int id PK + int segnalazione_id FK + int squadra_id FK + int operatore_assegnante_id FK + datetime data_assegnazione + datetime data_cessazione + + } + + FEEDBACK { + int id PK + int segnalazione_id FK, UK + int utente_id FK + int voto + %%?? + text commento + datetime data + } + + FOTO_SEGNALAZIONE { + int id PK + int segnalazione_id FK + string url + datetime data_upload + } + + MESSAGGIO { + int id PK + int segnalazione_id FK + int utente_id FK + text testo + datetime data_invio + } + +Il sito sarà in italiano però è importante che il codice sia scritto in inglese, per mantenere una coerenza con le convenzioni di programmazione e facilitare la comprensione da parte di sviluppatori di diverse nazionalità. Ovviamente nomi propri o cose tipicamente italiane. + +Voglio che il codice segua le best practice del C++ moderno, con un'architettura ben definita e modulare. Il backend deve essere scalabile e facilmente manutenibile. Molto importante per me anche la leggibilità e la semplicità del codice, voglio che sia chiaro e comprensibile anche per chi non è un esperto di C++. + +Dato che sei un LLM e che il framework Drogon non è così popolare, è importante che tu faccia sempre riferimento alla repo clonata in locale /home/gabdevele/Projects/snitchitaly/backend/drogon e alla documentazione ufficiale: https://drogonframework.github.io/drogon-docs/ + +Inoltre fondamentale per questo progetto che scrivi i file markdown che possono aiutare gli llm a capire meglio il progetto. + +Dopo aver definito tutte le feature, le spefiche del progetto e creato tutti i file, è possibile iniziare con lo sviluppo. \ No newline at end of file diff --git a/repositories/MigrationRunner.cc b/repositories/MigrationRunner.cc new file mode 100644 index 0000000..fa52e7a --- /dev/null +++ b/repositories/MigrationRunner.cc @@ -0,0 +1,103 @@ +#include "repositories/MigrationRunner.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace visione::repositories +{ +namespace +{ +std::string trim(std::string value) +{ + auto notSpace = [](unsigned char ch) { return !std::isspace(ch); }; + value.erase(value.begin(), + std::find_if(value.begin(), value.end(), notSpace)); + value.erase(std::find_if(value.rbegin(), value.rend(), notSpace).base(), + value.end()); + return value; +} + +std::vector splitStatements(const std::string &sql) +{ + std::vector statements; + std::string current; + bool inSingleQuote = false; + bool inDoubleQuote = false; + + for (char ch : sql) + { + if (ch == '\'' && !inDoubleQuote) + { + inSingleQuote = !inSingleQuote; + } + else if (ch == '"' && !inSingleQuote) + { + inDoubleQuote = !inDoubleQuote; + } + + if (ch == ';' && !inSingleQuote && !inDoubleQuote) + { + auto statement = trim(current); + if (!statement.empty()) + { + statements.push_back(std::move(statement)); + } + current.clear(); + continue; + } + + current.push_back(ch); + } + + auto statement = trim(current); + if (!statement.empty()) + { + statements.push_back(std::move(statement)); + } + return statements; +} +} // namespace + +void MigrationRunner::run(const std::string &directory) +{ + namespace fs = std::filesystem; + + std::vector files; + if (!fs::exists(directory)) + { + LOG_WARN << "Migration directory not found: " << directory; + return; + } + + for (const auto &entry : fs::directory_iterator(directory)) + { + if (entry.is_regular_file() && entry.path().extension() == ".sql") + { + files.push_back(entry.path()); + } + } + std::sort(files.begin(), files.end()); + + auto db = drogon::app().getDbClient(); + for (const auto &file : files) + { + std::ifstream input(file); + std::stringstream buffer; + buffer << input.rdbuf(); + + for (const auto &statement : splitStatements(buffer.str())) + { + db->execSqlSync(statement); + } + LOG_INFO << "Applied migration " << file.string(); + } +} +} // namespace visione::repositories + diff --git a/repositories/MigrationRunner.h b/repositories/MigrationRunner.h new file mode 100644 index 0000000..d7a781f --- /dev/null +++ b/repositories/MigrationRunner.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace visione::repositories +{ +class MigrationRunner +{ + public: + static void run(const std::string &directory); +}; +} // namespace visione::repositories + diff --git a/repositories/Sql.cc b/repositories/Sql.cc new file mode 100644 index 0000000..dcdf16b --- /dev/null +++ b/repositories/Sql.cc @@ -0,0 +1,44 @@ +#include "repositories/Sql.h" + +#include +#include +#include + +namespace visione::repositories +{ +Json::Value rowToJson(const drogon::orm::Row &row) +{ + Json::Value object(Json::objectValue); + for (const auto &field : row) + { + if (field.isNull()) + { + object[field.name()] = Json::nullValue; + } + else + { + object[field.name()] = field.as(); + } + } + return object; +} + +Json::Value resultToJson(const drogon::orm::Result &result) +{ + Json::Value array(Json::arrayValue); + for (const auto &row : result) + { + array.append(rowToJson(row)); + } + return array; +} + +std::string nullableString(const Json::Value &value, const std::string &key) +{ + if (!value.isMember(key) || value[key].isNull()) + { + return {}; + } + return value[key].asString(); +} +} // namespace visione::repositories diff --git a/repositories/Sql.h b/repositories/Sql.h new file mode 100644 index 0000000..b009403 --- /dev/null +++ b/repositories/Sql.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#include +#include + +namespace visione::repositories +{ +Json::Value rowToJson(const drogon::orm::Row &row); +Json::Value resultToJson(const drogon::orm::Result &result); +std::string nullableString(const Json::Value &value, const std::string &key); +} // namespace visione::repositories + diff --git a/services/AdminService.cc b/services/AdminService.cc new file mode 100644 index 0000000..9caff63 --- /dev/null +++ b/services/AdminService.cc @@ -0,0 +1,141 @@ +#include "services/AdminService.h" + +#include "common/Validation.h" +#include "repositories/Sql.h" + +#include + +namespace visione::services +{ +namespace +{ +Json::Value failure(const std::string &code) +{ + Json::Value body; + body["ok"] = false; + body["error"] = code; + return body; +} + +Json::Value success() +{ + Json::Value body; + body["ok"] = true; + return body; +} +} // namespace + +Json::Value AdminService::listCategories() +{ + auto response = success(); + response["categories"] = repositories::resultToJson( + drogon::app().getDbClient()->execSqlSync( + "SELECT * FROM report_categories ORDER BY name")); + return response; +} + +Json::Value AdminService::createCategory(const auth::UserContext &user, + const Json::Value &body) +{ + if (!auth::isAdmin(user) || !validation::hasString(body, "name")) + { + return failure("forbidden"); + } + + drogon::app().getDbClient()->execSqlSync( + "INSERT INTO report_categories (name, description) VALUES (?, ?)", + body["name"].asString(), + body.get("description", "").asString()); + return success(); +} + +Json::Value AdminService::listAreas() +{ + auto response = success(); + response["areas"] = repositories::resultToJson( + drogon::app().getDbClient()->execSqlSync("SELECT * FROM areas ORDER BY name")); + return response; +} + +Json::Value AdminService::createArea(const auth::UserContext &user, + const Json::Value &body) +{ + if (!auth::isAdmin(user) || !validation::hasString(body, "name") || + !body["boundary_geojson"].isObject()) + { + return failure("invalid_area"); + } + + Json::StreamWriterBuilder writer; + drogon::app().getDbClient()->execSqlSync( + "INSERT INTO areas (name, description, boundary_geojson) " + "VALUES (?, ?, CAST(? AS JSON))", + body["name"].asString(), + body.get("description", "").asString(), + Json::writeString(writer, body["boundary_geojson"])); + return success(); +} + +Json::Value AdminService::listTeams() +{ + auto response = success(); + response["teams"] = repositories::resultToJson( + drogon::app().getDbClient()->execSqlSync( + "SELECT t.*, tc.name AS category_name, a.name AS area_name " + "FROM teams t JOIN team_categories tc ON tc.id = t.team_category_id " + "LEFT JOIN areas a ON a.id = t.area_id ORDER BY t.name")); + return response; +} + +Json::Value AdminService::createTeam(const auth::UserContext &user, + const Json::Value &body) +{ + if (!auth::isAdmin(user) || !validation::hasString(body, "name") || + !validation::hasNumber(body, "team_category_id")) + { + return failure("invalid_team"); + } + + if (body["area_id"].isNumeric()) + { + drogon::app().getDbClient()->execSqlSync( + "INSERT INTO teams (team_category_id, area_id, name, description) " + "VALUES (?, ?, ?, ?)", + body["team_category_id"].asInt64(), + body["area_id"].asInt64(), + body["name"].asString(), + body.get("description", "").asString()); + } + else + { + drogon::app().getDbClient()->execSqlSync( + "INSERT INTO teams (team_category_id, name, description) VALUES (?, ?, ?)", + body["team_category_id"].asInt64(), + body["name"].asString(), + body.get("description", "").asString()); + } + return success(); +} + +Json::Value AdminService::overviewStats(const auth::UserContext &user) +{ + if (!auth::canManageReports(user)) + { + return failure("forbidden"); + } + + auto db = drogon::app().getDbClient(); + auto response = success(); + response["reports_by_status"] = repositories::resultToJson(db->execSqlSync( + "SELECT status, COUNT(*) AS total FROM reports GROUP BY status")); + response["reports_by_area"] = repositories::resultToJson(db->execSqlSync( + "SELECT COALESCE(a.name, 'Unassigned') AS area, COUNT(*) AS total " + "FROM reports r LEFT JOIN areas a ON a.id = r.area_id " + "GROUP BY COALESCE(a.name, 'Unassigned')")); + response["avg_resolution_hours"] = repositories::resultToJson(db->execSqlSync( + "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) AS value " + "FROM reports WHERE status IN ('RESOLVED', 'CLOSED')")); + return response; +} +} // namespace visione::services + diff --git a/services/AdminService.h b/services/AdminService.h new file mode 100644 index 0000000..12a3213 --- /dev/null +++ b/services/AdminService.h @@ -0,0 +1,21 @@ +#pragma once + +#include "common/Auth.h" + +#include + +namespace visione::services +{ +class AdminService +{ + public: + Json::Value listCategories(); + Json::Value createCategory(const auth::UserContext &user, const Json::Value &body); + Json::Value listAreas(); + Json::Value createArea(const auth::UserContext &user, const Json::Value &body); + Json::Value listTeams(); + Json::Value createTeam(const auth::UserContext &user, const Json::Value &body); + Json::Value overviewStats(const auth::UserContext &user); +}; +} // namespace visione::services + diff --git a/services/AuthService.cc b/services/AuthService.cc new file mode 100644 index 0000000..0160e8a --- /dev/null +++ b/services/AuthService.cc @@ -0,0 +1,233 @@ +#include "services/AuthService.h" + +#include "common/Crypto.h" +#include "common/Email.h" +#include "common/Validation.h" + +#include + +namespace visione::services +{ +namespace +{ +Json::Value failure(const std::string &code) +{ + Json::Value body; + body["ok"] = false; + body["error"] = code; + return body; +} + +Json::Value success() +{ + Json::Value body; + body["ok"] = true; + return body; +} + +int otpTtlMinutes() +{ + return drogon::app() + .getCustomConfig()["auth"] + .get("otp_ttl_minutes", 10) + .asInt(); +} + +int tokenTtlHours() +{ + return drogon::app() + .getCustomConfig()["auth"] + .get("token_ttl_hours", 24) + .asInt(); +} + +Json::Value issueToken(long long userId) +{ + auto token = crypto::randomToken(); + drogon::app().getDbClient()->execSqlSync( + "INSERT INTO api_tokens (user_id, token_hash, expires_at) " + "VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? HOUR))", + userId, + crypto::hashSecret(token), + tokenTtlHours()); + + auto body = success(); + body["token"] = token; + body["token_type"] = "Bearer"; + body["expires_in_hours"] = tokenTtlHours(); + return body; +} +} // namespace + +Json::Value AuthService::registerCitizen(const Json::Value &body) +{ + const auto missing = validation::missingStrings( + body, {"first_name", "last_name", "email", "password", "fiscal_code"}); + if (!missing.empty()) + { + auto response = failure("missing_fields"); + for (const auto &field : missing) + { + response["fields"].append(field); + } + return response; + } + + const auto email = body["email"].asString(); + const auto fiscalCode = body["fiscal_code"].asString(); + if (!validation::isValidEmail(email)) + { + return failure("invalid_email"); + } + if (!validation::isValidFiscalCode(fiscalCode)) + { + return failure("invalid_fiscal_code"); + } + + auto db = drogon::app().getDbClient(); + const auto duplicate = db->execSqlSync( + "SELECT id FROM users WHERE email = ? OR fiscal_code = ? LIMIT 1", + email, + fiscalCode); + if (!duplicate.empty()) + { + return failure("user_already_exists"); + } + + db->execSqlSync( + "INSERT INTO users " + "(first_name, last_name, email, password_hash, fiscal_code, birth_date, " + "birth_place, phone) VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?)", + body["first_name"].asString(), + body["last_name"].asString(), + email, + crypto::hashPassword(body["password"].asString()), + fiscalCode, + body.get("birth_date", "").asString(), + body.get("birth_place", "").asString(), + body.get("phone", "").asString()); + + const auto inserted = db->execSqlSync("SELECT LAST_INSERT_ID() AS id"); + const auto userId = inserted[0]["id"].as(); + db->execSqlSync( + "INSERT INTO user_roles (user_id, role_id) " + "SELECT ?, id FROM roles WHERE name = 'citizen'", + userId); + + auto response = success(); + response["user_id"] = Json::Int64(userId); + return response; +} + +Json::Value AuthService::requestOtp(const Json::Value &body) +{ + if (!validation::hasString(body, "email")) + { + return failure("missing_email"); + } + + auto db = drogon::app().getDbClient(); + const auto user = db->execSqlSync( + "SELECT id, email FROM users WHERE email = ? AND banned = FALSE LIMIT 1", + body["email"].asString()); + if (user.empty()) + { + return failure("user_not_found"); + } + + const auto code = crypto::randomNumericCode(6); + const auto userId = user[0]["id"].as(); + db->execSqlSync( + "INSERT INTO otp_challenges (user_id, code_hash, expires_at) " + "VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))", + userId, + crypto::hashSecret(code), + otpTtlMinutes()); + + email::EmailService mail; + if (!mail.sendOtp(user[0]["email"].as(), code)) + { + LOG_WARN << "Could not send OTP email through MailHog"; + return failure("otp_email_failed"); + } + + auto response = success(); + response["delivery"] = "mailhog"; + response["expires_in_minutes"] = otpTtlMinutes(); + return response; +} + +Json::Value AuthService::verifyOtp(const Json::Value &body) +{ + const auto missing = validation::missingStrings(body, {"email", "code"}); + if (!missing.empty()) + { + return failure("missing_fields"); + } + + auto db = drogon::app().getDbClient(); + const auto challenge = db->execSqlSync( + "SELECT c.id, c.user_id FROM otp_challenges c " + "JOIN users u ON u.id = c.user_id " + "WHERE u.email = ? AND c.code_hash = ? AND c.consumed_at IS NULL " + "AND c.expires_at > NOW() ORDER BY c.id DESC LIMIT 1", + body["email"].asString(), + crypto::hashSecret(body["code"].asString())); + if (challenge.empty()) + { + return failure("invalid_otp"); + } + + db->execSqlSync("UPDATE otp_challenges SET consumed_at = NOW() WHERE id = ?", + challenge[0]["id"].as()); + return issueToken(challenge[0]["user_id"].as()); +} + +Json::Value AuthService::loginWithPassword(const Json::Value &body) +{ + const auto missing = validation::missingStrings(body, {"email", "password"}); + if (!missing.empty()) + { + return failure("missing_fields"); + } + + auto db = drogon::app().getDbClient(); + const auto user = db->execSqlSync( + "SELECT id, password_hash FROM users WHERE email = ? AND banned = FALSE " + "LIMIT 1", + body["email"].asString()); + if (user.empty() || + !crypto::verifyPassword(body["password"].asString(), + user[0]["password_hash"].as())) + { + return failure("invalid_credentials"); + } + return issueToken(user[0]["id"].as()); +} + +Json::Value AuthService::currentUser(const auth::UserContext &user) +{ + auto db = drogon::app().getDbClient(); + const auto result = db->execSqlSync( + "SELECT id, first_name, last_name, email, fiscal_code, phone, points, " + "must_change_password FROM users WHERE id = ?", + user.id); + + auto response = success(); + if (!result.empty()) + { + response["user"]["id"] = Json::Int64(result[0]["id"].as()); + response["user"]["first_name"] = result[0]["first_name"].as(); + response["user"]["last_name"] = result[0]["last_name"].as(); + response["user"]["email"] = result[0]["email"].as(); + response["user"]["fiscal_code"] = result[0]["fiscal_code"].as(); + response["user"]["phone"] = result[0]["phone"].as(); + response["user"]["points"] = result[0]["points"].as(); + response["user"]["must_change_password"] = + result[0]["must_change_password"].as(); + response["user"]["role"] = user.role; + } + return response; +} +} // namespace visione::services + diff --git a/services/AuthService.h b/services/AuthService.h new file mode 100644 index 0000000..ec6a07f --- /dev/null +++ b/services/AuthService.h @@ -0,0 +1,19 @@ +#pragma once + +#include "common/Auth.h" + +#include + +namespace visione::services +{ +class AuthService +{ + public: + Json::Value registerCitizen(const Json::Value &body); + Json::Value requestOtp(const Json::Value &body); + Json::Value verifyOtp(const Json::Value &body); + Json::Value loginWithPassword(const Json::Value &body); + Json::Value currentUser(const auth::UserContext &user); +}; +} // namespace visione::services + diff --git a/services/ReportService.cc b/services/ReportService.cc new file mode 100644 index 0000000..b22872d --- /dev/null +++ b/services/ReportService.cc @@ -0,0 +1,376 @@ +#include "services/ReportService.h" + +#include "common/GeoJson.h" +#include "common/Validation.h" +#include "repositories/Sql.h" + +#include + +#include + +namespace visione::services +{ +namespace +{ +Json::Value failure(const std::string &code) +{ + Json::Value body; + body["ok"] = false; + body["error"] = code; + return body; +} + +Json::Value success() +{ + Json::Value body; + body["ok"] = true; + return body; +} + +bool isAllowedStatus(const std::string &status) +{ + static const std::set statuses = { + "OPEN", "UNDER_REVIEW", "ASSIGNED", "IN_PROGRESS", + "RESOLVED", "REJECTED", "CLOSED"}; + return statuses.contains(status); +} + +std::optional findArea(double latitude, double longitude) +{ + const auto result = drogon::app().getDbClient()->execSqlSync( + "SELECT id, JSON_UNQUOTE(JSON_EXTRACT(boundary_geojson, '$')) AS geojson " + "FROM areas WHERE active = TRUE"); + + Json::CharReaderBuilder builder; + for (const auto &row : result) + { + Json::Value geojson; + std::string errors; + const auto raw = row["geojson"].as(); + std::istringstream stream(raw); + if (Json::parseFromStream(builder, stream, &geojson, &errors) && + geojson::pointInPolygon(latitude, longitude, geojson)) + { + return row["id"].as(); + } + } + return std::nullopt; +} + +bool canAccessReport(const auth::UserContext &user, long long reportId) +{ + if (auth::canManageReports(user)) + { + return true; + } + const auto result = drogon::app().getDbClient()->execSqlSync( + "SELECT id FROM reports WHERE id = ? AND user_id = ?", + reportId, + user.id); + return !result.empty(); +} +} // namespace + +Json::Value ReportService::createReport(const auth::UserContext &user, + const Json::Value &body) +{ + const auto missing = + validation::missingStrings(body, {"address", "description"}); + if (!missing.empty() || !validation::hasNumber(body, "category_id") || + !validation::hasNumber(body, "latitude") || + !validation::hasNumber(body, "longitude")) + { + return failure("invalid_report_payload"); + } + + const auto latitude = body["latitude"].asDouble(); + const auto longitude = body["longitude"].asDouble(); + const auto areaId = findArea(latitude, longitude); + + auto db = drogon::app().getDbClient(); + if (areaId) + { + db->execSqlSync( + "INSERT INTO reports " + "(user_id, category_id, area_id, latitude, longitude, address, " + "description) VALUES (?, ?, ?, ?, ?, ?, ?)", + user.id, + body["category_id"].asInt64(), + *areaId, + latitude, + longitude, + body["address"].asString(), + body["description"].asString()); + } + else + { + db->execSqlSync( + "INSERT INTO reports " + "(user_id, category_id, latitude, longitude, address, description) " + "VALUES (?, ?, ?, ?, ?, ?)", + user.id, + body["category_id"].asInt64(), + latitude, + longitude, + body["address"].asString(), + body["description"].asString()); + } + + const auto inserted = db->execSqlSync("SELECT LAST_INSERT_ID() AS id"); + const auto reportId = inserted[0]["id"].as(); + db->execSqlSync( + "INSERT INTO report_status_history " + "(report_id, old_status, new_status, changed_by_user_id, note) " + "VALUES (?, NULL, 'OPEN', ?, 'Report created')", + reportId, + user.id); + + if (body["photos"].isArray()) + { + for (const auto &photo : body["photos"]) + { + if (photo.isString() && !photo.asString().empty()) + { + db->execSqlSync( + "INSERT INTO report_photos (report_id, url) VALUES (?, ?)", + reportId, + photo.asString()); + } + } + } + + auto response = success(); + response["report_id"] = Json::Int64(reportId); + if (areaId) + { + response["area_id"] = Json::Int64(*areaId); + } + return response; +} + +Json::Value ReportService::listReports(const auth::UserContext &user, + const std::optional &status, + const std::optional &categoryId, + const std::optional &areaId) +{ + auto db = drogon::app().getDbClient(); + drogon::orm::Result result(nullptr); + + if (status) + { + result = db->execSqlSync( + "SELECT r.*, c.name AS category_name, a.name AS area_name " + "FROM reports r JOIN report_categories c ON c.id = r.category_id " + "LEFT JOIN areas a ON a.id = r.area_id WHERE r.status = ? " + "ORDER BY r.created_at DESC LIMIT 100", + *status); + } + else if (categoryId) + { + result = db->execSqlSync( + "SELECT r.*, c.name AS category_name, a.name AS area_name " + "FROM reports r JOIN report_categories c ON c.id = r.category_id " + "LEFT JOIN areas a ON a.id = r.area_id WHERE r.category_id = ? " + "ORDER BY r.created_at DESC LIMIT 100", + *categoryId); + } + else if (areaId) + { + result = db->execSqlSync( + "SELECT r.*, c.name AS category_name, a.name AS area_name " + "FROM reports r JOIN report_categories c ON c.id = r.category_id " + "LEFT JOIN areas a ON a.id = r.area_id WHERE r.area_id = ? " + "ORDER BY r.created_at DESC LIMIT 100", + *areaId); + } + else if (auth::canManageReports(user)) + { + result = db->execSqlSync( + "SELECT r.*, c.name AS category_name, a.name AS area_name " + "FROM reports r JOIN report_categories c ON c.id = r.category_id " + "LEFT JOIN areas a ON a.id = r.area_id " + "ORDER BY r.created_at DESC LIMIT 100"); + } + else + { + result = db->execSqlSync( + "SELECT r.*, c.name AS category_name, a.name AS area_name " + "FROM reports r JOIN report_categories c ON c.id = r.category_id " + "LEFT JOIN areas a ON a.id = r.area_id " + "WHERE r.user_id = ? ORDER BY r.created_at DESC LIMIT 100", + user.id); + } + + auto response = success(); + response["reports"] = repositories::resultToJson(result); + return response; +} + +Json::Value ReportService::reportDetail(const auth::UserContext &user, + long long reportId) +{ + if (!canAccessReport(user, reportId)) + { + return failure("forbidden"); + } + + auto db = drogon::app().getDbClient(); + const auto report = db->execSqlSync( + "SELECT r.*, c.name AS category_name, a.name AS area_name " + "FROM reports r JOIN report_categories c ON c.id = r.category_id " + "LEFT JOIN areas a ON a.id = r.area_id WHERE r.id = ?", + reportId); + if (report.empty()) + { + return failure("report_not_found"); + } + + auto response = success(); + response["report"] = repositories::rowToJson(report[0]); + response["history"] = repositories::resultToJson(db->execSqlSync( + "SELECT * FROM report_status_history WHERE report_id = ? ORDER BY id", + reportId)); + response["photos"] = repositories::resultToJson(db->execSqlSync( + "SELECT * FROM report_photos WHERE report_id = ? ORDER BY id", + reportId)); + return response; +} + +Json::Value ReportService::updateStatus(const auth::UserContext &user, + long long reportId, + const Json::Value &body) +{ + if (!auth::canManageReports(user)) + { + return failure("forbidden"); + } + if (!validation::hasString(body, "status") || + !isAllowedStatus(body["status"].asString())) + { + return failure("invalid_status"); + } + + auto db = drogon::app().getDbClient(); + const auto current = db->execSqlSync("SELECT status FROM reports WHERE id = ?", + reportId); + if (current.empty()) + { + return failure("report_not_found"); + } + + db->execSqlSync("UPDATE reports SET status = ? WHERE id = ?", + body["status"].asString(), + reportId); + db->execSqlSync( + "INSERT INTO report_status_history " + "(report_id, old_status, new_status, changed_by_user_id, note) " + "VALUES (?, ?, ?, ?, ?)", + reportId, + current[0]["status"].as(), + body["status"].asString(), + user.id, + body.get("note", "").asString()); + + return success(); +} + +Json::Value ReportService::addMessage(const auth::UserContext &user, + long long reportId, + const Json::Value &body) +{ + if (!canAccessReport(user, reportId) || !validation::hasString(body, "body")) + { + return failure("invalid_message"); + } + + drogon::app().getDbClient()->execSqlSync( + "INSERT INTO messages (report_id, user_id, body) VALUES (?, ?, ?)", + reportId, + user.id, + body["body"].asString()); + return success(); +} + +Json::Value ReportService::listMessages(const auth::UserContext &user, + long long reportId) +{ + if (!canAccessReport(user, reportId)) + { + return failure("forbidden"); + } + + auto response = success(); + response["messages"] = repositories::resultToJson( + drogon::app().getDbClient()->execSqlSync( + "SELECT m.*, u.first_name, u.last_name FROM messages m " + "JOIN users u ON u.id = m.user_id WHERE m.report_id = ? " + "ORDER BY m.created_at", + reportId)); + return response; +} + +Json::Value ReportService::assignTeam(const auth::UserContext &user, + long long reportId, + const Json::Value &body) +{ + if (!auth::canManageReports(user) || !validation::hasNumber(body, "team_id")) + { + return failure("forbidden"); + } + + auto db = drogon::app().getDbClient(); + db->execSqlSync( + "INSERT INTO assignments (report_id, team_id, assigned_by_user_id) " + "VALUES (?, ?, ?)", + reportId, + body["team_id"].asInt64(), + user.id); + db->execSqlSync("UPDATE reports SET status = 'ASSIGNED' WHERE id = ?", + reportId); + db->execSqlSync( + "INSERT INTO report_status_history " + "(report_id, old_status, new_status, changed_by_user_id, note) " + "VALUES (?, NULL, 'ASSIGNED', ?, 'Team assigned')", + reportId, + user.id); + return success(); +} + +Json::Value ReportService::addFeedback(const auth::UserContext &user, + long long reportId, + const Json::Value &body) +{ + if (!validation::hasNumber(body, "rating")) + { + return failure("missing_rating"); + } + const auto rating = body["rating"].asInt(); + if (rating < 1 || rating > 5) + { + return failure("invalid_rating"); + } + + auto db = drogon::app().getDbClient(); + const auto report = db->execSqlSync( + "SELECT user_id, status FROM reports WHERE id = ?", + reportId); + if (report.empty() || report[0]["user_id"].as() != user.id) + { + return failure("forbidden"); + } + if (report[0]["status"].as() != "RESOLVED") + { + return failure("report_not_resolved"); + } + + db->execSqlSync( + "INSERT INTO feedback (report_id, user_id, rating, comment) " + "VALUES (?, ?, ?, ?)", + reportId, + user.id, + rating, + body.get("comment", "").asString()); + return success(); +} +} // namespace visione::services + diff --git a/services/ReportService.h b/services/ReportService.h new file mode 100644 index 0000000..b3dcf73 --- /dev/null +++ b/services/ReportService.h @@ -0,0 +1,35 @@ +#pragma once + +#include "common/Auth.h" + +#include +#include +#include + +namespace visione::services +{ +class ReportService +{ + public: + Json::Value createReport(const auth::UserContext &user, const Json::Value &body); + Json::Value listReports(const auth::UserContext &user, + const std::optional &status, + const std::optional &categoryId, + const std::optional &areaId); + Json::Value reportDetail(const auth::UserContext &user, long long reportId); + Json::Value updateStatus(const auth::UserContext &user, + long long reportId, + const Json::Value &body); + Json::Value addMessage(const auth::UserContext &user, + long long reportId, + const Json::Value &body); + Json::Value listMessages(const auth::UserContext &user, long long reportId); + Json::Value assignTeam(const auth::UserContext &user, + long long reportId, + const Json::Value &body); + Json::Value addFeedback(const auth::UserContext &user, + long long reportId, + const Json::Value &body); +}; +} // namespace visione::services + diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..4912b43 --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,16 @@ +add_executable(${PROJECT_NAME}_test + test_main.cc + ${APP_SOURCES} +) + +target_include_directories(${PROJECT_NAME}_test + PRIVATE + ${CMAKE_SOURCE_DIR} +) + +target_link_libraries(${PROJECT_NAME}_test + PRIVATE + Drogon::Drogon +) + +ParseAndAddDrogonTests(${PROJECT_NAME}_test) diff --git a/test/test_main.cc b/test/test_main.cc new file mode 100644 index 0000000..5a7ec21 --- /dev/null +++ b/test/test_main.cc @@ -0,0 +1,70 @@ +#define DROGON_TEST_MAIN +#include +#include +#include + +#include + +using namespace drogon; + +namespace +{ +std::pair sendRequest(const HttpRequestPtr &request) +{ + auto client = HttpClient::newHttpClient("http://127.0.0.1:15555", + app().getLoop()); + auto promise = + std::make_shared>>(); + auto future = promise->get_future(); + + client->sendRequest( + request, + [promise](ReqResult result, const HttpResponsePtr &response) { + promise->set_value({result, response}); + }); + + return future.get(); +} +} // namespace + +DROGON_TEST(HealthEndpoint) +{ + auto request = HttpRequest::newHttpRequest(); + request->setMethod(Get); + request->setPath("/api/v1/health"); + + auto [result, response] = sendRequest(request); + + REQUIRE(result == ReqResult::Ok); + REQUIRE(response != nullptr); + CHECK(response->getStatusCode() == k200OK); + REQUIRE(response->getJsonObject() != nullptr); + CHECK((*response->getJsonObject())["status"].asString() == "ok"); + CHECK((*response->getJsonObject())["service"].asString() == + "visione-backend"); +} + +int main(int argc, char **argv) +{ + std::promise startedPromise; + auto startedFuture = startedPromise.get_future(); + + app().addListener("127.0.0.1", 15555); + + std::thread serverThread([&startedPromise]() { + app().getLoop()->queueInLoop([&startedPromise]() { + startedPromise.set_value(); + }); + app().run(); + }); + + startedFuture.get(); + const int status = test::run(argc, argv); + + app().getLoop()->queueInLoop([]() { + app().quit(); + }); + serverThread.join(); + + return status; +}