Add Visione backend foundation

This commit is contained in:
gabdevele
2026-06-02 19:22:00 +02:00 Unverified
commit 580789ac83
52 changed files with 5008 additions and 0 deletions
+7
View File
@@ -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
+565
View File
@@ -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++
+6
View File
@@ -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"
}
+34
View File
@@ -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.
+71
View File
@@ -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)
+91
View File
@@ -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<void(const HttpResponsePtr &)> &&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
+1291
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
#include "common/Auth.h"
#include "common/Crypto.h"
#include <drogon/drogon.h>
namespace visione::auth
{
std::optional<std::string> 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<UserContext> 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<long long>(),
row["email"].as<std::string>(),
row["role"].as<std::string>()};
}
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
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <drogon/HttpRequest.h>
#include <optional>
#include <string>
namespace visione::auth
{
struct UserContext
{
long long id{0};
std::string email;
std::string role;
};
std::optional<std::string> bearerToken(const drogon::HttpRequestPtr &request);
std::optional<UserContext> 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
+68
View File
@@ -0,0 +1,68 @@
#include "common/Crypto.h"
#include <drogon/utils/Utilities.h>
#include <iomanip>
#include <random>
#include <sstream>
#include <string>
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<int> distribution(0, 9);
std::string code;
code.reserve(static_cast<size_t>(digits));
for (int i = 0; i < digits; ++i)
{
code.push_back(static_cast<char>('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
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <string>
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
+119
View File
@@ -0,0 +1,119 @@
#include "common/Email.h"
#include <drogon/drogon.h>
#include <array>
#include <cstring>
#include <string>
#include <netdb.h>
#include <sys/socket.h>
#include <unistd.h>
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<char, 512> 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<ssize_t>(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", "[email protected]");
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
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <string>
namespace visione::email
{
class EmailService
{
public:
bool sendOtp(const std::string &to, const std::string &code) const;
};
} // namespace visione::email
+59
View File
@@ -0,0 +1,59 @@
#include "common/GeoJson.h"
#include <vector>
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<Point> 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
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <json/json.h>
namespace visione::geojson
{
bool pointInPolygon(double latitude, double longitude, const Json::Value &geojson);
} // namespace visione::geojson
+35
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <drogon/HttpResponse.h>
#include <json/json.h>
#include <string>
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
+45
View File
@@ -0,0 +1,45 @@
#include "common/Validation.h"
#include <regex>
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<std::string> missingStrings(
const Json::Value &body,
std::initializer_list<const char *> keys)
{
std::vector<std::string> 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
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <json/json.h>
#include <string>
#include <vector>
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<std::string> missingStrings(const Json::Value &body,
std::initializer_list<const char *> keys);
bool isValidEmail(const std::string &email);
bool isValidFiscalCode(const std::string &fiscalCode);
} // namespace visione::validation
+62
View File
@@ -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": "[email protected]"
},
"cors": {
"allowed_origin": "http://localhost:5173"
}
}
}
+116
View File
@@ -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<visione::auth::UserContext> requireUser(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&callback)
{
visione::services::AdminService service;
callback(visione::http::json(service.listCategories()));
}
void AdminController::createCategory(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&callback)
{
visione::services::AdminService service;
callback(visione::http::json(service.listAreas()));
}
void AdminController::createArea(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&callback)
{
visione::services::AdminService service;
callback(visione::http::json(service.listTeams()));
}
void AdminController::createTeam(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&callback)
{
auto user = requireUser(req, callback);
if (!user)
{
return;
}
visione::services::AdminService service;
sendAdmin(service.overviewStats(*user), std::move(callback));
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <drogon/HttpController.h>
class AdminController : public drogon::HttpController<AdminController>
{
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<void(const drogon::HttpResponsePtr &)> &&callback);
void createCategory(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void listAreas(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void createArea(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void listTeams(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void createTeam(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void overviewStats(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
};
+72
View File
@@ -0,0 +1,72 @@
#include "ApiController.h"
#include <drogon/drogon.h>
#include <drogon/orm/Exception.h>
#include <json/json.h>
#include <memory>
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<void(const HttpResponsePtr &)> &&callback)
{
Json::Value body;
body["status"] = "ok";
body["service"] = "visione-backend";
callback(jsonResponse(std::move(body)));
}
void ApiController::ready(
const drogon::HttpRequestPtr &,
std::function<void(const HttpResponsePtr &)> &&callback)
{
auto responseCallback = std::make_shared<
std::function<void(const HttpResponsePtr &)>>(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));
}
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <drogon/HttpController.h>
class ApiController : public drogon::HttpController<ApiController>
{
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<void(const drogon::HttpResponsePtr &)> &&callback);
void ready(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
};
+98
View File
@@ -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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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)));
}
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <drogon/HttpController.h>
class AuthController : public drogon::HttpController<AuthController>
{
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<void(const drogon::HttpResponsePtr &)> &&callback);
void requestOtp(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void verifyOtp(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void loginWithPassword(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void me(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
};
+176
View File
@@ -0,0 +1,176 @@
#include "controllers/ReportController.h"
#include "common/Auth.h"
#include "common/Http.h"
#include "services/ReportService.h"
#include <optional>
using drogon::HttpResponsePtr;
namespace
{
std::optional<std::string> 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<visione::auth::UserContext> requireUser(
const drogon::HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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));
}
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <drogon/HttpController.h>
class ReportController : public drogon::HttpController<ReportController>
{
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<void(const drogon::HttpResponsePtr &)> &&callback);
void list(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback);
void detail(const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long reportId);
void updateStatus(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long reportId);
void addMessage(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long reportId);
void listMessages(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long reportId);
void assignTeam(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long reportId);
void addFeedback(
const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback,
long long reportId);
};
+27
View File
@@ -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:
+37
View File
@@ -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 <token>
```
## 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`
+23
View File
@@ -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.
+24
View File
@@ -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.
+20
View File
@@ -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.
+20
View File
@@ -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.
+36
View File
@@ -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.
+28
View File
@@ -0,0 +1,28 @@
#include <drogon/drogon.h>
#include "plugins/RequestLogger.h"
#include "repositories/MigrationRunner.h"
#include <cstdio>
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;
}
+244
View File
@@ -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',
'[email protected]',
'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 = '[email protected]' AND r.name = 'admin';
+33
View File
@@ -0,0 +1,33 @@
#include "RequestLogger.h"
#include <drogon/drogon.h>
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<int>(resp->statusCode()) << " from "
<< req->getPeerAddr().toIpPort();
});
}
void RequestLogger::shutdown()
{
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <drogon/plugins/Plugin.h>
class RequestLogger : public drogon::Plugin<RequestLogger>
{
public:
void initAndStart(const Json::Value &config) override;
void shutdown() override;
private:
bool includeQuery_{true};
};
+185
View File
@@ -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.
+103
View File
@@ -0,0 +1,103 @@
#include "repositories/MigrationRunner.h"
#include <drogon/drogon.h>
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
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<std::string> splitStatements(const std::string &sql)
{
std::vector<std::string> 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<fs::path> 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
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <string>
namespace visione::repositories
{
class MigrationRunner
{
public:
static void run(const std::string &directory);
};
} // namespace visione::repositories
+44
View File
@@ -0,0 +1,44 @@
#include "repositories/Sql.h"
#include <drogon/orm/Field.h>
#include <drogon/orm/ResultIterator.h>
#include <drogon/orm/RowIterator.h>
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<std::string>();
}
}
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
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <drogon/orm/Result.h>
#include <json/json.h>
#include <string>
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
+141
View File
@@ -0,0 +1,141 @@
#include "services/AdminService.h"
#include "common/Validation.h"
#include "repositories/Sql.h"
#include <drogon/drogon.h>
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
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include "common/Auth.h"
#include <json/json.h>
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
+233
View File
@@ -0,0 +1,233 @@
#include "services/AuthService.h"
#include "common/Crypto.h"
#include "common/Email.h"
#include "common/Validation.h"
#include <drogon/drogon.h>
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<long long>();
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<long long>();
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<std::string>(), 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<long long>());
return issueToken(challenge[0]["user_id"].as<long long>());
}
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<std::string>()))
{
return failure("invalid_credentials");
}
return issueToken(user[0]["id"].as<long long>());
}
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<long long>());
response["user"]["first_name"] = result[0]["first_name"].as<std::string>();
response["user"]["last_name"] = result[0]["last_name"].as<std::string>();
response["user"]["email"] = result[0]["email"].as<std::string>();
response["user"]["fiscal_code"] = result[0]["fiscal_code"].as<std::string>();
response["user"]["phone"] = result[0]["phone"].as<std::string>();
response["user"]["points"] = result[0]["points"].as<int>();
response["user"]["must_change_password"] =
result[0]["must_change_password"].as<bool>();
response["user"]["role"] = user.role;
}
return response;
}
} // namespace visione::services
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include "common/Auth.h"
#include <json/json.h>
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
+376
View File
@@ -0,0 +1,376 @@
#include "services/ReportService.h"
#include "common/GeoJson.h"
#include "common/Validation.h"
#include "repositories/Sql.h"
#include <drogon/drogon.h>
#include <set>
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<std::string> statuses = {
"OPEN", "UNDER_REVIEW", "ASSIGNED", "IN_PROGRESS",
"RESOLVED", "REJECTED", "CLOSED"};
return statuses.contains(status);
}
std::optional<long long> 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::string>();
std::istringstream stream(raw);
if (Json::parseFromStream(builder, stream, &geojson, &errors) &&
geojson::pointInPolygon(latitude, longitude, geojson))
{
return row["id"].as<long long>();
}
}
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<long long>();
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<std::string> &status,
const std::optional<std::string> &categoryId,
const std::optional<std::string> &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<std::string>(),
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<long long>() != user.id)
{
return failure("forbidden");
}
if (report[0]["status"].as<std::string>() != "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
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "common/Auth.h"
#include <json/json.h>
#include <optional>
#include <string>
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<std::string> &status,
const std::optional<std::string> &categoryId,
const std::optional<std::string> &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
+16
View File
@@ -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)
+70
View File
@@ -0,0 +1,70 @@
#define DROGON_TEST_MAIN
#include <drogon/HttpClient.h>
#include <drogon/drogon.h>
#include <drogon/drogon_test.h>
#include <future>
using namespace drogon;
namespace
{
std::pair<ReqResult, HttpResponsePtr> sendRequest(const HttpRequestPtr &request)
{
auto client = HttpClient::newHttpClient("http://127.0.0.1:15555",
app().getLoop());
auto promise =
std::make_shared<std::promise<std::pair<ReqResult, HttpResponsePtr>>>();
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<void> 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;
}