Compare commits

...
10 Commits
Author SHA1 Message Date
Lorenzo Sanesi 91f404ac96 Fix magazzino 2026-08-02 15:18:34 +02:00
Lorenzo Sanesi 0fdf395733 Sistemato magazzino 2026-08-01 03:12:23 +02:00
Lorenzo Sanesi de921abcd6 Sistemato attività 2026-07-26 03:19:11 +02:00
Lorenzo Sanesi 8d1a3e0d18 Sistemato home e keycloak 2026-07-26 00:38:08 +02:00
Lorenzo Sanesi 5855352c8c Fix Generali 2026-07-25 18:40:29 +02:00
Lorenzo Sanesi 1326a3c887 Fix sql e migration 2026-07-25 12:38:44 +02:00
Lorenzo Sanesi a4cca3f2c1 Fix sql e migration 2026-07-25 12:37:29 +02:00
Lorenzo Sanesi 7cd0a1ff1b Fix db, keyclaok .env.example e docker compose 2026-07-25 12:10:54 +02:00
Lorenzo Sanesi 5b7c82d359 Fix scouthub-home-be 2026-07-25 12:09:12 +02:00
Lorenzo Sanesi ac92ca43ce Fix grafica scouthub-attivita-fe 2026-07-25 12:03:59 +02:00
283 changed files with 16423 additions and 4497 deletions
+29 -14
View File
@@ -2,26 +2,41 @@
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
POSTGRES_DB=scouthub # Database di scouthub-home-be. Sulla stessa istanza vivono anche
# scouthub / scouthub_magazzino / scouthub_eventi (creati da db-init/
# al primo avvio), usati rispettivamente da attivita-backend/magazzino-backend/
# eventi-backend con le stesse credenziali POSTGRES_USER/POSTGRES_PASSWORD.
POSTGRES_DB=scouthub_home
POSTGRES_PORT=5432 POSTGRES_PORT=5432
ATTIVITA_BACKEND_PORT=8080
# Deve restare 8080: il frontend Angular ha l'URL dell'API hardcoded
# in src/environments/environment.ts (apiUrl: 'http://localhost:8080')
ATTIVITA_FRONTEND_PORT=4200
KEYCLOAK_DB_PASSWORD=keycloak_change_me KEYCLOAK_DB_PASSWORD=keycloak_change_me
KEYCLOAK_ADMIN_PASSWORD=admin_change_me KEYCLOAK_ADMIN_PASSWORD=admin_change_me
# Se in locale esegui anche scouthub-home-be fuori da Docker (npm run dev), # Keycloak ha una porta tutta sua, separata dai range frontend/backend qui sotto.
# il suo PORT di default (8082) è già scelto per non collidere con questo. KEYCLOAK_PORT=6999
KEYCLOAK_PORT=8081
# Deve restare 8082: scouthub-home-fe ha l'URL dell'org-service hardcoded # Porte frontend, a partire da 7000, in ordine home/attivita/magazzino/calendario.
# in src/environments/environment.ts (orgServiceApiBaseUrl: 'http://localhost:8082') # Nessuna e' hardcoded nel codice: i frontend Angular ricevono gli URL (Keycloak,
HOME_BACKEND_PORT=8082 # API, cross-frontend) come build ARG Docker valorizzati da queste variabili
HOME_FRONTEND_PORT=4201 # (vedi build.args dei rispettivi servizi in docker-compose.yml).
HOME_FRONTEND_PORT=7000
ATTIVITA_FRONTEND_PORT=7001
MAGAZZINO_FRONTEND_PORT=7002
EVENTI_FRONTEND_PORT=7003
# Porte backend, a partire da 8000, stesso ordine home/attivita/magazzino/calendario.
HOME_BACKEND_PORT=8000
ATTIVITA_BACKEND_PORT=8001
MAGAZZINO_BACKEND_PORT=8002
EVENTI_BACKEND_PORT=8003
# Deve coincidere con il secret del client "scouthub-home-be" in keycloak/realm-export.json # Deve coincidere con il secret del client "scouthub-home-be" in keycloak/realm-export.json
KEYCLOAK_ORG_SERVICE_CLIENT_SECRET=change_me KEYCLOAK_ORG_SERVICE_CLIENT_SECRET=change_me
# Deve coincidere con il secret del client "scouthub-magazzino-be" in keycloak/realm-export.json
KEYCLOAK_MAGAZZINO_CLIENT_SECRET=change_me
# Deve coincidere con il secret del client "scouthub-eventi-be" in keycloak/realm-export.json
# (secret condiviso anche con scouthub-attivita-be e scouthub-magazzino-be, che lo usano per
# chiamare POST /eventi/:id/risorse su scouthub-eventi-be)
KEYCLOAK_EVENTI_CLIENT_SECRET=change_me
@@ -1,5 +1,5 @@
-- scouthub-home-be usa un database separato sulla stessa istanza Postgres di "db". -- scouthub-attivita-be usa un database separato sulla stessa istanza Postgres di "db".
-- Eseguito solo al primo avvio del container (volume dati vuoto). -- Eseguito solo al primo avvio del container (volume dati vuoto).
SELECT 'CREATE DATABASE scouthub_home' SELECT 'CREATE DATABASE scouthub'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'scouthub_home') WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'scouthub')
\gexec \gexec
+5
View File
@@ -0,0 +1,5 @@
-- scouthub-eventi-be usa un database separato sulla stessa istanza Postgres di "db".
-- Eseguito solo al primo avvio del container (volume dati vuoto).
SELECT 'CREATE DATABASE scouthub_eventi'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'scouthub_eventi')
\gexec
+5
View File
@@ -0,0 +1,5 @@
-- scouthub-magazzino-be usa un database separato sulla stessa istanza Postgres di "db".
-- Eseguito solo al primo avvio del container (volume dati vuoto).
SELECT 'CREATE DATABASE scouthub_magazzino'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'scouthub_magazzino')
\gexec
+101 -10
View File
@@ -10,7 +10,8 @@ services:
- "${POSTGRES_PORT}:5432" - "${POSTGRES_PORT}:5432"
volumes: volumes:
- scouthub_db_data:/var/lib/postgresql/data - scouthub_db_data:/var/lib/postgresql/data
# Crea anche il DB scouthub_home usato da scouthub-home-be (solo al primo init). # Crea anche i DB scouthub / scouthub_magazzino / scouthub_eventi
# usati dagli altri backend, sulla stessa istanza (solo al primo init).
- ./db-init:/docker-entrypoint-initdb.d:ro - ./db-init:/docker-entrypoint-initdb.d:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
@@ -25,7 +26,7 @@ services:
environment: environment:
PORT: ${ATTIVITA_BACKEND_PORT} PORT: ${ATTIVITA_BACKEND_PORT}
CORS_ORIGIN: http://localhost:${ATTIVITA_FRONTEND_PORT} CORS_ORIGIN: http://localhost:${ATTIVITA_FRONTEND_PORT}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/scouthub?schema=public
KEYCLOAK_BASE_URL: http://keycloak:8080 KEYCLOAK_BASE_URL: http://keycloak:8080
KEYCLOAK_REALM: scouthub KEYCLOAK_REALM: scouthub
ports: ports:
@@ -39,6 +40,9 @@ services:
attivita-frontend: attivita-frontend:
build: build:
context: ./scouthub-attivita-fe context: ./scouthub-attivita-fe
args:
API_URL: http://localhost:${ATTIVITA_BACKEND_PORT}
KEYCLOAK_BASE_URL: http://localhost:${KEYCLOAK_PORT}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${ATTIVITA_FRONTEND_PORT}:80" - "${ATTIVITA_FRONTEND_PORT}:80"
@@ -51,7 +55,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
PORT: ${HOME_BACKEND_PORT} PORT: ${HOME_BACKEND_PORT}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/scouthub_home?schema=public DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
FRONTEND_BASE_URL: http://localhost:${HOME_FRONTEND_PORT} FRONTEND_BASE_URL: http://localhost:${HOME_FRONTEND_PORT}
# Rete interna Docker: la validazione del JWT (JWKS) non controlla l'issuer, # Rete interna Docker: la validazione del JWT (JWKS) non controlla l'issuer,
# quindi non serve che coincida con l'hostname visto dal browser (localhost:8081). # quindi non serve che coincida con l'hostname visto dal browser (localhost:8081).
@@ -70,12 +74,81 @@ services:
home-frontend: home-frontend:
build: build:
context: ./scouthub-home-fe context: ./scouthub-home-fe
args:
KEYCLOAK_BASE_URL: http://localhost:${KEYCLOAK_PORT}
ORG_SERVICE_API_BASE_URL: http://localhost:${HOME_BACKEND_PORT}
ATTIVITA_FE_BASE_URL: http://localhost:${ATTIVITA_FRONTEND_PORT}
MAGAZZINO_FE_BASE_URL: http://localhost:${MAGAZZINO_FRONTEND_PORT}
EVENTI_FE_BASE_URL: http://localhost:${EVENTI_FRONTEND_PORT}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${HOME_FRONTEND_PORT}:80" - "${HOME_FRONTEND_PORT}:80"
depends_on: depends_on:
- home-backend - home-backend
magazzino-backend:
build:
context: ./scouthub-magazzino-be
restart: unless-stopped
environment:
PORT: ${MAGAZZINO_BACKEND_PORT}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/scouthub_magazzino?schema=public
KEYCLOAK_BASE_URL: http://keycloak:8080
KEYCLOAK_REALM: scouthub
KEYCLOAK_MAGAZZINO_CLIENT_ID: scouthub-magazzino-be
KEYCLOAK_MAGAZZINO_CLIENT_SECRET: ${KEYCLOAK_MAGAZZINO_CLIENT_SECRET}
ports:
- "${MAGAZZINO_BACKEND_PORT}:${MAGAZZINO_BACKEND_PORT}"
depends_on:
db:
condition: service_healthy
keycloak:
condition: service_started
magazzino-frontend:
build:
context: ./scouthub-magazzino-fe
args:
KEYCLOAK_BASE_URL: http://localhost:${KEYCLOAK_PORT}
MAGAZZINO_API_BASE_URL: http://localhost:${MAGAZZINO_BACKEND_PORT}
restart: unless-stopped
ports:
- "${MAGAZZINO_FRONTEND_PORT}:80"
depends_on:
- magazzino-backend
eventi-backend:
build:
context: ./scouthub-eventi-be
restart: unless-stopped
environment:
PORT: ${EVENTI_BACKEND_PORT}
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/scouthub_eventi?schema=public
KEYCLOAK_BASE_URL: http://keycloak:8080
KEYCLOAK_REALM: scouthub
KEYCLOAK_EVENTI_CLIENT_ID: scouthub-eventi-be
KEYCLOAK_EVENTI_CLIENT_SECRET: ${KEYCLOAK_EVENTI_CLIENT_SECRET}
KEYCLOAK_AUTHORIZED_SERVICE_CLIENTS: scouthub-eventi-be
ports:
- "${EVENTI_BACKEND_PORT}:${EVENTI_BACKEND_PORT}"
depends_on:
db:
condition: service_healthy
keycloak:
condition: service_started
eventi-frontend:
build:
context: ./scouthub-eventi-fe
args:
KEYCLOAK_BASE_URL: http://localhost:${KEYCLOAK_PORT}
EVENTI_API_BASE_URL: http://localhost:${EVENTI_BACKEND_PORT}
restart: unless-stopped
ports:
- "${EVENTI_FRONTEND_PORT}:80"
depends_on:
- eventi-backend
keycloak-db: keycloak-db:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
@@ -89,13 +162,11 @@ services:
keycloak: keycloak:
image: quay.io/keycloak/keycloak:26.7 image: quay.io/keycloak/keycloak:26.7
restart: unless-stopped restart: unless-stopped
# --import-realm importa keycloak/realm-export.json solo se il realm non esiste già # --import-realm importa keycloak/realm-export.json solo se il realm non esiste già.
# NOTA: dopo l'import, assegnare al service account di scouthub-home-be i client role # Le correzioni post-import (ruoli service account, protocol mapper "organization") non
# del client 'realm-management': manage-organizations, manage-users, manage-groups, # coperte dal file JSON sono automatizzate dal servizio one-shot "keycloak-init" sotto,
# view-users (permessi minimi, non manage-realm). Con Fine-Grained Admin Permissions # non più manuali (vedi CLAUDE.md).
# (26.7+, feature admin-fine-grained-authz:v1) si puo' poi restringere ulteriormente command: start-dev --import-realm --health-enabled=true
# per singola Organization.
command: start-dev --import-realm
environment: environment:
KC_DB: postgres KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
@@ -114,8 +185,28 @@ services:
- "${KEYCLOAK_PORT}:8080" - "${KEYCLOAK_PORT}:8080"
volumes: volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
- ./keycloak/themes/scouthub:/opt/keycloak/themes/scouthub:ro
depends_on: depends_on:
- keycloak-db - keycloak-db
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '\"status\": \"UP\"' <&3"]
interval: 5s
timeout: 5s
retries: 30
start_period: 30s
keycloak-init:
image: alpine:3.20
restart: "no"
entrypoint: ["sh", "-c", "apk add --no-cache curl jq >/dev/null && sh /init-post-import.sh"]
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
volumes:
- ./keycloak/init-post-import.sh:/init-post-import.sh:ro
depends_on:
keycloak:
condition: service_healthy
volumes: volumes:
scouthub_db_data: scouthub_db_data:
+14 -9
View File
@@ -4,21 +4,26 @@ Realm `scouthub` con feature Organizations abilitata, importato automaticamente
all'avvio di Keycloak (`--import-realm` nel `docker-compose.yml` alla radice). all'avvio di Keycloak (`--import-realm` nel `docker-compose.yml` alla radice).
Basato su `keycloak/realm.json` (bozza di riferimento), con l'aggiunta del Basato su `keycloak/realm.json` (bozza di riferimento), con l'aggiunta del
ruolo `admin-centrale` richiesto da `POST /gruppi` in `scouthub-home-be` ruolo `admin` richiesto da `POST /gruppi` e `GET /gruppi` in
(placeholder temporaneo, vedi il TODO in `src/routes/gruppi.routes.ts`). `scouthub-home-be`: creazione/elenco diretti restano riservati ad `admin`,
mentre un utente normale passa dal flusso self-service di richiesta
creazione gruppo (`POST /richieste-creazione-gruppo`), che alla review
positiva invoca la stessa `createGruppo()` internamente.
Contiene già: Contiene già:
- i realm role usati dal backend: `admin-centrale`, `capo-gruppo`, - i realm role usati dal backend, in gerarchia composite (`admin` include
`capo-unita`, `censito`, più il placeholder `manage-organizations` `capo-gruppo` include `capo-unita` include `capo` include `censito`; `admin`
include anche `moderatore`): `admin`, `capo-gruppo`, `capo-unita`, `capo`,
`censito`, `moderatore`, più il placeholder `manage-organizations`
- il client scope `organization` (aggiunge id/attributi della Organization - il client scope `organization` (aggiunge id/attributi della Organization
nei token OIDC) nei token OIDC)
- il client confidential `scouthub-home-be` (service account abilitato, - il client confidential `scouthub-home-be` (service account abilitato,
usato dal backend per il flow client-credentials verso le Admin REST API — usato dal backend per il flow client-credentials verso le Admin REST API —
il suo `clientId`/`secret` devono corrispondere a il suo `clientId`/`secret` devono corrispondere a
`KEYCLOAK_ORG_SERVICE_CLIENT_ID`/`_SECRET` in `scouthub-home-be/.env.example`) `KEYCLOAK_ORG_SERVICE_CLIENT_ID`/`_SECRET` in `scouthub-home-be/.env.example`)
- il client pubblico `scouthub-frontend` (login utente, redirect su - il client pubblico `scouthub-frontend` (login utente, condiviso da tutti i
`http://localhost:4200/*`) frontend Angular del progetto, redirect su `http://localhost:7000-7003/*`)
Passi manuali ancora da fare dopo l'import (non automatizzabili in un realm Passi manuali ancora da fare dopo l'import (non automatizzabili in un realm
export senza conoscere gli id generati a runtime): export senza conoscere gli id generati a runtime):
@@ -49,15 +54,15 @@ I passaggi da fare sono:
- manage-users - manage-users
- view-users - view-users
9. Clicca Assign. 9. Clicca Assign.
10. Dopo fatto questo bisogna fare un user admin-centrale 10. Dopo fatto questo bisogna fare un user admin
I passaggi da fare per admin-centrale: I passaggi da fare per admin:
1. Apri http://localhost:8081/admin, login admin/admin, cambia realm in scouthub. 1. Apri http://localhost:8081/admin, login admin/admin, cambia realm in scouthub.
2. Menu laterale → Users → clicca su movioletto@yahoo.it. 2. Menu laterale → Users → clicca su movioletto@yahoo.it.
3. Vai sul tab "Role mapping". 3. Vai sul tab "Role mapping".
4. Clicca "Assign role". 4. Clicca "Assign role".
5. Assicurati che il filtro sia su "Filter by realm roles" (dovrebbe esserlo di default per gli utenti normali). 5. Assicurati che il filtro sia su "Filter by realm roles" (dovrebbe esserlo di default per gli utenti normali).
6. Cerca e seleziona admin-centrale. 6. Cerca e seleziona admin.
7. Clicca Assign. 7. Clicca Assign.
⚠️ Il secret del client `scouthub-home-be` (`CAMBIA-QUESTO-SECRET-IN-UN-VAULT`) ⚠️ Il secret del client `scouthub-home-be` (`CAMBIA-QUESTO-SECRET-IN-UN-VAULT`)
+98
View File
@@ -0,0 +1,98 @@
#!/bin/sh
# Corregge due impostazioni che l'import di realm-export.json non copre (vedi CLAUDE.md):
# 1. il client-scope built-in "organization" (creato da Keycloak con organizationsEnabled:true,
# non presente nel JSON) di default non include l'id dell'org nel claim JWT
# 2. il service account di scouthub-home-be non ha i client role di realm-management necessari
# a operare sulla Admin API (organizations, users, roles)
#
# Idempotente: eseguibile ad ogni avvio senza effetti collaterali se già a posto.
set -eu
KEYCLOAK_URL="http://keycloak:8080"
REALM="scouthub"
SA_CLIENT_ID="scouthub-home-be"
REQUIRED_REALM_MANAGEMENT_ROLES="manage-organizations manage-users view-realm view-users"
echo "[keycloak-init] attendo che $KEYCLOAK_URL sia pronto..."
until curl -sf "$KEYCLOAK_URL/realms/master/.well-known/openid-configuration" >/dev/null 2>&1; do
sleep 2
done
echo "[keycloak-init] attendo che il realm '$REALM' sia importato..."
until curl -sf "$KEYCLOAK_URL/realms/$REALM/.well-known/openid-configuration" >/dev/null 2>&1; do
sleep 2
done
get_admin_token() {
curl -sf -X POST "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \
-d "client_id=admin-cli" \
-d "username=$KEYCLOAK_ADMIN" \
-d "password=$KEYCLOAK_ADMIN_PASSWORD" \
-d "grant_type=password" | jq -r '.access_token'
}
TOKEN="$(get_admin_token)"
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "[keycloak-init] impossibile ottenere il token admin, esco." >&2
exit 1
fi
auth_get() { curl -sf -H "Authorization: Bearer $TOKEN" "$KEYCLOAK_URL$1"; }
# --- 1. client-scope "organization": abilita addOrganizationId -------------------------------
SCOPE_ID="$(auth_get "/admin/realms/$REALM/client-scopes" | jq -r '.[] | select(.name=="organization") | .id')"
if [ -z "$SCOPE_ID" ]; then
echo "[keycloak-init] client-scope 'organization' non trovato, salto." >&2
else
MAPPER_JSON="$(auth_get "/admin/realms/$REALM/client-scopes/$SCOPE_ID/protocol-mappers/models" \
| jq -c '.[] | select(.name=="organization")')"
MAPPER_ID="$(echo "$MAPPER_JSON" | jq -r '.id')"
ALREADY_SET="$(echo "$MAPPER_JSON" | jq -r '.config.addOrganizationId // "false"')"
if [ "$ALREADY_SET" = "true" ]; then
echo "[keycloak-init] mapper 'organization': addOrganizationId già attivo."
else
UPDATED_MAPPER="$(echo "$MAPPER_JSON" | jq '.config.addOrganizationId = "true"')"
curl -sf -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"$KEYCLOAK_URL/admin/realms/$REALM/client-scopes/$SCOPE_ID/protocol-mappers/models/$MAPPER_ID" \
-d "$UPDATED_MAPPER" >/dev/null
echo "[keycloak-init] mapper 'organization': addOrganizationId attivato."
fi
fi
# --- 2. service account di scouthub-home-be: ruoli realm-management ---------------------------
SA_UUID="$(auth_get "/admin/realms/$REALM/clients?clientId=$SA_CLIENT_ID" | jq -r '.[0].id')"
if [ -z "$SA_UUID" ] || [ "$SA_UUID" = "null" ]; then
echo "[keycloak-init] client '$SA_CLIENT_ID' non trovato, salto." >&2
else
SA_USER_ID="$(auth_get "/admin/realms/$REALM/clients/$SA_UUID/service-account-user" | jq -r '.id')"
RM_UUID="$(auth_get "/admin/realms/$REALM/clients?clientId=realm-management" | jq -r '.[0].id')"
GIA_ASSEGNATI="$(auth_get "/admin/realms/$REALM/users/$SA_USER_ID/role-mappings/clients/$RM_UUID" | jq -r '.[].name')"
DISPONIBILI="$(auth_get "/admin/realms/$REALM/clients/$RM_UUID/roles")"
DA_ASSEGNARE="[]"
for ROLE in $REQUIRED_REALM_MANAGEMENT_ROLES; do
if echo "$GIA_ASSEGNATI" | grep -qx "$ROLE"; then
continue
fi
ROLE_REPR="$(echo "$DISPONIBILI" | jq -c --arg name "$ROLE" '.[] | select(.name==$name)')"
if [ -z "$ROLE_REPR" ]; then
echo "[keycloak-init] ruolo '$ROLE' non esiste su questa versione di Keycloak, salto." >&2
continue
fi
DA_ASSEGNARE="$(echo "$DA_ASSEGNARE" | jq -c --argjson r "$ROLE_REPR" '. + [$r]')"
done
if [ "$(echo "$DA_ASSEGNARE" | jq 'length')" = "0" ]; then
echo "[keycloak-init] service account '$SA_CLIENT_ID': ruoli realm-management già a posto."
else
curl -sf -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
"$KEYCLOAK_URL/admin/realms/$REALM/users/$SA_USER_ID/role-mappings/clients/$RM_UUID" \
-d "$DA_ASSEGNARE" >/dev/null
echo "[keycloak-init] service account '$SA_CLIENT_ID': assegnati $(echo "$DA_ASSEGNARE" | jq -r '[.[].name] | join(", ")')."
fi
fi
echo "[keycloak-init] completato."
+81 -8
View File
@@ -6,16 +6,48 @@
"registrationAllowed": true, "registrationAllowed": true,
"registrationEmailAsUsername": true, "registrationEmailAsUsername": true,
"loginWithEmailAllowed": true, "loginWithEmailAllowed": true,
"editUsernameAllowed": true,
"resetPasswordAllowed": true, "resetPasswordAllowed": true,
"verifyEmail": false, "verifyEmail": false,
"displayName": "Scouthub",
"displayNameHtml": "Scouthub",
"loginTheme": "scouthub",
"accessTokenLifespan": 3600,
"ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 36000,
"offlineSessionIdleTimeout": 28800,
"offlineSessionMaxLifespan": 36000,
"roles": { "roles": {
"realm": [ "realm": [
{ "name": "admin-centrale", "description": "Placeholder temporaneo: crea nuovi gruppi scout (Organization). Vedi TODO in src/routes/gruppi.routes.ts" }, {
{ "name": "capo-gruppo", "description": "Gestisce il proprio gruppo scout (Organization): membri, inviti, ruoli" }, "name": "admin",
{ "name": "capo-unita", "description": "Gestisce attivita' e materiali della propria branca" }, "description": "Puo' fare tutto su tutte le organizzazioni. Ex admin-centrale. Unico ruolo abilitato a POST/GET /gruppi diretti; gli utenti normali passano dal flusso self-service di richiesta creazione gruppo",
"composite": true,
"composites": { "realm": ["capo-gruppo", "moderatore"] }
},
{
"name": "capo-gruppo",
"description": "Gestisce il proprio gruppo scout (Organization): membri, inviti, ruoli",
"composite": true,
"composites": { "realm": ["capo-unita"] }
},
{
"name": "capo-unita",
"description": "Gestisce attivita' e materiali della propria unita' (branca)",
"composite": true,
"composites": { "realm": ["capo"] }
},
{
"name": "capo",
"description": "Vede e modifica parzialmente le cose della propria unita' (branca). Permessi identici a capo-unita per ora, da differenziare in futuro",
"composite": true,
"composites": { "realm": ["censito"] }
},
{ "name": "censito", "description": "Accesso in sola consultazione" }, { "name": "censito", "description": "Accesso in sola consultazione" },
{ "name": "manage-organizations", "description": "Placeholder locale: il ruolo reale e' il client role manage-organizations su realm-management" } { "name": "manage-organizations", "description": "Placeholder locale: il ruolo reale e' il client role manage-organizations su realm-management" },
{ "name": "moderatore", "description": "Puo' approvare/rifiutare proposte da moderare nei vari servizi (es. catalogo materiali del magazzino, in futuro validazione attivita' prima della pubblicazione). Ex admin-catalogo" }
] ]
}, },
@@ -44,13 +76,22 @@
"standardFlowEnabled": true, "standardFlowEnabled": true,
"directAccessGrantsEnabled": false, "directAccessGrantsEnabled": false,
"redirectUris": [ "redirectUris": [
"http://localhost:4200/*", "http://localhost:7000/*",
"http://localhost:4201/*" "http://localhost:7001/*",
"http://localhost:7002/*",
"http://localhost:7002/silent-check-sso.html",
"http://localhost:7003/*",
"http://localhost:7003/silent-check-sso.html"
], ],
"webOrigins": [ "webOrigins": [
"http://localhost:4200", "http://localhost:7000",
"http://localhost:4201" "http://localhost:7001",
"http://localhost:7002",
"http://localhost:7003"
], ],
"attributes": {
"post.logout.redirect.uris": "http://localhost:7000/*##http://localhost:7001/*##http://localhost:7002/*##http://localhost:7003/*"
},
"defaultClientScopes": [ "defaultClientScopes": [
"openid", "openid",
"basic", "basic",
@@ -59,6 +100,38 @@
"email", "email",
"organization" "organization"
] ]
},
{
"clientId": "scouthub-magazzino-be",
"name": "Servizio di gestione catalogo magazzino (magazzino-be)",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "CAMBIA-QUESTO-SECRET-IN-UN-VAULT",
"redirectUris": [],
"attributes": {
"note": "Usato solo server-to-server dal backend scouthub-magazzino-be verso le Admin REST API. Non esporre mai il secret al frontend."
}
},
{
"clientId": "scouthub-eventi-be",
"name": "Servizio di gestione eventi (eventi-be)",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "CAMBIA-QUESTO-SECRET-IN-UN-VAULT",
"redirectUris": [],
"attributes": {
"note": "Usato solo server-to-server dal backend scouthub-eventi-be verso le Admin REST API. Il secret va condiviso anche con attivita-be e magazzino-be, che lo usano come client credentials per chiamare POST /eventi/:id/risorse su scouthub-eventi-be. Non esporre mai il secret al frontend."
}
} }
] ]
} }
@@ -0,0 +1,146 @@
/*
* Palette e font presi da scouthub-home-fe/src/material-theme.scss
* (design system "Scouthub - Home"), per far combaciare login/registrazione
* Keycloak con la grafica del frontend home. scouthub-home-fe forza
* `color-scheme: light` (nessuna dark mode), quindi qui i colori sono
* valori fissi e non seguono il toggle dark del tema keycloak.v2.
*/
@import url('https://fonts.googleapis.com/css2?family=Caprasimo&family=Figtree:wght@400;500;600;700&display=swap');
:root {
--scouthub-primary: #c67139;
--scouthub-on-primary: #f5ead8;
--scouthub-primary-container: #fff2eb;
--scouthub-on-primary-container: #643312;
--scouthub-secondary: #7a8a5e;
--scouthub-error: #8c2f22;
--scouthub-background: #f5ead8;
--scouthub-on-background: #201e1d;
--scouthub-surface-container: #ebddc5;
--scouthub-surface-container-lowest: #ffffff;
--scouthub-outline: #a89a83;
--scouthub-outline-variant: #d8c9ab;
color-scheme: light;
/* Nessuna striscia colorata in cima alla card: home-fe non usa questo
pattern (le sue card, es. profilo-card/crea-gruppo__card, sono piatte). */
--keycloak-card-top-color: transparent;
}
html,
body {
font-family: 'Figtree', sans-serif;
}
.login-pf body,
#keycloak-bg {
background: var(--scouthub-background);
color: var(--scouthub-on-background);
}
.pf-v5-c-login__main-header {
border-top: none;
}
.pf-v5-c-login__main {
/* Stesso tono delle card di home-fe (profilo-card, crea-gruppo__card),
non bianco: qui il bianco sarebbe l'unico elemento "freddo" della pagina. */
background: var(--scouthub-surface-container);
border-radius: 32px;
box-shadow: 0 12px 32px rgba(46, 43, 37, 0.16);
padding: 8px;
}
#kc-header-wrapper {
font-family: 'Caprasimo', sans-serif;
text-transform: none;
letter-spacing: normal;
font-size: 28px;
color: var(--scouthub-on-background) !important;
}
.pf-v5-c-title.pf-m-3xl,
#kc-page-title {
font-family: 'Caprasimo', sans-serif;
color: var(--scouthub-on-background);
}
a,
a:visited {
color: var(--scouthub-primary);
}
.pf-v5-c-form__label-text {
font-family: 'Figtree', sans-serif;
font-weight: 500;
font-size: 13px;
color: var(--scouthub-on-background);
opacity: 0.75;
}
.pf-v5-c-form-control {
background-color: var(--scouthub-surface-container-lowest);
color: var(--scouthub-on-background);
border-radius: 12px;
border-color: var(--scouthub-outline-variant);
}
/* La classe kcInputClass è sullo <span> che avvolge l'<input>, non
sull'input stesso: :focus non scatterebbe mai, serve :focus-within.
L'input interno non ha classi proprie, quindi va spento il suo outline
nativo separatamente. */
.pf-v5-c-form-control:focus-within {
border-color: var(--scouthub-primary);
box-shadow: 0 0 0 1px var(--scouthub-primary);
}
.pf-v5-c-form-control input:focus,
.pf-v5-c-form-control textarea:focus,
.pf-v5-c-form-control select:focus {
outline: none;
}
/* PatternFly disegna l'indicatore di focus come bordo inferiore blu tramite
uno pseudo-elemento ::after sullo <span> wrapper (non tramite :focus né
outline): va sovrascritto qui, altrimenti resta il blu di default. */
.pf-v5-c-form-control::after {
border-bottom-color: var(--scouthub-primary);
}
.pf-v5-c-button.pf-m-primary {
background-color: var(--scouthub-primary) !important;
color: var(--scouthub-on-primary) !important;
border-radius: 999px !important;
border: none !important;
}
.pf-v5-c-button.pf-m-primary:hover,
.pf-v5-c-button.pf-m-primary:focus {
background-color: var(--scouthub-on-primary-container) !important;
}
.pf-v5-c-button.pf-m-secondary {
color: var(--scouthub-secondary) !important;
border-color: var(--scouthub-secondary) !important;
border-radius: 999px !important;
}
.pf-v5-c-button.pf-m-link {
color: var(--scouthub-primary) !important;
}
.pf-v5-c-button.pf-m-control {
border-radius: 12px;
border-color: var(--scouthub-outline-variant);
color: var(--scouthub-on-background);
}
.pf-v5-c-check__input:checked ~ .pf-v5-c-check__label::before {
background-color: var(--scouthub-primary);
}
.pf-v5-c-alert.pf-m-danger {
--pf-v5-c-alert--BackgroundColor: var(--scouthub-primary-container);
--pf-v5-c-alert--m-danger--BorderTopColor: var(--scouthub-error);
color: var(--scouthub-on-primary-container);
}
@@ -0,0 +1,19 @@
# Tema di login/registrazione allineato alla grafica di scouthub-home-fe
# (stessi colori/font del design system "Scouthub - Home", vedi
# scouthub-home-fe/src/material-theme.scss). Eredita struttura e
# comportamento dal tema built-in "keycloak.v2", aggiungendo solo un foglio
# di stile che sovrascrive i colori PatternFly.
parent=keycloak.v2
import=common/keycloak
# Non chiamare il nostro foglio di stile "css/styles.css": è lo stesso path
# relativo usato dal tema padre keycloak.v2 (che contiene l'override del
# layout a colonna singola dell'header). Con lo stesso nome Keycloak serve
# solo il file del tema figlio, perdendo quello del padre: elenchiamo
# entrambi, il nostro per ultimo così vince in cascata sui colori.
styles=css/styles.css css/scouthub.css
# scouthub-home-fe non ha una dark mode (color-scheme fissato a light in
# material-theme.scss): disattiviamo anche qui il toggle automatico di
# keycloak.v2, altrimenti col sistema in dark mode gli input diventano neri.
darkMode=false
+3 -3
View File
@@ -1,11 +1,11 @@
PORT=8080 PORT=8001
CORS_ORIGIN=http://localhost:4200 CORS_ORIGIN=http://localhost:7001
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/scouthub?schema=public DATABASE_URL=postgresql://postgres:postgres@localhost:5432/scouthub?schema=public
# Keycloak (realm condiviso con scouthub-home-be/-fe, vedi keycloak/realm-export.json # Keycloak (realm condiviso con scouthub-home-be/-fe, vedi keycloak/realm-export.json
# alla radice del monorepo): usato solo per verificare i token, nessun client # alla radice del monorepo): usato solo per verificare i token, nessun client
# id/secret necessario qui. # id/secret necessario qui.
KEYCLOAK_BASE_URL=http://localhost:8081 KEYCLOAK_BASE_URL=http://localhost:6999
KEYCLOAK_REALM=scouthub KEYCLOAK_REALM=scouthub
# Database dedicato ai test automatici (Jest): tenuto separato dal DB di sviluppo # Database dedicato ai test automatici (Jest): tenuto separato dal DB di sviluppo
-240
View File
@@ -1,240 +0,0 @@
-- **************************************************** stato_attivita
create table stato_attivita
(
id varchar(2) not null primary key,
nome varchar(250) not null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null
);
-- **************************************************** tipo_categoria
create table tipo_categoria
(
id varchar(2) not null primary key,
nome varchar(250) not null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null
);
-- **************************************************** tipo_paragrafo
create table tipo_paragrafo
(
id varchar(10) not null primary key,
nome varchar(250) not null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null
);
-- **************************************************** attivita
create table attivita
(
id int not null primary key auto_increment,
nome varchar(250) not null,
autore varchar(250) not null,
padre int null,
stato varchar(2) not null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null,
constraint fk_attivita_attivita foreign key (padre) references attivita (id),
constraint fk_attivita_stato_attivita foreign key (stato) references stato_attivita (id)
);
create index fk_attivita_attivita_idx on attivita (padre);
create index fk_attivita_stato_attivita_idx on attivita (stato);
-- **************************************************** branca
create table branca
(
id int not null primary key auto_increment,
nome varchar(250) not null,
inizio_eta int null,
fine_eta int null,
colore varchar(10) null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null
);
-- **************************************************** branca_attivita
create table branca_attivita
(
attivita_id int not null,
branca_id int not null,
cancellato tinyint not null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null,
primary key (attivita_id, branca_id),
constraint fk_branca_attivita_attivita foreign key (attivita_id) references attivita (id),
constraint fk_branca_attivita_branca foreign key (branca_id) references branca (id)
);
create index fk_branca_attivita_attivita_idx on branca_attivita (attivita_id);
create index fk_branca_attivita_branca_idx on branca_attivita (branca_id);
-- **************************************************** periodo_anno
create table periodo_anno
(
id int not null primary key auto_increment,
nome varchar(250) not null,
inizio_mese int null,
fine_mese int null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null
);
-- **************************************************** periodo_anno_attivita
create table periodo_anno_attivita
(
attivita_id int not null,
periodo_anno_id int not null,
cancellato tinyint not null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null,
primary key (attivita_id, periodo_anno_id),
constraint fk_periodo_anno_attivita_attivita foreign key (attivita_id) references attivita (id),
constraint fk_periodo_anno_attivita_periodo_anno foreign key (periodo_anno_id) references periodo_anno (id)
);
create index fk_periodo_anno_attivita_attivita_idx on periodo_anno_attivita (attivita_id);
create index fkperiodo_anno_attivita_periodo_anno_idx on periodo_anno_attivita (periodo_anno_id);
-- **************************************************** paragrafo
create table paragrafo
(
id int not null primary key auto_increment,
attivita_id int not null,
corpo varchar(500) not null,
autore varchar(250) not null,
tipo varchar(10) not null,
ordine int not null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null,
constraint fk_paragrafo_tipo_paragrafo foreign key (tipo) references tipo_paragrafo (id),
constraint fk_paragrafo_attivita foreign key (attivita_id) references attivita (id)
);
create index fk_paragrafo_tipo_paragrafo_idx on paragrafo (tipo);
-- **************************************************** categoria
create table categoria
(
id int not null primary key auto_increment,
nome varchar(250) not null,
padre int null,
tipo varchar(2) not null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null,
constraint fk_categoria_categoria foreign key (padre) references categoria (id),
constraint fk_categoria_tipo_categoria foreign key (tipo) references tipo_categoria (id)
);
create index fk_categoria_categoria_idx on categoria (padre);
create index fk_categoria_tipo_categoria_idx on categoria (tipo);
-- **************************************************** categoria_attivita
create table categoria_attivita
(
attivita_id int not null,
categoria_id int not null,
cancellato tinyint not null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null,
primary key (attivita_id, categoria_id),
constraint fk_categoria_attivita_attivita foreign key (attivita_id) references attivita (id),
constraint fk_categoria_attivita_categoria foreign key (categoria_id) references categoria (id)
);
create index fk_categoria_attivita_attivita_idx on categoria_attivita (attivita_id);
create index fk_categoria_attivita_categoria_idx on categoria_attivita (categoria_id);
-- **************************************************** materiale
create table materiale
(
id int not null primary key auto_increment,
nome varchar(250) not null,
proprieta json,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null
);
-- **************************************************** materiale_attivita
create table materiale_attivita
(
attivita_id int not null,
materiale_id int not null,
proprieta json null,
cancellato tinyint not null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null,
primary key (attivita_id, materiale_id),
constraint fk_materiale_attivita_attivita foreign key (attivita_id) references attivita (id),
constraint fk_materiale_attivita_materiale foreign key (materiale_id) references materiale (id)
);
create index fk_materiale_attivita_attivita_idx on materiale_attivita (attivita_id);
create index fk_materiale_attivita_materiale_idx on materiale_attivita (materiale_id);
-- **************************************************** categoria_materiale
create table categoria_materiale
(
materiale_id int not null,
categoria_id int not null,
cancellato tinyint not null,
data_creazione datetime not null,
data_modifica datetime not null,
utente_modifica varchar(50) not null,
primary key (materiale_id, categoria_id),
constraint fk_categoria_materiale_categoria foreign key (categoria_id) references categoria (id),
constraint fk_categoria_materiale_materiale foreign key (materiale_id) references materiale (id)
);
create index fk_categoria_materiale_categoria_idx on categoria_materiale (categoria_id);
create index fk_categoria_materiale_materiale_idx on categoria_materiale (materiale_id);
@@ -1,3 +0,0 @@
INSERT INTO scouthub.stato_attivita (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('PU', 'Pubblicato', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.stato_attivita (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('BO', 'Bozza', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.stato_attivita (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('PR', 'Privato', sysdate(), sysdate(), 'MANUALE');
@@ -1,2 +0,0 @@
INSERT INTO scouthub.tipo_categoria (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('A', 'Attività', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.tipo_categoria (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('M', 'Materiale', sysdate(), sysdate(), 'MANUALE');
@@ -1,2 +0,0 @@
INSERT INTO scouthub.tipo_paragrafo (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('TITOLO', 'Titolo attività', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.tipo_paragrafo (id, nome, data_creazione, data_modifica, utente_modifica) VALUES ('PARAGRAFO', 'Paragrafo attività', sysdate(), sysdate(), 'MANUALE');
@@ -1,4 +0,0 @@
INSERT INTO scouthub.branca (id, nome, inizio_eta, fine_eta, colore, data_creazione, data_modifica, utente_modifica) VALUES (1, 'L/C', 8, 10, '#FDD835', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.branca (id, nome, inizio_eta, fine_eta, colore, data_creazione, data_modifica, utente_modifica) VALUES (2, 'E/G', 11, 16, '#43A047', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.branca (id, nome, inizio_eta, fine_eta, colore, data_creazione, data_modifica, utente_modifica) VALUES (3, 'R/S', 17, 21, '#E53935', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.branca (id, nome, inizio_eta, fine_eta, colore, data_creazione, data_modifica, utente_modifica) VALUES (4, 'Co.Ca.', 22, 99, '#8E24AA', sysdate(), sysdate(), 'MANUALE');
@@ -1,3 +0,0 @@
INSERT INTO scouthub.periodo_anno (id, nome, inizio_mese, fine_mese, data_creazione, data_modifica, utente_modifica) VALUES (1, 'promessa', 1, 3, sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.periodo_anno (id, nome, inizio_mese, fine_mese, data_creazione, data_modifica, utente_modifica) VALUES (2, 'campo estivo', 6, 9, sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.periodo_anno (id, nome, inizio_mese, fine_mese, data_creazione, data_modifica, utente_modifica) VALUES (3, 'campo invernale', 12, 1, sysdate(), sysdate(), 'MANUALE');
@@ -1,9 +0,0 @@
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (1, 'attività', null, 'A', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (2, 'gioco', null, 'A', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (3, 'danza', null, 'A', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (4, 'gioco d''acqua', 2, 'A', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (5, 'gioco notturno', 2, 'A', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (6, 'grande gioco', 2, 'A', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (7, 'torneo', 2, 'A', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (8, 'olimpiadi', 2, 'A', sysdate(), sysdate(), 'MANUALE');
INSERT INTO scouthub.categoria (id, nome, padre, tipo, data_creazione, data_modifica, utente_modifica) VALUES (9, 'gioco giungla', 2, 'A', sysdate(), sysdate(), 'MANUALE');
Binary file not shown.
@@ -1,7 +0,0 @@
-- AlterTable
-- Le righe esistenti (dati di sviluppo/seed, precedenti all'introduzione
-- dell'autenticazione Keycloak) non hanno un autore reale: valorizzate con
-- stringa vuota, poi il default viene rimosso perche' da qui in avanti
-- autoreId e' sempre valorizzato dal token (sub) al momento della creazione.
ALTER TABLE "attivita" ADD COLUMN "autore_id" TEXT NOT NULL DEFAULT '';
ALTER TABLE "attivita" ALTER COLUMN "autore_id" DROP DEFAULT;
@@ -1,3 +1,9 @@
-- CreateEnum
CREATE TYPE "StatoTassonomia" AS ENUM ('CONFERMATA', 'DA_APPROVARE');
-- CreateEnum
CREATE TYPE "TipoNotifica" AS ENUM ('TASSONOMIA_PROPOSTA', 'ATTIVITA_IN_ATTESA', 'ATTIVITA_PUBBLICATA', 'ATTIVITA_BOZZA_NOTA');
-- CreateTable -- CreateTable
CREATE TABLE "stato_attivita" ( CREATE TABLE "stato_attivita" (
"id" VARCHAR(2) NOT NULL, "id" VARCHAR(2) NOT NULL,
@@ -36,6 +42,7 @@ CREATE TABLE "attivita" (
"id" SERIAL NOT NULL, "id" SERIAL NOT NULL,
"nome" TEXT NOT NULL, "nome" TEXT NOT NULL,
"autore" TEXT NOT NULL, "autore" TEXT NOT NULL,
"autore_id" TEXT NOT NULL,
"padre" INTEGER, "padre" INTEGER,
"stato" TEXT NOT NULL, "stato" TEXT NOT NULL,
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -55,6 +62,8 @@ CREATE TABLE "branca" (
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"data_modifica" TIMESTAMP(3) NOT NULL, "data_modifica" TIMESTAMP(3) NOT NULL,
"utente_modifica" TEXT NOT NULL, "utente_modifica" TEXT NOT NULL,
"creato_da_id" TEXT,
"stato" "StatoTassonomia" NOT NULL DEFAULT 'CONFERMATA',
CONSTRAINT "branca_pkey" PRIMARY KEY ("id") CONSTRAINT "branca_pkey" PRIMARY KEY ("id")
); );
@@ -80,6 +89,8 @@ CREATE TABLE "periodo_anno" (
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"data_modifica" TIMESTAMP(3) NOT NULL, "data_modifica" TIMESTAMP(3) NOT NULL,
"utente_modifica" TEXT NOT NULL, "utente_modifica" TEXT NOT NULL,
"creato_da_id" TEXT,
"stato" "StatoTassonomia" NOT NULL DEFAULT 'CONFERMATA',
CONSTRAINT "periodo_anno_pkey" PRIMARY KEY ("id") CONSTRAINT "periodo_anno_pkey" PRIMARY KEY ("id")
); );
@@ -120,6 +131,8 @@ CREATE TABLE "categoria" (
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"data_modifica" TIMESTAMP(3) NOT NULL, "data_modifica" TIMESTAMP(3) NOT NULL,
"utente_modifica" TEXT NOT NULL, "utente_modifica" TEXT NOT NULL,
"creato_da_id" TEXT,
"stato" "StatoTassonomia" NOT NULL DEFAULT 'CONFERMATA',
CONSTRAINT "categoria_pkey" PRIMARY KEY ("id") CONSTRAINT "categoria_pkey" PRIMARY KEY ("id")
); );
@@ -173,6 +186,34 @@ CREATE TABLE "categoria_materiale" (
CONSTRAINT "categoria_materiale_pkey" PRIMARY KEY ("materiale_id","categoria_id") CONSTRAINT "categoria_materiale_pkey" PRIMARY KEY ("materiale_id","categoria_id")
); );
-- CreateTable
CREATE TABLE "notifica" (
"id" SERIAL NOT NULL,
"tipo" "TipoNotifica" NOT NULL,
"messaggio" TEXT NOT NULL,
"link" TEXT,
"destinatario_id" TEXT,
"letta" BOOLEAN NOT NULL DEFAULT false,
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notifica_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "notifica_destinatario_id_idx" ON "notifica"("destinatario_id");
-- CreateTable
CREATE TABLE "nota_attivita" (
"id" SERIAL NOT NULL,
"attivita_id" INTEGER NOT NULL,
"testo" VARCHAR(1000) NOT NULL,
"autore" TEXT NOT NULL,
"autore_id" TEXT NOT NULL,
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "nota_attivita_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey -- AddForeignKey
ALTER TABLE "attivita" ADD CONSTRAINT "attivita_padre_fkey" FOREIGN KEY ("padre") REFERENCES "attivita"("id") ON DELETE SET NULL ON UPDATE CASCADE; ALTER TABLE "attivita" ADD CONSTRAINT "attivita_padre_fkey" FOREIGN KEY ("padre") REFERENCES "attivita"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -221,3 +262,6 @@ ALTER TABLE "categoria_materiale" ADD CONSTRAINT "categoria_materiale_materiale_
-- AddForeignKey -- AddForeignKey
ALTER TABLE "categoria_materiale" ADD CONSTRAINT "categoria_materiale_categoria_id_fkey" FOREIGN KEY ("categoria_id") REFERENCES "categoria"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ALTER TABLE "categoria_materiale" ADD CONSTRAINT "categoria_materiale_categoria_id_fkey" FOREIGN KEY ("categoria_id") REFERENCES "categoria"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "nota_attivita" ADD CONSTRAINT "nota_attivita_attivita_id_fkey" FOREIGN KEY ("attivita_id") REFERENCES "attivita"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+47
View File
@@ -7,6 +7,33 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
enum StatoTassonomia {
CONFERMATA
DA_APPROVARE
}
enum TipoNotifica {
TASSONOMIA_PROPOSTA
ATTIVITA_IN_ATTESA
ATTIVITA_PUBBLICATA
ATTIVITA_BOZZA_NOTA
}
// destinatarioId null = notifica "broadcast" visibile a chiunque abbia ruolo admin/moderatore
// (usata per segnalare nuove proposte/attività da revisionare); altrimenti è personale.
model Notifica {
id Int @id @default(autoincrement())
tipo TipoNotifica
messaggio String
link String?
destinatarioId String? @map("destinatario_id")
letta Boolean @default(false)
dataCreazione DateTime @default(now()) @map("data_creazione")
@@index([destinatarioId])
@@map("notifica")
}
model StatoAttivita { model StatoAttivita {
id String @id @db.VarChar(2) id String @id @db.VarChar(2)
nome String nome String
@@ -63,16 +90,32 @@ model Attivita {
categoriaLinks CategoriaAttivita[] categoriaLinks CategoriaAttivita[]
materialeLinks MaterialeAttivita[] materialeLinks MaterialeAttivita[]
periodoAnnoLinks PeriodoAnnoAttivita[] periodoAnnoLinks PeriodoAnnoAttivita[]
noteList NotaAttivita[]
@@map("attivita") @@map("attivita")
} }
model NotaAttivita {
id Int @id @default(autoincrement())
attivitaId Int @map("attivita_id")
testo String @db.VarChar(1000)
autore String
autoreId String @map("autore_id")
dataCreazione DateTime @default(now()) @map("data_creazione")
attivita Attivita @relation(fields: [attivitaId], references: [id])
@@map("nota_attivita")
}
model Branca { model Branca {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
nome String nome String
inizioEta Int? @map("inizio_eta") inizioEta Int? @map("inizio_eta")
fineEta Int? @map("fine_eta") fineEta Int? @map("fine_eta")
colore String? @db.VarChar(10) colore String? @db.VarChar(10)
stato StatoTassonomia @default(CONFERMATA)
creatoDaId String? @map("creato_da_id")
dataCreazione DateTime @default(now()) @map("data_creazione") dataCreazione DateTime @default(now()) @map("data_creazione")
dataModifica DateTime @updatedAt @map("data_modifica") dataModifica DateTime @updatedAt @map("data_modifica")
utenteModifica String @map("utente_modifica") utenteModifica String @map("utente_modifica")
@@ -102,6 +145,8 @@ model PeriodoAnno {
nome String nome String
inizioMese Int? @map("inizio_mese") inizioMese Int? @map("inizio_mese")
fineMese Int? @map("fine_mese") fineMese Int? @map("fine_mese")
stato StatoTassonomia @default(CONFERMATA)
creatoDaId String? @map("creato_da_id")
dataCreazione DateTime @default(now()) @map("data_creazione") dataCreazione DateTime @default(now()) @map("data_creazione")
dataModifica DateTime @updatedAt @map("data_modifica") dataModifica DateTime @updatedAt @map("data_modifica")
utenteModifica String @map("utente_modifica") utenteModifica String @map("utente_modifica")
@@ -148,6 +193,8 @@ model Categoria {
nome String nome String
padreId Int? @map("padre") padreId Int? @map("padre")
tipoId String @map("tipo") tipoId String @map("tipo")
stato StatoTassonomia @default(CONFERMATA)
creatoDaId String? @map("creato_da_id")
dataCreazione DateTime @default(now()) @map("data_creazione") dataCreazione DateTime @default(now()) @map("data_creazione")
dataModifica DateTime @updatedAt @map("data_modifica") dataModifica DateTime @updatedAt @map("data_modifica")
utenteModifica String @map("utente_modifica") utenteModifica String @map("utente_modifica")
+14
View File
@@ -10,6 +10,7 @@ async function main() {
{ id: "PU", nome: "Pubblicato" }, { id: "PU", nome: "Pubblicato" },
{ id: "BO", nome: "Bozza" }, { id: "BO", nome: "Bozza" },
{ id: "PR", nome: "Privato" }, { id: "PR", nome: "Privato" },
{ id: "IA", nome: "In attesa di approvazione" },
].map((stato) => ].map((stato) =>
prisma.statoAttivita.upsert({ prisma.statoAttivita.upsert({
where: { id: stato.id }, where: { id: stato.id },
@@ -102,6 +103,19 @@ async function main() {
create: { ...categoria, utenteModifica: UTENTE_MODIFICA }, create: { ...categoria, utenteModifica: UTENTE_MODIFICA },
}); });
} }
// Branca/Categoria/PeriodoAnno sopra sono inserite con id espliciti: l'upsert non passa
// mai dal DEFAULT della colonna, quindi la sequence Postgres dell'id non avanza e resta
// disallineata rispetto al MAX(id) reale. Se non la si risincronizza qui, la prima riga
// creata in seguito con id auto-generato (es. una tassonomia proposta da un utente) va in
// conflitto su un id già esistente.
await Promise.all(
["branca", "categoria", "periodo_anno", "materiale"].map((tabella) =>
prisma.$executeRawUnsafe(
`SELECT setval(pg_get_serial_sequence('"${tabella}"', 'id'), COALESCE((SELECT MAX(id) FROM "${tabella}"), 1))`
)
)
);
} }
main() main()
+6 -1
View File
@@ -3,9 +3,12 @@ import cors from 'cors';
import { env } from './config/env'; import { env } from './config/env';
import { authenticate } from './middlewares/authenticate'; import { authenticate } from './middlewares/authenticate';
import { errorHandler } from './middlewares/errorHandler'; import { errorHandler } from './middlewares/errorHandler';
import { optionalAuthenticate } from './middlewares/optionalAuthenticate';
import { attivitaPublicRouter } from './modules/attivita/attivita.router.public'; import { attivitaPublicRouter } from './modules/attivita/attivita.router.public';
import { attivitaPrivateRouter } from './modules/attivita/attivita.router.private'; import { attivitaPrivateRouter } from './modules/attivita/attivita.router.private';
import { autocompleteRouter } from './modules/autocomplete/autocomplete.router'; import { autocompleteRouter } from './modules/autocomplete/autocomplete.router';
import { notificheRouter } from './modules/notifiche/notifiche.router';
import { tassonomieRouter } from './modules/tassonomie/tassonomie.router';
export const app = express(); export const app = express();
@@ -19,9 +22,11 @@ app.get('/health', (req, res) => {
res.json({ status: 'ok' }); res.json({ status: 'ok' });
}); });
app.use('/public/attivita', attivitaPublicRouter); app.use('/public/attivita', optionalAuthenticate, attivitaPublicRouter);
app.use('/private/attivita', authenticate, attivitaPrivateRouter); app.use('/private/attivita', authenticate, attivitaPrivateRouter);
app.use('/public/autocomplete', autocompleteRouter); app.use('/public/autocomplete', autocompleteRouter);
app.use('/private/tassonomie', authenticate, tassonomieRouter);
app.use('/private/notifiche', authenticate, notificheRouter);
app.use((req, res) => { app.use((req, res) => {
res.status(404).json({ message: 'not found' }); res.status(404).json({ message: 'not found' });
@@ -0,0 +1,71 @@
import { Request, Response, NextFunction } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
import { env } from '../config/env';
import { AuthContext } from './auth.types';
const client = jwksClient({
jwksUri: `${env.keycloak.baseUrl}/realms/${env.keycloak.realm}/protocol/openid-connect/certs`,
cache: true,
rateLimit: true,
});
interface KeycloakTokenPayload extends JwtPayload {
sub: string;
email?: string;
name?: string;
preferred_username?: string;
realm_access?: { roles?: string[] };
}
function extractBearerToken(req: Request): string | null {
const header = req.headers.authorization;
if (!header) {
return null;
}
const [scheme, token] = header.split(' ');
if (scheme !== 'Bearer' || !token) {
return null;
}
return token;
}
function buildAuthContext(payload: KeycloakTokenPayload): AuthContext {
return {
userId: payload.sub,
email: payload.email ?? null,
name: payload.name ?? payload.preferred_username ?? payload.email ?? payload.sub,
roles: payload.realm_access?.roles ?? [],
};
}
// Come authenticate.ts, ma se il token manca o non è valido lascia proseguire la richiesta
// senza req.auth invece di rispondere 401: serve sulle route pubbliche che devono comunque
// sapere chi è l'utente quando è loggato (es. per mostrargli le proprie tassonomie in attesa
// di approvazione), senza per questo richiedere l'autenticazione a chi non è loggato.
export async function optionalAuthenticate(req: Request, res: Response, next: NextFunction): Promise<void> {
const token = extractBearerToken(req);
if (!token) {
next();
return;
}
try {
const decoded = jwt.decode(token, { complete: true });
if (!decoded || !decoded.header.kid) {
throw new Error("Header del token privo di 'kid'");
}
const signingKey = await client.getSigningKey(decoded.header.kid);
const payload = jwt.verify(token, signingKey.getPublicKey(), { algorithms: ['RS256'] });
if (typeof payload === 'string') {
throw new Error('Payload del token non valido');
}
req.auth = buildAuthContext(payload as KeycloakTokenPayload);
} catch {
// token presente ma non valido: procediamo comunque come utente anonimo
}
next();
}
@@ -0,0 +1,14 @@
import { Request, Response, NextFunction } from 'express';
export function requireRole(...ruoliAmmessi: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
const roles = req.auth?.roles ?? [];
const autorizzato = ruoliAmmessi.some((ruolo) => roles.includes(ruolo));
if (!autorizzato) {
res.status(403).json({ message: 'Ruolo non autorizzato' });
return;
}
next();
};
}
@@ -1,8 +1,11 @@
import { Request, Response, NextFunction, Router } from 'express'; import { Request, Response, NextFunction, Router } from 'express';
import { HttpError } from '../../errors'; import { HttpError } from '../../errors';
import { attivitaSaveSchema } from '../../types/validation'; import { requireRole } from '../../middlewares/requireRole';
import { attivitaSaveSchema, notaAttivitaSchema } from '../../types/validation';
import * as attivitaService from './attivita.service'; import * as attivitaService from './attivita.service';
const moderazione = requireRole('admin', 'moderatore');
function asyncHandler( function asyncHandler(
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>, handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
) { ) {
@@ -11,12 +14,21 @@ function asyncHandler(
}; };
} }
function parseIntParam(value: string, next: NextFunction, message: string): number | undefined {
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
next(new HttpError(400, message));
return undefined;
}
return parsed;
}
export const attivitaPrivateRouter = Router(); export const attivitaPrivateRouter = Router();
attivitaPrivateRouter.get( attivitaPrivateRouter.get(
'/get/lista/my', '/get/lista/my',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const lista = await attivitaService.getListMy(req.auth!.userId); const lista = await attivitaService.getListMy(req.auth!.userId, req.auth);
res.status(200).json(lista); res.status(200).json(lista);
}), }),
); );
@@ -49,3 +61,54 @@ attivitaPrivateRouter.post(
res.status(200).end(); res.status(200).end();
}), }),
); );
attivitaPrivateRouter.get(
'/get/lista/moderazione',
moderazione,
asyncHandler(async (req, res) => {
const lista = await attivitaService.getListaModerazione(req.auth!);
res.status(200).json(lista);
}),
);
attivitaPrivateRouter.post(
'/:idAttivita/approva',
moderazione,
asyncHandler(async (req, res, next) => {
const idAttivita = parseIntParam(req.params.idAttivita, next, "l'idAttivita deve essere un numero intero");
if (idAttivita === undefined) return;
await attivitaService.approva(idAttivita, req.auth!);
res.status(200).end();
}),
);
attivitaPrivateRouter.post(
'/:idAttivita/commenta',
moderazione,
asyncHandler(async (req, res, next) => {
const idAttivita = parseIntParam(req.params.idAttivita, next, "l'idAttivita deve essere un numero intero");
if (idAttivita === undefined) return;
const parsed = notaAttivitaSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
await attivitaService.commenta(idAttivita, parsed.data.testo, req.auth!);
res.status(200).end();
}),
);
attivitaPrivateRouter.delete(
'/note/:idNota',
moderazione,
asyncHandler(async (req, res, next) => {
const idNota = parseIntParam(req.params.idNota, next, "l'idNota deve essere un numero intero");
if (idNota === undefined) return;
await attivitaService.eliminaNota(idNota);
res.status(204).end();
}),
);
@@ -24,7 +24,7 @@ export const attivitaPublicRouter = Router();
attivitaPublicRouter.get( attivitaPublicRouter.get(
'/get/lista/home', '/get/lista/home',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const lista = await attivitaService.getListaHome(); const lista = await attivitaService.getListaHome(req.auth);
res.status(200).json(lista); res.status(200).json(lista);
}), }),
); );
@@ -38,7 +38,7 @@ attivitaPublicRouter.post(
return; return;
} }
const lista = await attivitaService.getListaSearch(parsed.data); const lista = await attivitaService.getListaSearch(parsed.data, req.auth);
res.status(200).json(lista); res.status(200).json(lista);
}), }),
); );
@@ -52,7 +52,7 @@ attivitaPublicRouter.get(
return; return;
} }
const attivita = await attivitaService.getOne(id); const attivita = await attivitaService.getOne(id, req.auth);
if (!attivita) { if (!attivita) {
next(new HttpError(404, 'attività non trovata')); next(new HttpError(404, 'attività non trovata'));
return; return;
@@ -2,11 +2,13 @@ import { Prisma, PrismaClient } from '@prisma/client';
import { prisma } from '../../db/prisma'; import { prisma } from '../../db/prisma';
import { HttpError } from '../../errors'; import { HttpError } from '../../errors';
import { AuthContext } from '../../middlewares/auth.types'; import { AuthContext } from '../../middlewares/auth.types';
import * as notificheService from '../notifiche/notifiche.service';
import { import {
AttivitaDto, AttivitaDto,
BrancaDto, BrancaDto,
CategoriaDto, CategoriaDto,
MaterialeDto, MaterialeDto,
NotaDto,
ParagrafoDto, ParagrafoDto,
PeriodoAnnoDto, PeriodoAnnoDto,
SearchObjectDto, SearchObjectDto,
@@ -14,6 +16,10 @@ import {
} from '../../types/dto'; } from '../../types/dto';
import { AttivitaSaveInput } from '../../types/validation'; import { AttivitaSaveInput } from '../../types/validation';
const STATO_IN_ATTESA = 'IA';
const STATO_PUBBLICATO = 'PU';
const STATO_BOZZA = 'BO';
const attivitaInclude = { const attivitaInclude = {
stato: true, stato: true,
brancaLinks: { include: { branca: true } }, brancaLinks: { include: { branca: true } },
@@ -21,6 +27,7 @@ const attivitaInclude = {
materialeLinks: { include: { materiale: true } }, materialeLinks: { include: { materiale: true } },
periodoAnnoLinks: { include: { periodoAnno: true } }, periodoAnnoLinks: { include: { periodoAnno: true } },
paragrafi: { include: { tipo: true } }, paragrafi: { include: { tipo: true } },
noteList: { orderBy: { dataCreazione: 'desc' } },
} satisfies Prisma.AttivitaInclude; } satisfies Prisma.AttivitaInclude;
type AttivitaWithRelations = Prisma.AttivitaGetPayload<{ include: typeof attivitaInclude }>; type AttivitaWithRelations = Prisma.AttivitaGetPayload<{ include: typeof attivitaInclude }>;
@@ -29,15 +36,55 @@ function toTipologicaDto(entity: { id: string; nome: string }): TipologicaDto {
return { id: entity.id, nome: entity.nome }; return { id: entity.id, nome: entity.nome };
} }
function toAttivitaDto(entity: AttivitaWithRelations): AttivitaDto { // Usata sia per decidere se mostrare branca/categoria/periodo ancora in stato DA_APPROVARE,
// sia per le note di moderazione e per la visibilità delle attività non ancora pubblicate:
// in tutti i casi il perimetro di chi può "gestire" l'attività è lo stesso (autore, admin,
// moderatore).
function puoGestire(entity: AttivitaWithRelations, auth?: AuthContext): boolean {
if (!auth) {
return false;
}
return (
auth.userId === entity.autoreId ||
auth.roles.includes('admin') ||
auth.roles.includes('moderatore')
);
}
// Ogni volta che l'autore porta un'attività in stato Pubblicato (da save o da changeStato)
// il valore persistito è in realtà "in attesa di approvazione": diventa Pubblicato per davvero
// solo dopo l'approvazione di admin/moderatore (vedi approva()).
function risolviStatoPersistito(idStato: string): string {
return idStato === STATO_PUBBLICATO ? STATO_IN_ATTESA : idStato;
}
function toAttivitaDto(entity: AttivitaWithRelations, auth?: AuthContext): AttivitaDto {
const mostraProposte = puoGestire(entity, auth);
// Le note servono solo a far sistemare l'attività all'autore mentre è in revisione: una
// volta tornata Pubblicato (approvazione definitiva) non vanno più mostrate, anche se non
// sono ancora state cancellate esplicitamente (vedi eliminaNota).
const mostraNote = mostraProposte && entity.statoId !== STATO_PUBBLICATO;
const noteList: NotaDto[] = mostraNote
? entity.noteList.map((nota) => ({
id: nota.id,
attivitaId: nota.attivitaId,
testo: nota.testo,
autore: nota.autore,
dataCreazione: nota.dataCreazione,
}))
: [];
const brancaList: BrancaDto[] = entity.brancaLinks const brancaList: BrancaDto[] = entity.brancaLinks
.filter((link) => !link.cancellato) .filter((link) => !link.cancellato && (link.branca.stato === 'CONFERMATA' || mostraProposte))
.map((link) => ({ .map((link) => ({
id: link.branca.id, id: link.branca.id,
nome: link.branca.nome, nome: link.branca.nome,
inizioEta: link.branca.inizioEta, inizioEta: link.branca.inizioEta,
fineEta: link.branca.fineEta, fineEta: link.branca.fineEta,
colore: link.branca.colore, colore: link.branca.colore,
stato: link.branca.stato,
cancellato: false, cancellato: false,
dataCreazione: link.branca.dataCreazione, dataCreazione: link.branca.dataCreazione,
dataModifica: link.branca.dataModifica, dataModifica: link.branca.dataModifica,
@@ -45,12 +92,13 @@ function toAttivitaDto(entity: AttivitaWithRelations): AttivitaDto {
})); }));
const categoriaList: CategoriaDto[] = entity.categoriaLinks const categoriaList: CategoriaDto[] = entity.categoriaLinks
.filter((link) => !link.cancellato) .filter((link) => !link.cancellato && (link.categoria.stato === 'CONFERMATA' || mostraProposte))
.map((link) => ({ .map((link) => ({
id: link.categoria.id, id: link.categoria.id,
nome: link.categoria.nome, nome: link.categoria.nome,
padre: link.categoria.padreId, padre: link.categoria.padreId,
tipo: toTipologicaDto(link.categoria.tipo), tipo: toTipologicaDto(link.categoria.tipo),
stato: link.categoria.stato,
cancellato: false, cancellato: false,
dataCreazione: link.categoria.dataCreazione, dataCreazione: link.categoria.dataCreazione,
dataModifica: link.categoria.dataModifica, dataModifica: link.categoria.dataModifica,
@@ -70,12 +118,15 @@ function toAttivitaDto(entity: AttivitaWithRelations): AttivitaDto {
})); }));
const periodoAnnoList: PeriodoAnnoDto[] = entity.periodoAnnoLinks const periodoAnnoList: PeriodoAnnoDto[] = entity.periodoAnnoLinks
.filter((link) => !link.cancellato) .filter(
(link) => !link.cancellato && (link.periodoAnno.stato === 'CONFERMATA' || mostraProposte),
)
.map((link) => ({ .map((link) => ({
id: link.periodoAnno.id, id: link.periodoAnno.id,
nome: link.periodoAnno.nome, nome: link.periodoAnno.nome,
inizioMese: link.periodoAnno.inizioMese, inizioMese: link.periodoAnno.inizioMese,
fineMese: link.periodoAnno.fineMese, fineMese: link.periodoAnno.fineMese,
stato: link.periodoAnno.stato,
cancellato: false, cancellato: false,
dataCreazione: link.periodoAnno.dataCreazione, dataCreazione: link.periodoAnno.dataCreazione,
dataModifica: link.periodoAnno.dataModifica, dataModifica: link.periodoAnno.dataModifica,
@@ -107,42 +158,56 @@ function toAttivitaDto(entity: AttivitaWithRelations): AttivitaDto {
materialeList, materialeList,
paragrafoList, paragrafoList,
periodoAnnoList, periodoAnnoList,
noteList,
dataCreazione: entity.dataCreazione, dataCreazione: entity.dataCreazione,
dataModifica: entity.dataModifica, dataModifica: entity.dataModifica,
utenteModifica: entity.utenteModifica, utenteModifica: entity.utenteModifica,
}; };
} }
export async function getListaHome(): Promise<AttivitaDto[]> { export async function getListaHome(auth?: AuthContext): Promise<AttivitaDto[]> {
const entities = await prisma.attivita.findMany({ const entities = await prisma.attivita.findMany({
where: { statoId: 'PU' }, where: { statoId: 'PU' },
include: attivitaInclude, include: attivitaInclude,
orderBy: { dataModifica: 'desc' }, orderBy: { dataModifica: 'desc' },
}); });
return entities.map(toAttivitaDto); return entities.map((entity) => toAttivitaDto(entity, auth));
} }
export async function getOne(id: number): Promise<AttivitaDto | null> { export async function getOne(id: number, auth?: AuthContext): Promise<AttivitaDto | null> {
const entity = await prisma.attivita.findUnique({ const entity = await prisma.attivita.findUnique({
where: { id }, where: { id },
include: attivitaInclude, include: attivitaInclude,
}); });
return entity ? toAttivitaDto(entity) : null; if (!entity) {
return null;
}
// Un'attività non ancora pubblicata (bozza, privata, o in attesa di approvazione) è
// visibile solo a chi può gestirla: chiunque altro la vede come inesistente.
if (entity.statoId !== STATO_PUBBLICATO && !puoGestire(entity, auth)) {
return null;
}
return toAttivitaDto(entity, auth);
} }
export async function getListMy(autoreId: string): Promise<AttivitaDto[]> { export async function getListMy(autoreId: string, auth?: AuthContext): Promise<AttivitaDto[]> {
const entities = await prisma.attivita.findMany({ const entities = await prisma.attivita.findMany({
where: { autoreId }, where: { autoreId },
include: attivitaInclude, include: attivitaInclude,
orderBy: { dataModifica: 'desc' }, orderBy: { dataModifica: 'desc' },
}); });
return entities.map(toAttivitaDto); return entities.map((entity) => toAttivitaDto(entity, auth));
} }
export async function getListaSearch(dtoList: SearchObjectDto[]): Promise<AttivitaDto[]> { export async function getListaSearch(
dtoList: SearchObjectDto[],
auth?: AuthContext,
): Promise<AttivitaDto[]> {
const brancaIds = dtoList.filter((d) => d.gruppo === 'branca').map((d) => d.id as number); const brancaIds = dtoList.filter((d) => d.gruppo === 'branca').map((d) => d.id as number);
const categoriaIds = dtoList.filter((d) => d.gruppo === 'categoria').map((d) => d.id as number); const categoriaIds = dtoList.filter((d) => d.gruppo === 'categoria').map((d) => d.id as number);
const materialeIds = dtoList.filter((d) => d.gruppo === 'materiale').map((d) => d.id as number); const materialeIds = dtoList.filter((d) => d.gruppo === 'materiale').map((d) => d.id as number);
@@ -154,7 +219,7 @@ export async function getListaSearch(dtoList: SearchObjectDto[]): Promise<Attivi
.map((d) => d.nome) .map((d) => d.nome)
.filter((nome): nome is string => !!nome); .filter((nome): nome is string => !!nome);
const and: Prisma.AttivitaWhereInput[] = []; const and: Prisma.AttivitaWhereInput[] = [{ statoId: STATO_PUBBLICATO }];
if (brancaIds.length > 0) { if (brancaIds.length > 0) {
and.push({ and.push({
@@ -195,7 +260,7 @@ export async function getListaSearch(dtoList: SearchObjectDto[]): Promise<Attivi
orderBy: { dataModifica: 'desc' }, orderBy: { dataModifica: 'desc' },
}); });
return entities.map(toAttivitaDto); return entities.map((entity) => toAttivitaDto(entity, auth));
} }
export async function changeStato( export async function changeStato(
@@ -211,18 +276,115 @@ export async function changeStato(
throw new HttpError(403, 'non sei autore di questa attività'); throw new HttpError(403, 'non sei autore di questa attività');
} }
const nuovoStatoId = risolviStatoPersistito(idStato);
const updated = await prisma.attivita.update({ const updated = await prisma.attivita.update({
where: { id: idAttivita }, where: { id: idAttivita },
data: { data: {
statoId: idStato, statoId: nuovoStatoId,
utenteModifica: auth.name, utenteModifica: auth.name,
}, },
include: { stato: true }, include: { stato: true },
}); });
if (nuovoStatoId === STATO_IN_ATTESA && existing.statoId !== STATO_IN_ATTESA) {
await notificheService.creaBroadcastModerazione(
'ATTIVITA_IN_ATTESA',
`Attività da approvare: "${updated.nome}"`,
`/attivita/dettaglio/${updated.id}`,
);
}
return toTipologicaDto(updated.stato); return toTipologicaDto(updated.stato);
} }
export async function getListaModerazione(auth: AuthContext): Promise<AttivitaDto[]> {
const entities = await prisma.attivita.findMany({
where: { statoId: STATO_IN_ATTESA },
include: attivitaInclude,
orderBy: { dataModifica: 'asc' },
});
return entities.map((entity) => toAttivitaDto(entity, auth));
}
async function trovaAttivitaInAttesa(idAttivita: number): Promise<AttivitaWithRelations> {
const entity = await prisma.attivita.findUnique({
where: { id: idAttivita },
include: attivitaInclude,
});
if (!entity) {
throw new HttpError(404, 'attività non trovata');
}
if (entity.statoId !== STATO_IN_ATTESA) {
throw new HttpError(409, 'attività non in attesa di approvazione');
}
return entity;
}
export async function approva(idAttivita: number, auth: AuthContext): Promise<void> {
const entity = await trovaAttivitaInAttesa(idAttivita);
await prisma.attivita.update({
where: { id: idAttivita },
data: {
statoId: STATO_PUBBLICATO,
utenteModifica: auth.name,
},
});
await notificheService.creaPersonale(
'ATTIVITA_PUBBLICATA',
`La tua attività "${entity.nome}" è stata pubblicata`,
entity.autoreId,
`/attivita/dettaglio/${entity.id}`,
);
}
export async function commenta(idAttivita: number, testo: string, auth: AuthContext): Promise<void> {
const entity = await trovaAttivitaInAttesa(idAttivita);
await prisma.$transaction([
prisma.notaAttivita.create({
data: {
attivitaId: idAttivita,
testo,
autore: auth.name,
autoreId: auth.userId,
},
}),
prisma.attivita.update({
where: { id: idAttivita },
data: {
statoId: STATO_BOZZA,
utenteModifica: auth.name,
},
}),
]);
await notificheService.creaPersonale(
'ATTIVITA_BOZZA_NOTA',
`La tua attività "${entity.nome}" è tornata in bozza con una nota di moderazione`,
entity.autoreId,
`/modifica-attivita/${entity.id}`,
);
}
export async function eliminaNota(idNota: number): Promise<void> {
const nota = await prisma.notaAttivita.findUnique({
where: { id: idNota },
include: { attivita: true },
});
if (!nota) {
throw new HttpError(404, 'nota non trovata');
}
if (nota.attivita.statoId !== STATO_PUBBLICATO) {
throw new HttpError(409, 'le note si possono cancellare solo su attività pubblicate');
}
await prisma.notaAttivita.delete({ where: { id: idNota } });
}
type Tx = Omit<PrismaClient, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'>; type Tx = Omit<PrismaClient, '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'>;
async function upsertBranca( async function upsertBranca(
@@ -439,8 +601,12 @@ async function upsertPeriodoAnno(
} }
export async function save(dto: AttivitaSaveInput, auth: AuthContext): Promise<void> { export async function save(dto: AttivitaSaveInput, auth: AuthContext): Promise<void> {
let entraInAttesa = false;
let savedAttivitaId!: number;
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
let attivitaId: number; let attivitaId: number;
const nuovoStatoId = risolviStatoPersistito(dto.stato.id);
if (dto.id) { if (dto.id) {
const existing = await tx.attivita.findUnique({ where: { id: dto.id } }); const existing = await tx.attivita.findUnique({ where: { id: dto.id } });
@@ -455,23 +621,26 @@ export async function save(dto: AttivitaSaveInput, auth: AuthContext): Promise<v
where: { id: dto.id }, where: { id: dto.id },
data: { data: {
nome: dto.nome, nome: dto.nome,
statoId: dto.stato.id, statoId: nuovoStatoId,
utenteModifica: auth.name, utenteModifica: auth.name,
}, },
}); });
attivitaId = dto.id; attivitaId = dto.id;
entraInAttesa = nuovoStatoId === STATO_IN_ATTESA && existing.statoId !== STATO_IN_ATTESA;
} else { } else {
const created = await tx.attivita.create({ const created = await tx.attivita.create({
data: { data: {
nome: dto.nome, nome: dto.nome,
autore: auth.name, autore: auth.name,
autoreId: auth.userId, autoreId: auth.userId,
statoId: dto.stato.id, statoId: nuovoStatoId,
utenteModifica: auth.name, utenteModifica: auth.name,
}, },
}); });
attivitaId = created.id; attivitaId = created.id;
entraInAttesa = nuovoStatoId === STATO_IN_ATTESA;
} }
savedAttivitaId = attivitaId;
const brancaIds = new Set<number>(); const brancaIds = new Set<number>();
for (const branca of dto.brancaList) { for (const branca of dto.brancaList) {
@@ -557,4 +726,12 @@ export async function save(dto: AttivitaSaveInput, auth: AuthContext): Promise<v
where: { attivitaId, id: { notIn: [...paragrafoIds] } }, where: { attivitaId, id: { notIn: [...paragrafoIds] } },
}); });
}); });
if (entraInAttesa) {
await notificheService.creaBroadcastModerazione(
'ATTIVITA_IN_ATTESA',
`Attività da approvare: "${dto.nome}"`,
`/attivita/dettaglio/${savedAttivitaId}`,
);
}
} }
@@ -3,7 +3,7 @@ import { SearchGroupDto, SearchObjectDto } from '../../types/dto';
export async function getBranca(keyword?: string | null): Promise<SearchObjectDto[]> { export async function getBranca(keyword?: string | null): Promise<SearchObjectDto[]> {
const entities = await prisma.branca.findMany({ const entities = await prisma.branca.findMany({
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } }, where: { nome: { contains: keyword ?? '', mode: 'insensitive' }, stato: 'CONFERMATA' },
}); });
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'branca' })); return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'branca' }));
@@ -11,7 +11,7 @@ export async function getBranca(keyword?: string | null): Promise<SearchObjectDt
export async function getCategoria(keyword?: string | null): Promise<SearchObjectDto[]> { export async function getCategoria(keyword?: string | null): Promise<SearchObjectDto[]> {
const entities = await prisma.categoria.findMany({ const entities = await prisma.categoria.findMany({
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } }, where: { nome: { contains: keyword ?? '', mode: 'insensitive' }, stato: 'CONFERMATA' },
}); });
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'categoria' })); return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'categoria' }));
@@ -27,7 +27,7 @@ export async function getMateriale(keyword?: string | null): Promise<SearchObjec
export async function getPeriodoAnno(keyword?: string | null): Promise<SearchObjectDto[]> { export async function getPeriodoAnno(keyword?: string | null): Promise<SearchObjectDto[]> {
const entities = await prisma.periodoAnno.findMany({ const entities = await prisma.periodoAnno.findMany({
where: { nome: { contains: keyword ?? '', mode: 'insensitive' } }, where: { nome: { contains: keyword ?? '', mode: 'insensitive' }, stato: 'CONFERMATA' },
}); });
return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'periodoAnno' })); return entities.map((entity) => ({ id: entity.id, nome: entity.nome, gruppo: 'periodoAnno' }));
@@ -0,0 +1,57 @@
import { Request, Response, NextFunction, Router } from 'express';
import { HttpError } from '../../errors';
import * as notificheService from './notifiche.service';
function asyncHandler(
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
) {
return (req: Request, res: Response, next: NextFunction): void => {
handler(req, res, next).catch(next);
};
}
function parseId(req: Request, next: NextFunction): number | undefined {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
next(new HttpError(400, "l'id deve essere un numero intero"));
return undefined;
}
return id;
}
export const notificheRouter = Router();
notificheRouter.get(
'/',
asyncHandler(async (req, res) => {
res.status(200).json(await notificheService.listNotifiche(req.auth!));
}),
);
notificheRouter.get(
'/non-lette/count',
asyncHandler(async (req, res) => {
res.status(200).json({ count: await notificheService.countNonLette(req.auth!) });
}),
);
notificheRouter.put(
'/letta-tutte',
asyncHandler(async (req, res) => {
await notificheService.segnaTutteLette(req.auth!);
res.status(204).end();
}),
);
notificheRouter.put(
'/:id/letta',
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await notificheService.segnaLetta(id, req.auth!);
res.status(204).end();
}),
);
export default notificheRouter;
@@ -0,0 +1,95 @@
import { TipoNotifica } from '@prisma/client';
import { prisma } from '../../db/prisma';
import { HttpError } from '../../errors';
import { AuthContext } from '../../middlewares/auth.types';
import { NotificaDto } from '../../types/dto';
const RUOLI_MODERAZIONE = ['admin', 'moderatore'];
function isModeratore(auth: AuthContext): boolean {
return RUOLI_MODERAZIONE.some((ruolo) => auth.roles.includes(ruolo));
}
function toDto(entity: {
id: number;
tipo: TipoNotifica;
messaggio: string;
link: string | null;
letta: boolean;
dataCreazione: Date;
}): NotificaDto {
return {
id: entity.id,
tipo: entity.tipo,
messaggio: entity.messaggio,
link: entity.link,
letta: entity.letta,
dataCreazione: entity.dataCreazione,
};
}
// Le notifiche "broadcast" (destinatarioId null) sono la coda di moderazione condivisa tra
// admin/moderatore: sono visibili solo a chi ha uno di questi ruoli, oltre alle proprie
// notifiche personali.
function whereVisibili(auth: AuthContext) {
return isModeratore(auth)
? { OR: [{ destinatarioId: auth.userId }, { destinatarioId: null }] }
: { destinatarioId: auth.userId };
}
export async function listNotifiche(auth: AuthContext): Promise<NotificaDto[]> {
const entities = await prisma.notifica.findMany({
where: whereVisibili(auth),
orderBy: { dataCreazione: 'desc' },
take: 50,
});
return entities.map(toDto);
}
export async function countNonLette(auth: AuthContext): Promise<number> {
return prisma.notifica.count({ where: { ...whereVisibili(auth), letta: false } });
}
export async function segnaLetta(id: number, auth: AuthContext): Promise<void> {
const entity = await prisma.notifica.findUnique({ where: { id } });
if (!entity) {
throw new HttpError(404, 'notifica non trovata');
}
const visibile =
entity.destinatarioId === auth.userId || (entity.destinatarioId === null && isModeratore(auth));
if (!visibile) {
throw new HttpError(403, 'non puoi accedere a questa notifica');
}
await prisma.notifica.update({ where: { id }, data: { letta: true } });
}
export async function segnaTutteLette(auth: AuthContext): Promise<void> {
await prisma.notifica.updateMany({
where: { ...whereVisibili(auth), letta: false },
data: { letta: true },
});
}
// Notifica destinata a chiunque abbia ruolo admin/moderatore (coda di moderazione condivisa,
// vedi whereVisibili): usata per segnalare nuove proposte/attività in attesa di revisione.
export async function creaBroadcastModerazione(
tipo: TipoNotifica,
messaggio: string,
link?: string,
): Promise<void> {
await prisma.notifica.create({
data: { tipo, messaggio, link: link ?? null, destinatarioId: null },
});
}
export async function creaPersonale(
tipo: TipoNotifica,
messaggio: string,
destinatarioId: string,
link?: string,
): Promise<void> {
await prisma.notifica.create({
data: { tipo, messaggio, link: link ?? null, destinatarioId },
});
}
@@ -0,0 +1,305 @@
import { Request, Response, NextFunction, Router } from 'express';
import { HttpError } from '../../errors';
import { requireRole } from '../../middlewares/requireRole';
import {
brancaAdminSchema,
categoriaAdminSchema,
periodoAnnoAdminSchema,
proponiTassonomiaSchema,
} from '../../types/validation';
import * as tassonomieService from './tassonomie.service';
const moderazione = requireRole('admin', 'moderatore');
function asyncHandler(
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
) {
return (req: Request, res: Response, next: NextFunction): void => {
handler(req, res, next).catch(next);
};
}
function parseId(req: Request, next: NextFunction): number | undefined {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
next(new HttpError(400, "l'id deve essere un numero intero"));
return undefined;
}
return id;
}
export const tassonomieRouter = Router();
tassonomieRouter.get(
'/branca',
moderazione,
asyncHandler(async (req, res) => {
res.status(200).json(await tassonomieService.listBranca());
}),
);
tassonomieRouter.post(
'/branca',
moderazione,
asyncHandler(async (req, res, next) => {
const parsed = brancaAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.createBranca(parsed.data, req.auth!);
res.status(201).json(created);
}),
);
tassonomieRouter.put(
'/branca/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const parsed = brancaAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const updated = await tassonomieService.updateBranca(id, parsed.data, req.auth!);
res.status(200).json(updated);
}),
);
tassonomieRouter.delete(
'/branca/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.deleteBranca(id);
res.status(204).end();
}),
);
tassonomieRouter.post(
'/branca/:id/approva',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const approvata = await tassonomieService.approvaBranca(id);
res.status(200).json(approvata);
}),
);
tassonomieRouter.post(
'/branca/:id/rifiuta',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.rifiutaBranca(id);
res.status(204).end();
}),
);
tassonomieRouter.get(
'/categoria',
moderazione,
asyncHandler(async (req, res) => {
res.status(200).json(await tassonomieService.listCategoria());
}),
);
tassonomieRouter.get(
'/tipo-categoria',
moderazione,
asyncHandler(async (req, res) => {
res.status(200).json(await tassonomieService.listTipoCategoria());
}),
);
tassonomieRouter.post(
'/categoria',
moderazione,
asyncHandler(async (req, res, next) => {
const parsed = categoriaAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.createCategoria(parsed.data, req.auth!);
res.status(201).json(created);
}),
);
tassonomieRouter.put(
'/categoria/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const parsed = categoriaAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const updated = await tassonomieService.updateCategoria(id, parsed.data, req.auth!);
res.status(200).json(updated);
}),
);
tassonomieRouter.delete(
'/categoria/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.deleteCategoria(id);
res.status(204).end();
}),
);
tassonomieRouter.post(
'/categoria/:id/approva',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const approvata = await tassonomieService.approvaCategoria(id);
res.status(200).json(approvata);
}),
);
tassonomieRouter.post(
'/categoria/:id/rifiuta',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.rifiutaCategoria(id);
res.status(204).end();
}),
);
tassonomieRouter.get(
'/periodoAnno',
moderazione,
asyncHandler(async (req, res) => {
res.status(200).json(await tassonomieService.listPeriodoAnno());
}),
);
tassonomieRouter.post(
'/periodoAnno',
moderazione,
asyncHandler(async (req, res, next) => {
const parsed = periodoAnnoAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.createPeriodoAnno(parsed.data, req.auth!);
res.status(201).json(created);
}),
);
tassonomieRouter.put(
'/periodoAnno/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const parsed = periodoAnnoAdminSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const updated = await tassonomieService.updatePeriodoAnno(id, parsed.data, req.auth!);
res.status(200).json(updated);
}),
);
tassonomieRouter.delete(
'/periodoAnno/:id',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.deletePeriodoAnno(id);
res.status(204).end();
}),
);
tassonomieRouter.post(
'/periodoAnno/:id/approva',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
const approvato = await tassonomieService.approvaPeriodoAnno(id);
res.status(200).json(approvato);
}),
);
tassonomieRouter.post(
'/periodoAnno/:id/rifiuta',
moderazione,
asyncHandler(async (req, res, next) => {
const id = parseId(req, next);
if (id === undefined) return;
await tassonomieService.rifiutaPeriodoAnno(id);
res.status(204).end();
}),
);
// Route di proposta: qualunque utente autenticato può proporre una nuova tassonomia,
// che viene creata con stato DA_APPROVARE (vedi tassonomie.service.ts).
tassonomieRouter.post(
'/proposte/branca',
asyncHandler(async (req, res, next) => {
const parsed = proponiTassonomiaSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.proponiBranca(parsed.data.nome, req.auth!);
res.status(201).json(created);
}),
);
tassonomieRouter.post(
'/proposte/categoria',
asyncHandler(async (req, res, next) => {
const parsed = proponiTassonomiaSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.proponiCategoria(parsed.data.nome, req.auth!);
res.status(201).json(created);
}),
);
tassonomieRouter.post(
'/proposte/periodoAnno',
asyncHandler(async (req, res, next) => {
const parsed = proponiTassonomiaSchema.safeParse(req.body);
if (!parsed.success) {
next(new HttpError(400, JSON.stringify(parsed.error.flatten())));
return;
}
const created = await tassonomieService.proponiPeriodoAnno(parsed.data.nome, req.auth!);
res.status(201).json(created);
}),
);
@@ -0,0 +1,301 @@
import { Branca, Categoria, PeriodoAnno, TipoCategoria } from '@prisma/client';
import { prisma } from '../../db/prisma';
import { HttpError } from '../../errors';
import { AuthContext } from '../../middlewares/auth.types';
import * as notificheService from '../notifiche/notifiche.service';
import { BrancaAdminInput, CategoriaAdminInput, PeriodoAnnoAdminInput } from '../../types/validation';
export async function listBranca(): Promise<Branca[]> {
return prisma.branca.findMany({ orderBy: { nome: 'asc' } });
}
export async function createBranca(input: BrancaAdminInput, auth: AuthContext): Promise<Branca> {
return prisma.branca.create({
data: {
nome: input.nome,
inizioEta: input.inizioEta ?? null,
fineEta: input.fineEta ?? null,
colore: input.colore ?? null,
utenteModifica: auth.name,
},
});
}
export async function updateBranca(
id: number,
input: BrancaAdminInput,
auth: AuthContext,
): Promise<Branca> {
await assertExists(prisma.branca.findUnique({ where: { id } }), 'Branca non trovata');
return prisma.branca.update({
where: { id },
data: {
nome: input.nome,
inizioEta: input.inizioEta ?? null,
fineEta: input.fineEta ?? null,
colore: input.colore ?? null,
utenteModifica: auth.name,
},
});
}
export async function deleteBranca(id: number): Promise<void> {
await assertExists(prisma.branca.findUnique({ where: { id } }), 'Branca non trovata');
const linkCount = await prisma.brancaAttivita.count({ where: { brancaId: id } });
if (linkCount > 0) {
throw new HttpError(409, `In uso da ${linkCount} attività, impossibile eliminare`);
}
await prisma.branca.delete({ where: { id } });
}
export async function proponiBranca(nome: string, auth: AuthContext): Promise<Branca> {
const created = await prisma.branca.create({
data: {
nome,
stato: 'DA_APPROVARE',
creatoDaId: auth.userId,
utenteModifica: auth.name,
},
});
await notificheService.creaBroadcastModerazione(
'TASSONOMIA_PROPOSTA',
`Nuova branca proposta da approvare: "${created.nome}"`,
'/tassonomie',
);
return created;
}
export async function approvaBranca(id: number): Promise<Branca> {
const entity = await assertExists(prisma.branca.findUnique({ where: { id } }), 'Branca non trovata');
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Branca già confermata');
}
return prisma.branca.update({ where: { id }, data: { stato: 'CONFERMATA' } });
}
export async function rifiutaBranca(id: number): Promise<void> {
const entity = await assertExists(prisma.branca.findUnique({ where: { id } }), 'Branca non trovata');
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Solo le proposte in attesa possono essere rifiutate');
}
await prisma.$transaction([
prisma.brancaAttivita.deleteMany({ where: { brancaId: id } }),
prisma.branca.delete({ where: { id } }),
]);
}
export async function listCategoria(): Promise<Categoria[]> {
return prisma.categoria.findMany({ orderBy: { nome: 'asc' } });
}
export async function listTipoCategoria(): Promise<TipoCategoria[]> {
return prisma.tipoCategoria.findMany({ orderBy: { nome: 'asc' } });
}
export async function createCategoria(
input: CategoriaAdminInput,
auth: AuthContext,
): Promise<Categoria> {
return prisma.categoria.create({
data: {
nome: input.nome,
padreId: input.padreId ?? null,
tipoId: input.tipoId,
utenteModifica: auth.name,
},
});
}
export async function updateCategoria(
id: number,
input: CategoriaAdminInput,
auth: AuthContext,
): Promise<Categoria> {
await assertExists(prisma.categoria.findUnique({ where: { id } }), 'Categoria non trovata');
if (input.padreId === id) {
throw new HttpError(400, 'Una categoria non può essere padre di se stessa');
}
return prisma.categoria.update({
where: { id },
data: {
nome: input.nome,
padreId: input.padreId ?? null,
tipoId: input.tipoId,
utenteModifica: auth.name,
},
});
}
export async function deleteCategoria(id: number): Promise<void> {
await assertExists(prisma.categoria.findUnique({ where: { id } }), 'Categoria non trovata');
const [linkCount, figliCount, materialeLinkCount] = await Promise.all([
prisma.categoriaAttivita.count({ where: { categoriaId: id } }),
prisma.categoria.count({ where: { padreId: id } }),
prisma.categoriaMateriale.count({ where: { categoriaId: id } }),
]);
const usi = linkCount + figliCount + materialeLinkCount;
if (usi > 0) {
throw new HttpError(409, `In uso da ${usi} elementi collegati, impossibile eliminare`);
}
await prisma.categoria.delete({ where: { id } });
}
export async function proponiCategoria(nome: string, auth: AuthContext): Promise<Categoria> {
const created = await prisma.categoria.create({
data: {
nome,
tipoId: 'A',
stato: 'DA_APPROVARE',
creatoDaId: auth.userId,
utenteModifica: auth.name,
},
});
await notificheService.creaBroadcastModerazione(
'TASSONOMIA_PROPOSTA',
`Nuova categoria proposta da approvare: "${created.nome}"`,
'/tassonomie',
);
return created;
}
export async function approvaCategoria(id: number): Promise<Categoria> {
const entity = await assertExists(prisma.categoria.findUnique({ where: { id } }), 'Categoria non trovata');
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Categoria già confermata');
}
return prisma.categoria.update({ where: { id }, data: { stato: 'CONFERMATA' } });
}
export async function rifiutaCategoria(id: number): Promise<void> {
const entity = await assertExists(prisma.categoria.findUnique({ where: { id } }), 'Categoria non trovata');
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Solo le proposte in attesa possono essere rifiutate');
}
const figliCount = await prisma.categoria.count({ where: { padreId: id } });
if (figliCount > 0) {
throw new HttpError(409, 'Ha sotto-categorie collegate, impossibile rifiutare');
}
await prisma.$transaction([
prisma.categoriaAttivita.deleteMany({ where: { categoriaId: id } }),
prisma.categoriaMateriale.deleteMany({ where: { categoriaId: id } }),
prisma.categoria.delete({ where: { id } }),
]);
}
export async function listPeriodoAnno(): Promise<PeriodoAnno[]> {
return prisma.periodoAnno.findMany({ orderBy: { nome: 'asc' } });
}
export async function createPeriodoAnno(
input: PeriodoAnnoAdminInput,
auth: AuthContext,
): Promise<PeriodoAnno> {
return prisma.periodoAnno.create({
data: {
nome: input.nome,
inizioMese: input.inizioMese ?? null,
fineMese: input.fineMese ?? null,
utenteModifica: auth.name,
},
});
}
export async function updatePeriodoAnno(
id: number,
input: PeriodoAnnoAdminInput,
auth: AuthContext,
): Promise<PeriodoAnno> {
await assertExists(prisma.periodoAnno.findUnique({ where: { id } }), 'Periodo anno non trovato');
return prisma.periodoAnno.update({
where: { id },
data: {
nome: input.nome,
inizioMese: input.inizioMese ?? null,
fineMese: input.fineMese ?? null,
utenteModifica: auth.name,
},
});
}
export async function deletePeriodoAnno(id: number): Promise<void> {
await assertExists(prisma.periodoAnno.findUnique({ where: { id } }), 'Periodo anno non trovato');
const linkCount = await prisma.periodoAnnoAttivita.count({ where: { periodoAnnoId: id } });
if (linkCount > 0) {
throw new HttpError(409, `In uso da ${linkCount} attività, impossibile eliminare`);
}
await prisma.periodoAnno.delete({ where: { id } });
}
export async function proponiPeriodoAnno(nome: string, auth: AuthContext): Promise<PeriodoAnno> {
const created = await prisma.periodoAnno.create({
data: {
nome,
stato: 'DA_APPROVARE',
creatoDaId: auth.userId,
utenteModifica: auth.name,
},
});
await notificheService.creaBroadcastModerazione(
'TASSONOMIA_PROPOSTA',
`Nuovo periodo dell'anno proposto da approvare: "${created.nome}"`,
'/tassonomie',
);
return created;
}
export async function approvaPeriodoAnno(id: number): Promise<PeriodoAnno> {
const entity = await assertExists(
prisma.periodoAnno.findUnique({ where: { id } }),
'Periodo anno non trovato',
);
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Periodo anno già confermato');
}
return prisma.periodoAnno.update({ where: { id }, data: { stato: 'CONFERMATA' } });
}
export async function rifiutaPeriodoAnno(id: number): Promise<void> {
const entity = await assertExists(
prisma.periodoAnno.findUnique({ where: { id } }),
'Periodo anno non trovato',
);
if (entity.stato !== 'DA_APPROVARE') {
throw new HttpError(409, 'Solo le proposte in attesa possono essere rifiutate');
}
await prisma.$transaction([
prisma.periodoAnnoAttivita.deleteMany({ where: { periodoAnnoId: id } }),
prisma.periodoAnno.delete({ where: { id } }),
]);
}
async function assertExists<T>(promise: Promise<T | null>, message: string): Promise<T> {
const entity = await promise;
if (!entity) {
throw new HttpError(404, message);
}
return entity;
}
+25
View File
@@ -3,6 +3,8 @@ export interface TipologicaDto {
nome: string; nome: string;
} }
export type StatoTassonomiaDto = 'CONFERMATA' | 'DA_APPROVARE';
export interface BaseDto { export interface BaseDto {
dataCreazione: Date; dataCreazione: Date;
dataModifica: Date; dataModifica: Date;
@@ -15,6 +17,7 @@ export interface BrancaDto extends BaseDto {
inizioEta: number | null; inizioEta: number | null;
fineEta: number | null; fineEta: number | null;
colore: string | null; colore: string | null;
stato: StatoTassonomiaDto;
cancellato: boolean; cancellato: boolean;
} }
@@ -23,6 +26,7 @@ export interface CategoriaDto extends BaseDto {
nome: string; nome: string;
padre: number | null; padre: number | null;
tipo: TipologicaDto; tipo: TipologicaDto;
stato: StatoTassonomiaDto;
cancellato: boolean; cancellato: boolean;
} }
@@ -48,9 +52,18 @@ export interface PeriodoAnnoDto extends BaseDto {
nome: string; nome: string;
inizioMese: number | null; inizioMese: number | null;
fineMese: number | null; fineMese: number | null;
stato: StatoTassonomiaDto;
cancellato: boolean; cancellato: boolean;
} }
export interface NotaDto {
id: number;
attivitaId: number;
testo: string;
autore: string;
dataCreazione: Date;
}
export interface AttivitaDto extends BaseDto { export interface AttivitaDto extends BaseDto {
id: number | null; id: number | null;
nome: string; nome: string;
@@ -62,6 +75,18 @@ export interface AttivitaDto extends BaseDto {
materialeList: MaterialeDto[]; materialeList: MaterialeDto[];
paragrafoList: ParagrafoDto[]; paragrafoList: ParagrafoDto[];
periodoAnnoList: PeriodoAnnoDto[]; periodoAnnoList: PeriodoAnnoDto[];
// Popolata solo se il richiedente è l'autore o ha ruolo admin/moderatore (vedi puoGestire
// in attivita.service.ts); per chiunque altro arriva sempre vuota, anche se esistono note.
noteList: NotaDto[];
}
export interface NotificaDto {
id: number;
tipo: string;
messaggio: string;
link: string | null;
letta: boolean;
dataCreazione: Date;
} }
export interface SearchObjectDto { export interface SearchObjectDto {
@@ -49,6 +49,39 @@ export const attivitaSaveSchema = z.object({
paragrafoList: z.array(paragrafoInputSchema), paragrafoList: z.array(paragrafoInputSchema),
}); });
export const brancaAdminSchema = z.object({
nome: z.string().min(1),
inizioEta: z.number().int().nullable().optional(),
fineEta: z.number().int().nullable().optional(),
colore: z.string().max(10).nullable().optional(),
});
export const categoriaAdminSchema = z.object({
nome: z.string().min(1),
padreId: z.number().int().nullable().optional(),
tipoId: z.string().min(1),
});
export const periodoAnnoAdminSchema = z.object({
nome: z.string().min(1),
inizioMese: z.number().int().min(1).max(12).nullable().optional(),
fineMese: z.number().int().min(1).max(12).nullable().optional(),
});
export const proponiTassonomiaSchema = z.object({
nome: z.string().min(1),
});
export const notaAttivitaSchema = z.object({
testo: z.string().min(1).max(1000),
});
export type BrancaAdminInput = z.infer<typeof brancaAdminSchema>;
export type CategoriaAdminInput = z.infer<typeof categoriaAdminSchema>;
export type PeriodoAnnoAdminInput = z.infer<typeof periodoAnnoAdminSchema>;
export type ProponiTassonomiaInput = z.infer<typeof proponiTassonomiaSchema>;
export type NotaAttivitaInput = z.infer<typeof notaAttivitaSchema>;
export type TipologicaInput = z.infer<typeof tipologicaSchema>; export type TipologicaInput = z.infer<typeof tipologicaSchema>;
export type ParagrafoInput = z.infer<typeof paragrafoInputSchema>; export type ParagrafoInput = z.infer<typeof paragrafoInputSchema>;
export type BrancaInput = z.infer<typeof brancaInputSchema>; export type BrancaInput = z.infer<typeof brancaInputSchema>;
+7
View File
@@ -1,11 +1,18 @@
FROM node:22-bookworm-slim AS build FROM node:22-bookworm-slim AS build
ARG API_URL
ARG KEYCLOAK_BASE_URL
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN sed -i \
-e "s#__API_URL__#${API_URL}#" \
-e "s#__KEYCLOAK_BASE_URL__#${KEYCLOAK_BASE_URL}#" \
src/environments/environment.ts
RUN npm run build RUN npm run build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve --port 7001",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test"
+3 -2
View File
@@ -1,20 +1,21 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { authGuard } from './core/auth/auth.guard'; import { authGuard } from './core/auth/auth.guard';
import { requireModeratoreGuard } from './core/auth/require-moderatore.guard';
import { ActivityForm } from './pages/activity-form/activity-form'; import { ActivityForm } from './pages/activity-form/activity-form';
import { Detail } from './pages/detail/detail'; import { Detail } from './pages/detail/detail';
import { Home } from './pages/home/home'; import { Home } from './pages/home/home';
import { MyActivity } from './pages/my-activity/my-activity'; import { MyActivity } from './pages/my-activity/my-activity';
import { Profile } from './pages/profile/profile';
import { Search } from './pages/search/search'; import { Search } from './pages/search/search';
import { Tassonomie } from './pages/tassonomie/tassonomie';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: Home }, { path: '', component: Home },
{ path: 'ricerca', component: Search }, { path: 'ricerca', component: Search },
{ path: 'attivita/dettaglio/:idAttivita', component: Detail }, { path: 'attivita/dettaglio/:idAttivita', component: Detail },
{ path: 'mie-attivita', component: MyActivity, canActivate: [authGuard] }, { path: 'mie-attivita', component: MyActivity, canActivate: [authGuard] },
{ path: 'profilo', component: Profile, canActivate: [authGuard] },
{ path: 'nuova-attivita', component: ActivityForm, canActivate: [authGuard] }, { path: 'nuova-attivita', component: ActivityForm, canActivate: [authGuard] },
{ path: 'modifica-attivita/:idAttivita', component: ActivityForm, canActivate: [authGuard] }, { path: 'modifica-attivita/:idAttivita', component: ActivityForm, canActivate: [authGuard] },
{ path: 'tassonomie', component: Tassonomie, canActivate: [authGuard, requireModeratoreGuard] },
{ path: '**', redirectTo: '' }, { path: '**', redirectTo: '' },
]; ];
@@ -0,0 +1,15 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { MODERATORE_ROLE, extractRealmRoles } from './roles';
// La gestione delle tassonomie (Branca/Categoria/Periodo dell'anno) è riservata al ruolo
// realm moderatore (admin lo include come composite role). Da usare insieme a authGuard.
export const requireModeratoreGuard: CanActivateFn = () => {
const router = inject(Router);
const keycloak = inject(Keycloak);
const roles = extractRealmRoles(keycloak.tokenParsed);
return roles.includes(MODERATORE_ROLE) ? true : router.parseUrl('/');
};
@@ -0,0 +1,7 @@
import { KeycloakTokenParsed } from 'keycloak-js';
export const MODERATORE_ROLE = 'moderatore';
export function extractRealmRoles(tokenParsed: KeycloakTokenParsed | undefined): string[] {
return tokenParsed?.realm_access?.roles ?? [];
}
@@ -8,6 +8,10 @@ export interface Stato extends BaseTipologica {}
export const STATO_BOZZA: Stato = { id: 'BO', nome: 'Bozza' }; export const STATO_BOZZA: Stato = { id: 'BO', nome: 'Bozza' };
export const STATO_PUBBLICATO: Stato = { id: 'PU', nome: 'Pubblicato' }; export const STATO_PUBBLICATO: Stato = { id: 'PU', nome: 'Pubblicato' };
export const STATO_PRIVATO: Stato = { id: 'PR', nome: 'Privato' }; export const STATO_PRIVATO: Stato = { id: 'PR', nome: 'Privato' };
// Non selezionabile direttamente dall'autore: è lo stato in cui il backend porta
// un'attività quando l'autore sceglie "Pubblicato", in attesa dell'approvazione di
// admin/moderatore (vedi risolviStatoPersistito in attivita.service.ts sul backend).
export const STATO_IN_ATTESA: Stato = { id: 'IA', nome: 'In attesa di approvazione' };
export const STATI: Stato[] = [STATO_BOZZA, STATO_PUBBLICATO, STATO_PRIVATO]; export const STATI: Stato[] = [STATO_BOZZA, STATO_PUBBLICATO, STATO_PRIVATO];
export interface TipoParagrafo extends BaseTipologica {} export interface TipoParagrafo extends BaseTipologica {}
@@ -17,12 +21,15 @@ export const TIPO_PARAGRAFO: TipoParagrafo = { id: 'PARAGRAFO', nome: 'Paragrafo
export interface TipoCategoria extends BaseTipologica {} export interface TipoCategoria extends BaseTipologica {}
export type StatoTassonomia = 'CONFERMATA' | 'DA_APPROVARE';
export interface Branca { export interface Branca {
id: number; id: number;
nome: string; nome: string;
inizioEta?: number; inizioEta?: number;
fineEta?: number; fineEta?: number;
colore?: string; colore?: string;
stato?: StatoTassonomia;
} }
export interface Categoria { export interface Categoria {
@@ -30,6 +37,7 @@ export interface Categoria {
nome: string; nome: string;
tipo?: TipoCategoria; tipo?: TipoCategoria;
padre?: number | null; padre?: number | null;
stato?: StatoTassonomia;
} }
export interface Materiale { export interface Materiale {
@@ -43,6 +51,7 @@ export interface PeriodoAnno {
nome: string; nome: string;
inizioMese?: number | null; inizioMese?: number | null;
fineMese?: number | null; fineMese?: number | null;
stato?: StatoTassonomia;
} }
export interface Paragrafo { export interface Paragrafo {
@@ -52,6 +61,14 @@ export interface Paragrafo {
ordine: number; ordine: number;
} }
export interface Nota {
id: number;
attivitaId: number;
testo: string;
autore: string;
dataCreazione: string;
}
export interface Attivita { export interface Attivita {
id?: number; id?: number;
nome: string; nome: string;
@@ -63,6 +80,9 @@ export interface Attivita {
categoriaList: Categoria[]; categoriaList: Categoria[];
materialeList: Materiale[]; materialeList: Materiale[];
periodoAnnoList: PeriodoAnno[]; periodoAnnoList: PeriodoAnno[];
// Popolata dal backend solo per l'autore o per admin/moderatore (vedi puoGestire su
// attivita.service.ts); vuota per chiunque altro anche quando esistono note.
noteList?: Nota[];
dataCreazione?: string; dataCreazione?: string;
dataModifica?: string; dataModifica?: string;
utenteModifica?: string; utenteModifica?: string;
@@ -0,0 +1,14 @@
export type TipoNotifica =
| 'TASSONOMIA_PROPOSTA'
| 'ATTIVITA_IN_ATTESA'
| 'ATTIVITA_PUBBLICATA'
| 'ATTIVITA_BOZZA_NOTA';
export interface Notifica {
id: number;
tipo: TipoNotifica;
messaggio: string;
link: string | null;
letta: boolean;
dataCreazione: string;
}
@@ -0,0 +1,56 @@
export type StatoTassonomia = 'CONFERMATA' | 'DA_APPROVARE';
export interface Branca {
id: number;
nome: string;
inizioEta: number | null;
fineEta: number | null;
colore: string | null;
stato: StatoTassonomia;
creatoDaId: string | null;
dataCreazione: string;
dataModifica: string;
utenteModifica: string;
}
export interface TipoCategoria {
id: string;
nome: string;
}
export interface Categoria {
id: number;
nome: string;
padreId: number | null;
tipoId: string;
stato: StatoTassonomia;
creatoDaId: string | null;
dataCreazione: string;
dataModifica: string;
utenteModifica: string;
}
export interface PeriodoAnno {
id: number;
nome: string;
inizioMese: number | null;
fineMese: number | null;
stato: StatoTassonomia;
creatoDaId: string | null;
dataCreazione: string;
dataModifica: string;
utenteModifica: string;
}
export type BrancaInput = Omit<
Branca,
'id' | 'stato' | 'creatoDaId' | 'dataCreazione' | 'dataModifica' | 'utenteModifica'
>;
export type CategoriaInput = Omit<
Categoria,
'id' | 'stato' | 'creatoDaId' | 'dataCreazione' | 'dataModifica' | 'utenteModifica'
>;
export type PeriodoAnnoInput = Omit<
PeriodoAnno,
'id' | 'stato' | 'creatoDaId' | 'dataCreazione' | 'dataModifica' | 'utenteModifica'
>;
@@ -37,4 +37,20 @@ export class AttivitaService {
save(attivita: Attivita): Observable<void> { save(attivita: Attivita): Observable<void> {
return this.http.post<void>(`${this.privateUrl}/save`, attivita); return this.http.post<void>(`${this.privateUrl}/save`, attivita);
} }
getListModerazione(): Observable<Attivita[]> {
return this.http.get<Attivita[]>(`${this.privateUrl}/get/lista/moderazione`);
}
approva(idAttivita: number): Observable<void> {
return this.http.post<void>(`${this.privateUrl}/${idAttivita}/approva`, {});
}
commenta(idAttivita: number, testo: string): Observable<void> {
return this.http.post<void>(`${this.privateUrl}/${idAttivita}/commenta`, { testo });
}
eliminaNota(idNota: number): Observable<void> {
return this.http.delete<void>(`${this.privateUrl}/note/${idNota}`);
}
} }
@@ -0,0 +1,30 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Notifica } from '../models/notifica.model';
@Injectable({
providedIn: 'root',
})
export class NotificheService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/private/notifiche`;
getLista(): Observable<Notifica[]> {
return this.http.get<Notifica[]>(`${this.baseUrl}/`);
}
getCountNonLette(): Observable<{ count: number }> {
return this.http.get<{ count: number }>(`${this.baseUrl}/non-lette/count`);
}
segnaLetta(id: number): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/${id}/letta`, {});
}
segnaTutteLette(): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/letta-tutte`, {});
}
}
@@ -0,0 +1,110 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
Branca,
BrancaInput,
Categoria,
CategoriaInput,
PeriodoAnno,
PeriodoAnnoInput,
TipoCategoria,
} from '../models/tassonomia.model';
@Injectable({
providedIn: 'root',
})
export class TassonomieService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/private/tassonomie`;
listBranca(): Observable<Branca[]> {
return this.http.get<Branca[]>(`${this.baseUrl}/branca`);
}
createBranca(input: BrancaInput): Observable<Branca> {
return this.http.post<Branca>(`${this.baseUrl}/branca`, input);
}
updateBranca(id: number, input: BrancaInput): Observable<Branca> {
return this.http.put<Branca>(`${this.baseUrl}/branca/${id}`, input);
}
deleteBranca(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/branca/${id}`);
}
proponiBranca(nome: string): Observable<Branca> {
return this.http.post<Branca>(`${this.baseUrl}/proposte/branca`, { nome });
}
approvaBranca(id: number): Observable<Branca> {
return this.http.post<Branca>(`${this.baseUrl}/branca/${id}/approva`, {});
}
rifiutaBranca(id: number): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/branca/${id}/rifiuta`, {});
}
listCategoria(): Observable<Categoria[]> {
return this.http.get<Categoria[]>(`${this.baseUrl}/categoria`);
}
listTipoCategoria(): Observable<TipoCategoria[]> {
return this.http.get<TipoCategoria[]>(`${this.baseUrl}/tipo-categoria`);
}
createCategoria(input: CategoriaInput): Observable<Categoria> {
return this.http.post<Categoria>(`${this.baseUrl}/categoria`, input);
}
updateCategoria(id: number, input: CategoriaInput): Observable<Categoria> {
return this.http.put<Categoria>(`${this.baseUrl}/categoria/${id}`, input);
}
deleteCategoria(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/categoria/${id}`);
}
proponiCategoria(nome: string): Observable<Categoria> {
return this.http.post<Categoria>(`${this.baseUrl}/proposte/categoria`, { nome });
}
approvaCategoria(id: number): Observable<Categoria> {
return this.http.post<Categoria>(`${this.baseUrl}/categoria/${id}/approva`, {});
}
rifiutaCategoria(id: number): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/categoria/${id}/rifiuta`, {});
}
listPeriodoAnno(): Observable<PeriodoAnno[]> {
return this.http.get<PeriodoAnno[]>(`${this.baseUrl}/periodoAnno`);
}
createPeriodoAnno(input: PeriodoAnnoInput): Observable<PeriodoAnno> {
return this.http.post<PeriodoAnno>(`${this.baseUrl}/periodoAnno`, input);
}
updatePeriodoAnno(id: number, input: PeriodoAnnoInput): Observable<PeriodoAnno> {
return this.http.put<PeriodoAnno>(`${this.baseUrl}/periodoAnno/${id}`, input);
}
deletePeriodoAnno(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/periodoAnno/${id}`);
}
proponiPeriodoAnno(nome: string): Observable<PeriodoAnno> {
return this.http.post<PeriodoAnno>(`${this.baseUrl}/proposte/periodoAnno`, { nome });
}
approvaPeriodoAnno(id: number): Observable<PeriodoAnno> {
return this.http.post<PeriodoAnno>(`${this.baseUrl}/periodoAnno/${id}/approva`, {});
}
rifiutaPeriodoAnno(id: number): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/periodoAnno/${id}/rifiuta`, {});
}
}
@@ -11,6 +11,9 @@ export function statoStyle(idStato: string): StatoStyle {
if (idStato === 'PR') { if (idStato === 'PR') {
return { bg: 'var(--color-periodo-bg)', color: 'var(--color-periodo-text)', border: 'var(--color-periodo-border)' }; return { bg: 'var(--color-periodo-bg)', color: 'var(--color-periodo-text)', border: 'var(--color-periodo-border)' };
} }
if (idStato === 'IA') {
return { bg: 'var(--color-attesa-bg)', color: 'var(--color-attesa-text)', border: 'var(--color-attesa-border)' };
}
return { bg: 'var(--color-neutral-bg)', color: 'var(--color-neutral-text)', border: 'var(--color-neutral-border)' }; return { bg: 'var(--color-neutral-bg)', color: 'var(--color-neutral-text)', border: 'var(--color-neutral-border)' };
} }
@@ -44,6 +44,11 @@
gap: 24px; gap: 24px;
} }
.errore-inline {
color: var(--color-danger);
font-size: 13px;
}
.field { .field {
font-weight: 700; font-weight: 700;
font-size: 14px; font-size: 14px;
@@ -47,29 +47,38 @@
@if (step() === 1) { @if (step() === 1) {
<div class="step-content step-content--gap"> <div class="step-content step-content--gap">
@if (classificazioneErrore()) {
<div class="errore-inline">{{ classificazioneErrore() }}</div>
}
<app-chip-field <app-chip-field
label="Branca" label="Branca"
[chips]="brancaList()" [chips]="brancaList()"
[(query)]="brancaQuery" [(query)]="brancaQuery"
[suggestions]="brancaSuggestions()" [suggestions]="brancaSuggestions()"
[allowCreate]="true"
(select)="addBranca($event)" (select)="addBranca($event)"
(remove)="removeBranca($event)" (remove)="removeBranca($event)"
(create)="onCreateBranca($event)"
/> />
<app-chip-field <app-chip-field
label="Categoria" label="Categoria"
[chips]="categoriaList()" [chips]="categoriaList()"
[(query)]="categoriaQuery" [(query)]="categoriaQuery"
[suggestions]="categoriaSuggestions()" [suggestions]="categoriaSuggestions()"
[allowCreate]="true"
(select)="addCategoria($event)" (select)="addCategoria($event)"
(remove)="removeCategoria($event)" (remove)="removeCategoria($event)"
(create)="onCreateCategoria($event)"
/> />
<app-chip-field <app-chip-field
label="Periodo dell'anno" label="Periodo dell'anno"
[chips]="periodoAnnoList()" [chips]="periodoAnnoList()"
[(query)]="periodoQuery" [(query)]="periodoQuery"
[suggestions]="periodoSuggestions()" [suggestions]="periodoSuggestions()"
[allowCreate]="true"
(select)="addPeriodo($event)" (select)="addPeriodo($event)"
(remove)="removePeriodo($event)" (remove)="removePeriodo($event)"
(create)="onCreatePeriodo($event)"
/> />
</div> </div>
} }
@@ -1,6 +1,8 @@
import { Component, computed, effect, inject, signal } from '@angular/core'; import { Component, Signal, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import Keycloak from 'keycloak-js'; import Keycloak from 'keycloak-js';
import { Observable, catchError, debounceTime, distinctUntilChanged, of, switchMap } from 'rxjs';
import { import {
Attivita, Attivita,
@@ -17,10 +19,12 @@ import {
} from '../../core/models/attivita.model'; } from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita'; import { AttivitaService } from '../../core/services/attivita';
import { AutocompleteService } from '../../core/services/autocomplete'; import { AutocompleteService } from '../../core/services/autocomplete';
import { TassonomieService } from '../../core/services/tassonomie';
import { statoStyle } from '../../core/utils/stato-style'; import { statoStyle } from '../../core/utils/stato-style';
import { ChipField, ChipOption } from '../../shared/chip-field/chip-field'; import { ChipField, ChipOption } from '../../shared/chip-field/chip-field';
const STEP_LABELS = ['Info base', 'Classificazione', 'Materiale', 'Paragrafi', 'Riepilogo']; const STEP_LABELS = ['Info base', 'Classificazione', 'Materiale', 'Paragrafi', 'Riepilogo'];
const AUTOCOMPLETE_DEBOUNCE_MS = 300;
@Component({ @Component({
selector: 'app-activity-form', selector: 'app-activity-form',
@@ -33,6 +37,7 @@ export class ActivityForm {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly attivitaService = inject(AttivitaService); private readonly attivitaService = inject(AttivitaService);
private readonly autocompleteService = inject(AutocompleteService); private readonly autocompleteService = inject(AutocompleteService);
private readonly tassonomieService = inject(TassonomieService);
private readonly keycloak = inject(Keycloak); private readonly keycloak = inject(Keycloak);
readonly editingId = signal<number | null>(null); readonly editingId = signal<number | null>(null);
@@ -59,6 +64,8 @@ export class ActivityForm {
readonly periodoSuggestions = signal<ChipOption[]>([]); readonly periodoSuggestions = signal<ChipOption[]>([]);
readonly materialeSuggestions = signal<ChipOption[]>([]); readonly materialeSuggestions = signal<ChipOption[]>([]);
readonly classificazioneErrore = signal<string | null>(null);
readonly wizardTitle = computed(() => (this.editingId() ? 'Modifica attività' : 'Nuova attività')); readonly wizardTitle = computed(() => (this.editingId() ? 'Modifica attività' : 'Nuova attività'));
readonly isLastStep = computed(() => this.step() === 4); readonly isLastStep = computed(() => this.step() === 4);
readonly nonTitoloCount = computed(() => this.paragrafoList().filter((p) => p.tipo.id !== 'TITOLO').length); readonly nonTitoloCount = computed(() => this.paragrafoList().filter((p) => p.tipo.id !== 'TITOLO').length);
@@ -75,54 +82,53 @@ export class ActivityForm {
this.attivitaService.getOne(id).subscribe((attivita) => this.popolaForm(attivita)); this.attivitaService.getOne(id).subscribe((attivita) => this.popolaForm(attivita));
} }
effect(() => { this.autocompleteQuery(
const query = this.brancaQuery(); this.brancaQuery,
(query) => this.autocompleteService.branca(query),
).subscribe((r) => {
const selezionati = new Set(this.brancaList().map((b) => b.id)); const selezionati = new Set(this.brancaList().map((b) => b.id));
if (!query.trim()) { this.brancaSuggestions.set(this.toChipOptions(r, selezionati));
this.brancaSuggestions.set([]);
return;
}
this.autocompleteService.branca(query).subscribe({
next: (r) => this.brancaSuggestions.set(this.toChipOptions(r, selezionati)),
error: () => this.brancaSuggestions.set([]),
}); });
}); this.autocompleteQuery(
effect(() => { this.categoriaQuery,
const query = this.categoriaQuery(); (query) => this.autocompleteService.categoria(query),
).subscribe((r) => {
const selezionati = new Set(this.categoriaList().map((c) => c.id)); const selezionati = new Set(this.categoriaList().map((c) => c.id));
if (!query.trim()) { this.categoriaSuggestions.set(this.toChipOptions(r, selezionati));
this.categoriaSuggestions.set([]);
return;
}
this.autocompleteService.categoria(query).subscribe({
next: (r) => this.categoriaSuggestions.set(this.toChipOptions(r, selezionati)),
error: () => this.categoriaSuggestions.set([]),
}); });
}); this.autocompleteQuery(
effect(() => { this.periodoQuery,
const query = this.periodoQuery(); (query) => this.autocompleteService.periodoAnno(query),
).subscribe((r) => {
const selezionati = new Set(this.periodoAnnoList().map((p) => p.id)); const selezionati = new Set(this.periodoAnnoList().map((p) => p.id));
if (!query.trim()) { this.periodoSuggestions.set(this.toChipOptions(r, selezionati));
this.periodoSuggestions.set([]);
return;
}
this.autocompleteService.periodoAnno(query).subscribe({
next: (r) => this.periodoSuggestions.set(this.toChipOptions(r, selezionati)),
error: () => this.periodoSuggestions.set([]),
}); });
}); this.autocompleteQuery(
effect(() => { this.materialeQuery,
const query = this.materialeQuery(); (query) => this.autocompleteService.materiale(query),
).subscribe((r) => {
const selezionati = new Set(this.materialeList().map((m) => m.id)); const selezionati = new Set(this.materialeList().map((m) => m.id));
if (!query.trim()) { this.materialeSuggestions.set(this.toChipOptions(r, selezionati));
this.materialeSuggestions.set([]); });
return;
} }
this.autocompleteService.materiale(query).subscribe({
next: (r) => this.materialeSuggestions.set(this.toChipOptions(r, selezionati)), // Aspetta che l'utente smetta di scrivere per AUTOCOMPLETE_DEBOUNCE_MS prima di
error: () => this.materialeSuggestions.set([]), // interrogare il backend, invece di chiamarlo ad ogni singola lettera digitata.
}); private autocompleteQuery(
}); querySignal: Signal<string>,
richiedi: (query: string) => Observable<{ id: number | null; nome: string | null }[]>,
) {
return toObservable(querySignal).pipe(
debounceTime(AUTOCOMPLETE_DEBOUNCE_MS),
distinctUntilChanged(),
switchMap((query) => {
if (!query.trim()) {
return of([]);
}
return richiedi(query).pipe(catchError(() => of([])));
}),
takeUntilDestroyed(),
);
} }
private toChipOptions( private toChipOptions(
@@ -165,6 +171,26 @@ export class ActivityForm {
removeBranca(chip: ChipOption): void { removeBranca(chip: ChipOption): void {
this.brancaList.update((lista) => lista.filter((b) => b.id !== chip.id)); this.brancaList.update((lista) => lista.filter((b) => b.id !== chip.id));
} }
onCreateBranca(nome: string): void {
this.classificazioneErrore.set(null);
this.tassonomieService.proponiBranca(nome).subscribe({
next: (branca) => {
this.brancaList.update((lista) => [
...lista,
{
id: branca.id,
nome: branca.nome,
inizioEta: branca.inizioEta ?? undefined,
fineEta: branca.fineEta ?? undefined,
colore: branca.colore ?? undefined,
stato: branca.stato,
},
]);
this.brancaQuery.set('');
},
error: () => this.classificazioneErrore.set('Impossibile proporre la branca.'),
});
}
addCategoria(chip: ChipOption): void { addCategoria(chip: ChipOption): void {
this.categoriaList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]); this.categoriaList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
@@ -173,6 +199,19 @@ export class ActivityForm {
removeCategoria(chip: ChipOption): void { removeCategoria(chip: ChipOption): void {
this.categoriaList.update((lista) => lista.filter((c) => c.id !== chip.id)); this.categoriaList.update((lista) => lista.filter((c) => c.id !== chip.id));
} }
onCreateCategoria(nome: string): void {
this.classificazioneErrore.set(null);
this.tassonomieService.proponiCategoria(nome).subscribe({
next: (categoria) => {
this.categoriaList.update((lista) => [
...lista,
{ id: categoria.id, nome: categoria.nome, stato: categoria.stato },
]);
this.categoriaQuery.set('');
},
error: () => this.classificazioneErrore.set('Impossibile proporre la categoria.'),
});
}
addPeriodo(chip: ChipOption): void { addPeriodo(chip: ChipOption): void {
this.periodoAnnoList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]); this.periodoAnnoList.update((lista) => [...lista, { id: chip.id, nome: chip.nome }]);
@@ -181,6 +220,25 @@ export class ActivityForm {
removePeriodo(chip: ChipOption): void { removePeriodo(chip: ChipOption): void {
this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== chip.id)); this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== chip.id));
} }
onCreatePeriodo(nome: string): void {
this.classificazioneErrore.set(null);
this.tassonomieService.proponiPeriodoAnno(nome).subscribe({
next: (periodo) => {
this.periodoAnnoList.update((lista) => [
...lista,
{
id: periodo.id,
nome: periodo.nome,
inizioMese: periodo.inizioMese,
fineMese: periodo.fineMese,
stato: periodo.stato,
},
]);
this.periodoQuery.set('');
},
error: () => this.classificazioneErrore.set('Impossibile proporre il periodo.'),
});
}
addMateriale(chip: ChipOption): void { addMateriale(chip: ChipOption): void {
this.materialeList.update((lista) => [...lista, { id: chip.id, nome: chip.nome, proprieta: '' }]); this.materialeList.update((lista) => [...lista, { id: chip.id, nome: chip.nome, proprieta: '' }]);
@@ -64,6 +64,12 @@
color: var(--color-periodo-text); color: var(--color-periodo-text);
} }
.badge-attesa {
cursor: default;
font-size: 13px;
margin-left: 2px;
}
.materiale-box { .materiale-box {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -123,3 +129,79 @@
margin: 0; margin: 0;
white-space: pre-wrap; white-space: pre-wrap;
} }
.moderazione-box {
margin-top: 32px;
background: var(--color-attesa-bg);
border: 1px solid var(--color-attesa-border);
border-radius: 14px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.moderazione-title {
font-weight: 700;
font-size: 15px;
color: var(--color-attesa-text);
}
.moderazione-azioni {
display: flex;
}
.moderazione-textarea {
width: 100%;
min-height: 90px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font-size: 14px;
resize: vertical;
}
.note-box {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.note-title {
font-weight: 700;
font-size: 15px;
color: var(--color-text-muted);
}
.nota {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 12px 16px;
}
.nota-meta {
font-size: 13px;
color: var(--color-text-muted);
margin-bottom: 6px;
}
.nota-testo {
font-size: 15px;
white-space: pre-wrap;
}
.nota-elimina {
cursor: pointer;
margin-top: 8px;
background: none;
border: none;
padding: 0;
color: var(--color-danger);
font-size: 13px;
font-weight: 600;
font-family: inherit;
}
@@ -3,6 +3,9 @@
<div class="branca-row"> <div class="branca-row">
@for (branca of a.brancaList; track branca.id) { @for (branca of a.brancaList; track branca.id) {
<span class="branca-dot" [style.background]="branca.colore" [title]="branca.nome"></span> <span class="branca-dot" [style.background]="branca.colore" [title]="branca.nome"></span>
@if (branca.stato === 'DA_APPROVARE') {
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
}
} }
<span class="branca-nomi">{{ brancaNomi() }}</span> <span class="branca-nomi">{{ brancaNomi() }}</span>
</div> </div>
@@ -13,10 +16,20 @@
</div> </div>
<div class="chips-row"> <div class="chips-row">
@for (categoria of a.categoriaList; track categoria.id) { @for (categoria of a.categoriaList; track categoria.id) {
<span class="chip chip--categoria">{{ categoria.nome }}</span> <span class="chip chip--categoria">
{{ categoria.nome }}
@if (categoria.stato === 'DA_APPROVARE') {
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
}
</span>
} }
@for (periodo of a.periodoAnnoList; track periodo.id) { @for (periodo of a.periodoAnnoList; track periodo.id) {
<span class="chip chip--periodo">{{ periodo.nome }}</span> <span class="chip chip--periodo">
{{ periodo.nome }}
@if (periodo.stato === 'DA_APPROVARE') {
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
}
</span>
} }
</div> </div>
@@ -50,6 +63,43 @@
} }
} }
</div> </div>
@if (puoModerare()) {
<div class="moderazione-box">
<div class="moderazione-title">Moderazione</div>
<div class="moderazione-azioni">
<button class="btn-primary" [disabled]="azioneInCorso()" (click)="approva()">✓ Approva e pubblica</button>
</div>
<textarea
class="moderazione-textarea"
placeholder="Scrivi una nota per l'autore: l'attività tornerà in bozza per essere sistemata..."
[value]="testoCommento()"
(input)="testoCommento.set($any($event.target).value)"
></textarea>
<button
class="btn-secondary"
[disabled]="azioneInCorso() || !testoCommento().trim()"
(click)="commenta()"
>
Commenta e rimanda all'autore
</button>
</div>
}
@if (a.noteList && a.noteList.length > 0) {
<div class="note-box">
<div class="note-title">Note di moderazione</div>
@for (nota of a.noteList; track nota.id) {
<div class="nota">
<div class="nota-meta">{{ nota.autore }} · {{ formatNotaData(nota.dataCreazione) }}</div>
<div class="nota-testo">{{ nota.testo }}</div>
@if (puoCancellareNote()) {
<button class="nota-elimina" (click)="eliminaNota(nota.id)">Elimina nota</button>
}
</div>
}
</div>
}
</div> </div>
} }
@@ -1,6 +1,9 @@
import { Component, computed, inject, signal } from '@angular/core'; import { Component, computed, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import Keycloak from 'keycloak-js';
import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular';
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
import { Attivita, Paragrafo } from '../../core/models/attivita.model'; import { Attivita, Paragrafo } from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita'; import { AttivitaService } from '../../core/services/attivita';
import { formatData, parseMarkdown, statoStyle } from '../../core/utils/stato-style'; import { formatData, parseMarkdown, statoStyle } from '../../core/utils/stato-style';
@@ -14,9 +17,13 @@ import { formatData, parseMarkdown, statoStyle } from '../../core/utils/stato-st
export class Detail { export class Detail {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly attivitaService = inject(AttivitaService); private readonly attivitaService = inject(AttivitaService);
private readonly keycloak = inject(Keycloak);
private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL);
readonly attivita = signal<Attivita | null>(null); readonly attivita = signal<Attivita | null>(null);
readonly caricamento = signal(true); readonly caricamento = signal(true);
readonly testoCommento = signal('');
readonly azioneInCorso = signal(false);
readonly dataModificaFmt = computed(() => formatData(this.attivita()?.dataModifica)); readonly dataModificaFmt = computed(() => formatData(this.attivita()?.dataModifica));
readonly statoTextColor = computed(() => statoStyle(this.attivita()?.stato.id ?? 'BO').color); readonly statoTextColor = computed(() => statoStyle(this.attivita()?.stato.id ?? 'BO').color);
@@ -25,8 +32,26 @@ export class Detail {
[...(this.attivita()?.paragrafoList ?? [])].sort((a, b) => a.ordine - b.ordine) [...(this.attivita()?.paragrafoList ?? [])].sort((a, b) => a.ordine - b.ordine)
); );
readonly isModeratore = computed(() => {
this.keycloakEvent();
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
});
readonly puoModerare = computed(
() => this.isModeratore() && this.attivita()?.stato.id === 'IA',
);
readonly puoCancellareNote = computed(
() => this.isModeratore() && this.attivita()?.stato.id === 'PU',
);
constructor() { constructor() {
this.caricaAttivita();
}
private caricaAttivita(): void {
const idAttivita = Number(this.route.snapshot.paramMap.get('idAttivita')); const idAttivita = Number(this.route.snapshot.paramMap.get('idAttivita'));
this.caricamento.set(true);
this.attivitaService.getOne(idAttivita).subscribe({ this.attivitaService.getOne(idAttivita).subscribe({
next: (attivita) => { next: (attivita) => {
this.attivita.set(attivita); this.attivita.set(attivita);
@@ -43,4 +68,42 @@ export class Detail {
tokens(paragrafo: Paragrafo) { tokens(paragrafo: Paragrafo) {
return parseMarkdown(paragrafo.corpo); return parseMarkdown(paragrafo.corpo);
} }
formatNotaData(data: string): string {
return formatData(data);
}
approva(): void {
const attivita = this.attivita();
if (!attivita?.id || this.azioneInCorso()) return;
this.azioneInCorso.set(true);
this.attivitaService.approva(attivita.id).subscribe({
next: () => {
this.azioneInCorso.set(false);
this.caricaAttivita();
},
error: () => this.azioneInCorso.set(false),
});
}
commenta(): void {
const attivita = this.attivita();
const testo = this.testoCommento().trim();
if (!attivita?.id || !testo || this.azioneInCorso()) return;
this.azioneInCorso.set(true);
this.attivitaService.commenta(attivita.id, testo).subscribe({
next: () => {
this.azioneInCorso.set(false);
this.testoCommento.set('');
this.caricaAttivita();
},
error: () => this.azioneInCorso.set(false),
});
}
eliminaNota(idNota: number): void {
this.attivitaService.eliminaNota(idNota).subscribe(() => this.caricaAttivita());
}
} }
@@ -0,0 +1,14 @@
.approvazione-box {
background: var(--color-attesa-bg);
border: 1px solid var(--color-attesa-border);
border-radius: 14px;
padding: 20px;
margin-bottom: 32px;
}
.approvazione-title {
font-weight: 700;
font-size: 17px;
color: var(--color-attesa-text);
margin-bottom: 14px;
}
@@ -1,4 +1,15 @@
<div class="page"> <div class="page">
@if (isModeratore() && daApprovare().length > 0) {
<div class="approvazione-box">
<div class="approvazione-title">⏳ Attività da approvare ({{ daApprovare().length }})</div>
<div class="grid">
@for (attivita of daApprovare(); track attivita.id) {
<app-activity-card [attivita]="attivita" />
}
</div>
</div>
}
<div class="page-header"> <div class="page-header">
<h1 class="page-title">Attività pubblicate</h1> <h1 class="page-title">Attività pubblicate</h1>
<a class="search-bar" routerLink="/ricerca"> <a class="search-bar" routerLink="/ricerca">
@@ -1,6 +1,9 @@
import { Component, inject, signal } from '@angular/core'; import { Component, computed, effect, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import Keycloak from 'keycloak-js';
import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular';
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
import { Attivita } from '../../core/models/attivita.model'; import { Attivita } from '../../core/models/attivita.model';
import { AttivitaService } from '../../core/services/attivita'; import { AttivitaService } from '../../core/services/attivita';
import { ActivityCard } from '../../shared/activity-card/activity-card'; import { ActivityCard } from '../../shared/activity-card/activity-card';
@@ -13,9 +16,17 @@ import { ActivityCard } from '../../shared/activity-card/activity-card';
}) })
export class Home { export class Home {
private readonly attivitaService = inject(AttivitaService); private readonly attivitaService = inject(AttivitaService);
private readonly keycloak = inject(Keycloak);
private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL);
readonly attivitaList = signal<Attivita[]>([]); readonly attivitaList = signal<Attivita[]>([]);
readonly caricamento = signal(true); readonly caricamento = signal(true);
readonly daApprovare = signal<Attivita[]>([]);
readonly isModeratore = computed(() => {
this.keycloakEvent();
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
});
constructor() { constructor() {
this.attivitaService.getListHome().subscribe({ this.attivitaService.getListHome().subscribe({
@@ -25,5 +36,16 @@ export class Home {
}, },
error: () => this.caricamento.set(false), error: () => this.caricamento.set(false),
}); });
// Il ruolo moderatore/admin arriva dal token Keycloak in modo asincrono: quando diventa
// disponibile carichiamo la lista delle attività in attesa di approvazione da mostrare
// in cima alla home.
effect(() => {
if (this.isModeratore()) {
this.attivitaService.getListModerazione().subscribe((lista) => this.daApprovare.set(lista));
} else {
this.daApprovare.set([]);
}
});
} }
} }
@@ -33,6 +33,24 @@
margin-top: 2px; margin-top: 2px;
} }
.riga-badge {
display: inline-block;
margin-top: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid;
}
.riga-note {
display: block;
margin-top: 6px;
font-size: 13px;
font-weight: 600;
color: var(--color-danger);
}
.stato-options { .stato-options {
display: flex; display: flex;
gap: 6px; gap: 6px;
@@ -11,6 +11,14 @@
<div class="riga-info"> <div class="riga-info">
<a class="riga-titolo" [routerLink]="['/attivita/dettaglio', attivita.id]">{{ attivita.nome }}</a> <a class="riga-titolo" [routerLink]="['/attivita/dettaglio', attivita.id]">{{ attivita.nome }}</a>
<div class="riga-data">Aggiornata il {{ dataModificaFmt(attivita) }}</div> <div class="riga-data">Aggiornata il {{ dataModificaFmt(attivita) }}</div>
@if (isInAttesa(attivita)) {
<div class="riga-badge" [style.color]="stileStato('IA').color" [style.background]="stileStato('IA').bg" [style.border-color]="stileStato('IA').border">
In attesa di approvazione
</div>
}
@if (attivita.noteList && attivita.noteList.length > 0) {
<a class="riga-note" [routerLink]="['/attivita/dettaglio', attivita.id]">📝 Note del moderatore</a>
}
</div> </div>
<div class="stato-options"> <div class="stato-options">
@for (stato of stati; track stato.id) { @for (stato of stati; track stato.id) {
@@ -45,6 +45,10 @@ export class MyActivity {
return attivita.stato.id === stato.id; return attivita.stato.id === stato.id;
} }
isInAttesa(attivita: Attivita): boolean {
return attivita.stato.id === 'IA';
}
cambiaStato(attivita: Attivita, stato: Stato): void { cambiaStato(attivita: Attivita, stato: Stato): void {
if (!attivita.id || attivita.stato.id === stato.id) { if (!attivita.id || attivita.stato.id === stato.id) {
return; return;
@@ -1,30 +0,0 @@
.profile-page {
max-width: 520px;
padding-top: 60px;
text-align: center;
}
.avatar {
width: 84px;
height: 84px;
border-radius: 50%;
background: var(--color-primary-soft);
color: var(--color-primary-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 34px;
font-weight: 700;
margin: 0 auto 18px;
}
.titolo {
font-size: 24px;
margin: 0 0 6px;
font-weight: 700;
}
.sottotitolo {
color: var(--color-text-muted);
margin: 0 0 24px;
}
@@ -1,8 +0,0 @@
<div class="page profile-page">
<div class="avatar">👤</div>
<h1 class="titolo">Il tuo profilo</h1>
<p class="sottotitolo">
La gestione account arriverà con l'autenticazione. Per ora, accedi alle tue attività qui sotto.
</p>
<a class="btn-primary" routerLink="/mie-attivita">Vai a Le mie attività</a>
</div>
@@ -1,10 +0,0 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-profile',
imports: [RouterLink],
templateUrl: './profile.html',
styleUrl: './profile.css',
})
export class Profile {}
@@ -1,4 +1,6 @@
import { Component, computed, effect, inject, signal } from '@angular/core'; import { Component, computed, effect, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { catchError, debounceTime, distinctUntilChanged, of, switchMap } from 'rxjs';
import { Attivita } from '../../core/models/attivita.model'; import { Attivita } from '../../core/models/attivita.model';
import { AutocompleteGroup, GruppoFiltro, SearchObjectDto } from '../../core/models/search.model'; import { AutocompleteGroup, GruppoFiltro, SearchObjectDto } from '../../core/models/search.model';
@@ -7,6 +9,8 @@ import { AutocompleteService } from '../../core/services/autocomplete';
import { NavigationService } from '../../core/services/navigation'; import { NavigationService } from '../../core/services/navigation';
import { ActivityCard } from '../../shared/activity-card/activity-card'; import { ActivityCard } from '../../shared/activity-card/activity-card';
const AUTOCOMPLETE_DEBOUNCE_MS = 300;
@Component({ @Component({
selector: 'app-search', selector: 'app-search',
imports: [ActivityCard], imports: [ActivityCard],
@@ -38,19 +42,26 @@ export class Search {
error: () => this.results.set([]), error: () => this.results.set([]),
}); });
}); });
// Aspetta che l'utente smetta di scrivere per AUTOCOMPLETE_DEBOUNCE_MS prima di
// interrogare il backend, invece di chiamarlo ad ogni singola lettera digitata.
toObservable(this.searchQuery)
.pipe(
debounceTime(AUTOCOMPLETE_DEBOUNCE_MS),
distinctUntilChanged(),
switchMap((query) => {
if (!query.trim()) {
return of([]);
}
return this.autocompleteService.search(query).pipe(catchError(() => of([])));
}),
takeUntilDestroyed(),
)
.subscribe((groups) => this.suggestionGroups.set(groups));
} }
onQueryChange(event: Event): void { onQueryChange(event: Event): void {
const value = (event.target as HTMLInputElement).value; this.searchQuery.set((event.target as HTMLInputElement).value);
this.searchQuery.set(value);
if (!value.trim()) {
this.suggestionGroups.set([]);
return;
}
this.autocompleteService.search(value).subscribe({
next: (groups) => this.suggestionGroups.set(groups),
error: () => this.suggestionGroups.set([]),
});
} }
onQueryKeyDown(event: KeyboardEvent): void { onQueryKeyDown(event: KeyboardEvent): void {
@@ -0,0 +1,155 @@
.tabs {
display: flex;
gap: 6px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.tab {
cursor: pointer;
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 700;
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.tab--attivo {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.form-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 18px 20px;
margin-bottom: 20px;
}
.form-riga {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.field {
font-weight: 700;
font-size: 14px;
flex: 1;
min-width: 160px;
}
.text-input {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 6px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--color-border);
font-size: 16px;
font-family: inherit;
background: var(--color-bg);
color: var(--color-text);
}
.text-input--sm {
margin-top: 4px;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
}
.form-azioni {
display: flex;
gap: 10px;
margin-top: 16px;
}
.errore-inline {
color: var(--color-danger);
font-size: 13px;
margin-top: 10px;
}
.lista {
display: flex;
flex-direction: column;
gap: 14px;
}
.riga {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 18px 20px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
.riga-info {
flex: 1;
min-width: 220px;
}
.riga-titolo {
font-weight: 700;
font-size: 17px;
color: var(--color-text);
}
.riga-data {
font-size: 13px;
color: var(--color-text-muted);
margin-top: 2px;
}
.riga-azioni {
display: flex;
gap: 8px;
}
.riga-modifica {
cursor: pointer;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-border);
font-weight: 600;
font-size: 14px;
}
.riga-elimina {
cursor: pointer;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-danger);
color: var(--color-danger);
font-weight: 600;
font-size: 14px;
}
.proposte-box {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 20px;
}
.proposte-titolo {
font-weight: 700;
font-size: 14px;
color: var(--color-text-muted);
text-transform: uppercase;
}
.badge-attesa {
cursor: default;
font-size: 14px;
margin-left: 4px;
}
@@ -0,0 +1,269 @@
<div class="page">
<div class="page-header">
<h1 class="page-title page-title--sm">Tassonomie</h1>
</div>
<div class="tabs">
<div class="tab" [class.tab--attivo]="tab() === 'branca'" (click)="setTab('branca')">Branca</div>
<div class="tab" [class.tab--attivo]="tab() === 'categoria'" (click)="setTab('categoria')">Categoria</div>
<div class="tab" [class.tab--attivo]="tab() === 'periodoAnno'" (click)="setTab('periodoAnno')">Periodo dell'anno</div>
</div>
@if (caricamento()) {
<div class="empty-state">Caricamento...</div>
} @else if (erroreCaricamento()) {
<div class="empty-state">{{ erroreCaricamento() }}</div>
} @else {
@if (tab() === 'branca') {
@if (brancaProposte().length > 0) {
<div class="proposte-box">
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
@for (branca of brancaProposte(); track branca.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">
{{ branca.nome }}
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
</div>
@if (approvazioneErroreId() === branca.id) {
<div class="errore-inline">{{ approvazioneErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="approvaBranca(branca)">Approva</div>
<div class="riga-elimina" (click)="rifiutaBranca(branca)">Rifiuta</div>
</div>
</div>
}
</div>
}
<div class="form-card">
<div class="form-riga">
<label class="field">
Nome
<input class="text-input text-input--sm" type="text" [value]="brancaNome()" (input)="brancaNome.set($any($event.target).value)" placeholder="Es. Lupetti" />
</label>
<label class="field">
Inizio età
<input class="text-input text-input--sm" type="number" [value]="brancaInizioEta() ?? ''" (input)="brancaInizioEta.set(numeroONull($any($event.target).value))" />
</label>
<label class="field">
Fine età
<input class="text-input text-input--sm" type="number" [value]="brancaFineEta() ?? ''" (input)="brancaFineEta.set(numeroONull($any($event.target).value))" />
</label>
<label class="field">
Colore
<input class="text-input text-input--sm" type="text" [value]="brancaColore() ?? ''" (input)="brancaColore.set($any($event.target).value)" placeholder="Es. #f4a300" />
</label>
</div>
@if (brancaSalvataggioErrore()) {
<div class="errore-inline">{{ brancaSalvataggioErrore() }}</div>
}
<div class="form-azioni">
<div class="btn-primary" (click)="salvaBranca()">{{ brancaEditId() ? 'Salva modifiche' : '+ Aggiungi branca' }}</div>
@if (brancaEditId()) {
<div class="btn-secondary" (click)="annullaBranca()">Annulla</div>
}
</div>
</div>
@if (brancaConfermate().length > 0) {
<div class="lista">
@for (branca of brancaConfermate(); track branca.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">{{ branca.nome }}</div>
<div class="riga-data">
@if (branca.inizioEta !== null || branca.fineEta !== null) {
Età {{ branca.inizioEta ?? '?' }}-{{ branca.fineEta ?? '?' }}
}
@if (branca.colore) {
· {{ branca.colore }}
}
</div>
@if (eliminaErroreId() === branca.id) {
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="modificaBranca(branca)">Modifica</div>
<div class="riga-elimina" (click)="eliminaBranca(branca)">Elimina</div>
</div>
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-title">Nessuna branca presente</div>
</div>
}
}
@if (tab() === 'categoria') {
@if (categoriaProposte().length > 0) {
<div class="proposte-box">
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
@for (categoria of categoriaProposte(); track categoria.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">
{{ categoria.nome }}
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
</div>
@if (approvazioneErroreId() === categoria.id) {
<div class="errore-inline">{{ approvazioneErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="approvaCategoria(categoria)">Approva</div>
<div class="riga-elimina" (click)="rifiutaCategoria(categoria)">Rifiuta</div>
</div>
</div>
}
</div>
}
<div class="form-card">
<div class="form-riga">
<label class="field">
Nome
<input class="text-input text-input--sm" type="text" [value]="categoriaNome()" (input)="categoriaNome.set($any($event.target).value)" placeholder="Es. Giochi" />
</label>
<label class="field">
Tipo
<select class="text-input text-input--sm" [value]="categoriaTipoId()" (change)="categoriaTipoId.set($any($event.target).value)">
<option value="" disabled>Seleziona un tipo</option>
@for (tipo of tipoCategoriaList(); track tipo.id) {
<option [value]="tipo.id">{{ tipo.nome }}</option>
}
</select>
</label>
<label class="field">
Categoria padre
<select class="text-input text-input--sm" [value]="categoriaPadreId() ?? ''" (change)="categoriaPadreId.set(numeroONull($any($event.target).value))">
<option value="">Nessuna</option>
@for (categoria of categoriaPadreOpzioni(); track categoria.id) {
<option [value]="categoria.id">{{ categoria.nome }}</option>
}
</select>
</label>
</div>
@if (categoriaSalvataggioErrore()) {
<div class="errore-inline">{{ categoriaSalvataggioErrore() }}</div>
}
<div class="form-azioni">
<div class="btn-primary" (click)="salvaCategoria()">{{ categoriaEditId() ? 'Salva modifiche' : '+ Aggiungi categoria' }}</div>
@if (categoriaEditId()) {
<div class="btn-secondary" (click)="annullaCategoria()">Annulla</div>
}
</div>
</div>
@if (categoriaConfermate().length > 0) {
<div class="lista">
@for (categoria of categoriaConfermate(); track categoria.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">{{ categoria.nome }}</div>
<div class="riga-data">
{{ tipoCategoriaNome(categoria.tipoId) }}
@if (categoriaPadreNome(categoria.padreId); as padreNome) {
· figlia di {{ padreNome }}
}
</div>
@if (eliminaErroreId() === categoria.id) {
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="modificaCategoria(categoria)">Modifica</div>
<div class="riga-elimina" (click)="eliminaCategoria(categoria)">Elimina</div>
</div>
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-title">Nessuna categoria presente</div>
</div>
}
}
@if (tab() === 'periodoAnno') {
@if (periodoProposti().length > 0) {
<div class="proposte-box">
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
@for (periodo of periodoProposti(); track periodo.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">
{{ periodo.nome }}
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
</div>
@if (approvazioneErroreId() === periodo.id) {
<div class="errore-inline">{{ approvazioneErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="approvaPeriodo(periodo)">Approva</div>
<div class="riga-elimina" (click)="rifiutaPeriodo(periodo)">Rifiuta</div>
</div>
</div>
}
</div>
}
<div class="form-card">
<div class="form-riga">
<label class="field">
Nome
<input class="text-input text-input--sm" type="text" [value]="periodoNome()" (input)="periodoNome.set($any($event.target).value)" placeholder="Es. Estate" />
</label>
<label class="field">
Mese inizio
<input class="text-input text-input--sm" type="number" min="1" max="12" [value]="periodoInizioMese() ?? ''" (input)="periodoInizioMese.set(numeroONull($any($event.target).value))" />
</label>
<label class="field">
Mese fine
<input class="text-input text-input--sm" type="number" min="1" max="12" [value]="periodoFineMese() ?? ''" (input)="periodoFineMese.set(numeroONull($any($event.target).value))" />
</label>
</div>
@if (periodoSalvataggioErrore()) {
<div class="errore-inline">{{ periodoSalvataggioErrore() }}</div>
}
<div class="form-azioni">
<div class="btn-primary" (click)="salvaPeriodo()">{{ periodoEditId() ? 'Salva modifiche' : '+ Aggiungi periodo' }}</div>
@if (periodoEditId()) {
<div class="btn-secondary" (click)="annullaPeriodo()">Annulla</div>
}
</div>
</div>
@if (periodoConfermati().length > 0) {
<div class="lista">
@for (periodo of periodoConfermati(); track periodo.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">{{ periodo.nome }}</div>
<div class="riga-data">
@if (periodo.inizioMese !== null || periodo.fineMese !== null) {
Mese {{ periodo.inizioMese ?? '?' }}-{{ periodo.fineMese ?? '?' }}
}
</div>
@if (eliminaErroreId() === periodo.id) {
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="modificaPeriodo(periodo)">Modifica</div>
<div class="riga-elimina" (click)="eliminaPeriodo(periodo)">Elimina</div>
</div>
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-title">Nessun periodo presente</div>
</div>
}
}
}
</div>
@@ -0,0 +1,375 @@
import { Component, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { TassonomieService } from '../../core/services/tassonomie';
import {
Branca,
Categoria,
PeriodoAnno,
TipoCategoria,
} from '../../core/models/tassonomia.model';
type Tab = 'branca' | 'categoria' | 'periodoAnno';
@Component({
selector: 'app-tassonomie',
templateUrl: './tassonomie.html',
styleUrl: './tassonomie.css',
})
export class Tassonomie {
private readonly tassonomieService = inject(TassonomieService);
readonly tab = signal<Tab>('branca');
readonly caricamento = signal(true);
readonly erroreCaricamento = signal<string | null>(null);
readonly brancaList = signal<Branca[]>([]);
readonly categoriaList = signal<Categoria[]>([]);
readonly tipoCategoriaList = signal<TipoCategoria[]>([]);
readonly periodoAnnoList = signal<PeriodoAnno[]>([]);
readonly brancaEditId = signal<number | null>(null);
readonly brancaNome = signal('');
readonly brancaInizioEta = signal<number | null>(null);
readonly brancaFineEta = signal<number | null>(null);
readonly brancaColore = signal<string | null>(null);
readonly brancaSalvataggioErrore = signal<string | null>(null);
readonly categoriaEditId = signal<number | null>(null);
readonly categoriaNome = signal('');
readonly categoriaPadreId = signal<number | null>(null);
readonly categoriaTipoId = signal('');
readonly categoriaSalvataggioErrore = signal<string | null>(null);
readonly periodoEditId = signal<number | null>(null);
readonly periodoNome = signal('');
readonly periodoInizioMese = signal<number | null>(null);
readonly periodoFineMese = signal<number | null>(null);
readonly periodoSalvataggioErrore = signal<string | null>(null);
readonly eliminaErroreId = signal<number | null>(null);
readonly eliminaErroreMsg = signal<string | null>(null);
readonly categoriaPadreOpzioni = computed(() =>
this.categoriaList().filter((c) => c.id !== this.categoriaEditId()),
);
readonly brancaConfermate = computed(() => this.brancaList().filter((b) => b.stato === 'CONFERMATA'));
readonly brancaProposte = computed(() => this.brancaList().filter((b) => b.stato === 'DA_APPROVARE'));
readonly categoriaConfermate = computed(() => this.categoriaList().filter((c) => c.stato === 'CONFERMATA'));
readonly categoriaProposte = computed(() => this.categoriaList().filter((c) => c.stato === 'DA_APPROVARE'));
readonly periodoConfermati = computed(() => this.periodoAnnoList().filter((p) => p.stato === 'CONFERMATA'));
readonly periodoProposti = computed(() => this.periodoAnnoList().filter((p) => p.stato === 'DA_APPROVARE'));
readonly approvazioneErroreId = signal<number | null>(null);
readonly approvazioneErroreMsg = signal<string | null>(null);
constructor() {
this.caricaTutto();
}
setTab(tab: Tab): void {
this.tab.set(tab);
this.annullaBranca();
this.annullaCategoria();
this.annullaPeriodo();
}
private async caricaTutto(): Promise<void> {
this.caricamento.set(true);
this.erroreCaricamento.set(null);
try {
const [branche, categorie, tipiCategoria, periodi] = await Promise.all([
firstValueFrom(this.tassonomieService.listBranca()),
firstValueFrom(this.tassonomieService.listCategoria()),
firstValueFrom(this.tassonomieService.listTipoCategoria()),
firstValueFrom(this.tassonomieService.listPeriodoAnno()),
]);
this.brancaList.set(branche);
this.categoriaList.set(categorie);
this.tipoCategoriaList.set(tipiCategoria);
this.periodoAnnoList.set(periodi);
} catch {
this.erroreCaricamento.set('Impossibile caricare le tassonomie. Riprova più tardi.');
} finally {
this.caricamento.set(false);
}
}
tipoCategoriaNome(tipoId: string): string {
return this.tipoCategoriaList().find((t) => t.id === tipoId)?.nome ?? tipoId;
}
categoriaPadreNome(padreId: number | null): string | null {
if (padreId === null) {
return null;
}
return this.categoriaList().find((c) => c.id === padreId)?.nome ?? null;
}
modificaBranca(branca: Branca): void {
this.brancaEditId.set(branca.id);
this.brancaNome.set(branca.nome);
this.brancaInizioEta.set(branca.inizioEta);
this.brancaFineEta.set(branca.fineEta);
this.brancaColore.set(branca.colore);
this.brancaSalvataggioErrore.set(null);
}
annullaBranca(): void {
this.brancaEditId.set(null);
this.brancaNome.set('');
this.brancaInizioEta.set(null);
this.brancaFineEta.set(null);
this.brancaColore.set(null);
this.brancaSalvataggioErrore.set(null);
}
async salvaBranca(): Promise<void> {
const nome = this.brancaNome().trim();
if (!nome) {
return;
}
const input = {
nome,
inizioEta: this.brancaInizioEta(),
fineEta: this.brancaFineEta(),
colore: this.brancaColore() || null,
};
this.brancaSalvataggioErrore.set(null);
try {
const editId = this.brancaEditId();
const salvata = editId
? await firstValueFrom(this.tassonomieService.updateBranca(editId, input))
: await firstValueFrom(this.tassonomieService.createBranca(input));
this.brancaList.update((lista) => {
const senzaVecchia = lista.filter((b) => b.id !== salvata.id);
return [...senzaVecchia, salvata].sort((a, b) => a.nome.localeCompare(b.nome));
});
this.annullaBranca();
} catch {
this.brancaSalvataggioErrore.set('Impossibile salvare la branca.');
}
}
async eliminaBranca(branca: Branca): Promise<void> {
this.eliminaErroreId.set(null);
this.eliminaErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.deleteBranca(branca.id));
this.brancaList.update((lista) => lista.filter((b) => b.id !== branca.id));
} catch (err) {
this.eliminaErroreId.set(branca.id);
this.eliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async approvaBranca(branca: Branca): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
const approvata = await firstValueFrom(this.tassonomieService.approvaBranca(branca.id));
this.brancaList.update((lista) => lista.map((b) => (b.id === approvata.id ? approvata : b)));
} catch (err) {
this.approvazioneErroreId.set(branca.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async rifiutaBranca(branca: Branca): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.rifiutaBranca(branca.id));
this.brancaList.update((lista) => lista.filter((b) => b.id !== branca.id));
} catch (err) {
this.approvazioneErroreId.set(branca.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
modificaCategoria(categoria: Categoria): void {
this.categoriaEditId.set(categoria.id);
this.categoriaNome.set(categoria.nome);
this.categoriaPadreId.set(categoria.padreId);
this.categoriaTipoId.set(categoria.tipoId);
this.categoriaSalvataggioErrore.set(null);
}
annullaCategoria(): void {
this.categoriaEditId.set(null);
this.categoriaNome.set('');
this.categoriaPadreId.set(null);
this.categoriaTipoId.set('');
this.categoriaSalvataggioErrore.set(null);
}
async salvaCategoria(): Promise<void> {
const nome = this.categoriaNome().trim();
const tipoId = this.categoriaTipoId();
if (!nome || !tipoId) {
return;
}
const input = {
nome,
padreId: this.categoriaPadreId(),
tipoId,
};
this.categoriaSalvataggioErrore.set(null);
try {
const editId = this.categoriaEditId();
const salvata = editId
? await firstValueFrom(this.tassonomieService.updateCategoria(editId, input))
: await firstValueFrom(this.tassonomieService.createCategoria(input));
this.categoriaList.update((lista) => {
const senzaVecchia = lista.filter((c) => c.id !== salvata.id);
return [...senzaVecchia, salvata].sort((a, b) => a.nome.localeCompare(b.nome));
});
this.annullaCategoria();
} catch {
this.categoriaSalvataggioErrore.set('Impossibile salvare la categoria.');
}
}
async eliminaCategoria(categoria: Categoria): Promise<void> {
this.eliminaErroreId.set(null);
this.eliminaErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.deleteCategoria(categoria.id));
this.categoriaList.update((lista) => lista.filter((c) => c.id !== categoria.id));
} catch (err) {
this.eliminaErroreId.set(categoria.id);
this.eliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async approvaCategoria(categoria: Categoria): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
const approvata = await firstValueFrom(this.tassonomieService.approvaCategoria(categoria.id));
this.categoriaList.update((lista) => lista.map((c) => (c.id === approvata.id ? approvata : c)));
} catch (err) {
this.approvazioneErroreId.set(categoria.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async rifiutaCategoria(categoria: Categoria): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.rifiutaCategoria(categoria.id));
this.categoriaList.update((lista) => lista.filter((c) => c.id !== categoria.id));
} catch (err) {
this.approvazioneErroreId.set(categoria.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
modificaPeriodo(periodo: PeriodoAnno): void {
this.periodoEditId.set(periodo.id);
this.periodoNome.set(periodo.nome);
this.periodoInizioMese.set(periodo.inizioMese);
this.periodoFineMese.set(periodo.fineMese);
this.periodoSalvataggioErrore.set(null);
}
annullaPeriodo(): void {
this.periodoEditId.set(null);
this.periodoNome.set('');
this.periodoInizioMese.set(null);
this.periodoFineMese.set(null);
this.periodoSalvataggioErrore.set(null);
}
async salvaPeriodo(): Promise<void> {
const nome = this.periodoNome().trim();
if (!nome) {
return;
}
const input = {
nome,
inizioMese: this.periodoInizioMese(),
fineMese: this.periodoFineMese(),
};
this.periodoSalvataggioErrore.set(null);
try {
const editId = this.periodoEditId();
const salvato = editId
? await firstValueFrom(this.tassonomieService.updatePeriodoAnno(editId, input))
: await firstValueFrom(this.tassonomieService.createPeriodoAnno(input));
this.periodoAnnoList.update((lista) => {
const senzaVecchia = lista.filter((p) => p.id !== salvato.id);
return [...senzaVecchia, salvato].sort((a, b) => a.nome.localeCompare(b.nome));
});
this.annullaPeriodo();
} catch {
this.periodoSalvataggioErrore.set('Impossibile salvare il periodo.');
}
}
async eliminaPeriodo(periodo: PeriodoAnno): Promise<void> {
this.eliminaErroreId.set(null);
this.eliminaErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.deletePeriodoAnno(periodo.id));
this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== periodo.id));
} catch (err) {
this.eliminaErroreId.set(periodo.id);
this.eliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async approvaPeriodo(periodo: PeriodoAnno): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
const approvato = await firstValueFrom(this.tassonomieService.approvaPeriodoAnno(periodo.id));
this.periodoAnnoList.update((lista) => lista.map((p) => (p.id === approvato.id ? approvato : p)));
} catch (err) {
this.approvazioneErroreId.set(periodo.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async rifiutaPeriodo(periodo: PeriodoAnno): Promise<void> {
this.approvazioneErroreId.set(null);
this.approvazioneErroreMsg.set(null);
try {
await firstValueFrom(this.tassonomieService.rifiutaPeriodoAnno(periodo.id));
this.periodoAnnoList.update((lista) => lista.filter((p) => p.id !== periodo.id));
} catch (err) {
this.approvazioneErroreId.set(periodo.id);
this.approvazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
private estraiMessaggioErrore(err: unknown): string {
if (err && typeof err === 'object' && 'error' in err) {
const body = (err as { error?: unknown }).error;
if (body && typeof body === 'object' && 'message' in body && typeof (body as { message?: unknown }).message === 'string') {
return (body as { message: string }).message;
}
}
return 'Impossibile eliminare l\'elemento.';
}
numeroONull(value: string): number | null {
if (value.trim() === '') {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
}
@@ -48,6 +48,17 @@
background: var(--color-primary-soft); background: var(--color-primary-soft);
} }
.suggestion-item--create {
color: var(--color-primary-text);
font-weight: 600;
border-top: 1px solid var(--color-border);
}
.chip-badge {
cursor: default;
font-size: 13px;
}
.chips { .chips {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -8,11 +8,16 @@
[value]="query()" [value]="query()"
(input)="onQueryInput($event)" (input)="onQueryInput($event)"
/> />
@if (suggestions().length > 0) { @if (suggestions().length > 0 || showCreateOption()) {
<div class="suggestions"> <div class="suggestions">
@for (suggestion of suggestions(); track suggestion.id) { @for (suggestion of suggestions(); track suggestion.id) {
<div class="suggestion-item" (click)="select.emit(suggestion)">{{ suggestion.nome }}</div> <div class="suggestion-item" (click)="select.emit(suggestion)">{{ suggestion.nome }}</div>
} }
@if (showCreateOption()) {
<div class="suggestion-item suggestion-item--create" (click)="onCreate()">
+ Crea "{{ query() }}"
</div>
}
</div> </div>
} }
</div> </div>
@@ -20,6 +25,9 @@
@for (chip of chips(); track chip.id) { @for (chip of chips(); track chip.id) {
<div class="chip"> <div class="chip">
<span>{{ chip.nome }}</span> <span>{{ chip.nome }}</span>
@if (chip.stato === 'DA_APPROVARE') {
<span class="chip-badge" title="In attesa di conferma">⚠️</span>
}
<span class="chip-remove" (click)="remove.emit(chip)">×</span> <span class="chip-remove" (click)="remove.emit(chip)">×</span>
</div> </div>
} }
@@ -1,8 +1,9 @@
import { Component, input, model, output } from '@angular/core'; import { Component, computed, input, model, output } from '@angular/core';
export interface ChipOption { export interface ChipOption {
id: number; id: number;
nome: string; nome: string;
stato?: 'CONFERMATA' | 'DA_APPROVARE';
} }
@Component({ @Component({
@@ -15,12 +16,33 @@ export class ChipField {
readonly label = input.required<string>(); readonly label = input.required<string>();
readonly chips = input.required<ChipOption[]>(); readonly chips = input.required<ChipOption[]>();
readonly suggestions = input<ChipOption[]>([]); readonly suggestions = input<ChipOption[]>([]);
readonly allowCreate = input(false);
readonly query = model(''); readonly query = model('');
readonly remove = output<ChipOption>(); readonly remove = output<ChipOption>();
readonly select = output<ChipOption>(); readonly select = output<ChipOption>();
readonly create = output<string>();
readonly showCreateOption = computed(() => {
const query = this.query().trim();
if (!this.allowCreate() || !query) {
return false;
}
const queryLower = query.toLowerCase();
const giaPresente = [...this.suggestions(), ...this.chips()].some(
(opzione) => opzione.nome.toLowerCase() === queryLower,
);
return !giaPresente;
});
onQueryInput(event: Event): void { onQueryInput(event: Event): void {
this.query.set((event.target as HTMLInputElement).value); this.query.set((event.target as HTMLInputElement).value);
} }
onCreate(): void {
const nome = this.query().trim();
if (nome) {
this.create.emit(nome);
}
}
} }
@@ -97,3 +97,119 @@
padding: 6px 12px; padding: 6px 12px;
font-size: 13px; font-size: 13px;
} }
.notifiche {
position: relative;
}
.campanellina {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: transparent;
font-size: 18px;
cursor: pointer;
}
.campanellina:hover {
background: var(--color-primary-soft);
}
.campanellina-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--color-error, #d64545);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.notifiche-overlay {
position: fixed;
inset: 0;
z-index: 19;
}
.notifiche-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
width: 320px;
max-height: 420px;
overflow-y: auto;
background: var(--color-header);
border: 1px solid var(--color-border);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.notifiche-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
font-weight: 700;
font-size: 14px;
color: var(--color-text);
border-bottom: 1px solid var(--color-border);
}
.notifiche-segna-tutte {
cursor: pointer;
font-weight: 600;
font-size: 12px;
color: var(--color-primary-text-strong);
}
.notifiche-vuoto {
padding: 20px 14px;
text-align: center;
font-size: 13px;
color: var(--color-text);
opacity: 0.7;
}
.notifica-item {
cursor: pointer;
padding: 12px 14px;
border-bottom: 1px solid var(--color-border);
font-size: 13px;
color: var(--color-text);
}
.notifica-item:last-child {
border-bottom: none;
}
.notifica-item:hover {
background: var(--color-primary-soft);
}
.notifica-item--non-letta {
font-weight: 600;
background: var(--color-primary-soft);
}
.notifica-item--non-letta::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-right: 8px;
border-radius: 999px;
background: var(--color-primary);
}
@@ -16,7 +16,7 @@
<span class="brand-name">Scouthub</span> <span class="brand-name">Scouthub</span>
</a> </a>
<nav class="nav-items"> <nav class="nav-items">
@for (item of navItems; track item.path) { @for (item of navItems(); track item.path) {
<a <a
class="nav-item" class="nav-item"
[class.nav-item--active]="isActive(item.path)" [class.nav-item--active]="isActive(item.path)"
@@ -28,6 +28,45 @@
</nav> </nav>
<div class="auth-section"> <div class="auth-section">
@if (isAuthenticated()) { @if (isAuthenticated()) {
<div class="notifiche">
<button
type="button"
class="campanellina"
(click)="toggleCampanellina()"
aria-label="Notifiche"
>
🔔
@if (countNonLette() > 0) {
<span class="campanellina-badge">{{ countBadge() }}</span>
}
</button>
@if (campanellinaAperta()) {
<div class="notifiche-overlay" (click)="chiudiCampanellina()"></div>
<div class="notifiche-dropdown">
<div class="notifiche-header">
<span>Notifiche</span>
@if (countNonLette() > 0) {
<span class="notifiche-segna-tutte" (click)="segnaTutteLette()">
Segna tutte come lette
</span>
}
</div>
@if (notifiche().length === 0) {
<div class="notifiche-vuoto">Nessuna notifica</div>
} @else {
@for (notifica of notifiche(); track notifica.id) {
<div
class="notifica-item"
[class.notifica-item--non-letta]="!notifica.letta"
(click)="apriNotifica(notifica)"
>
<span class="notifica-messaggio">{{ notifica.messaggio }}</span>
</div>
}
}
</div>
}
</div>
<span class="auth-name">{{ displayName() }}</span> <span class="auth-name">{{ displayName() }}</span>
<div class="btn-secondary btn-secondary--sm" (click)="logout()">Esci</div> <div class="btn-secondary btn-secondary--sm" (click)="logout()">Esci</div>
} @else { } @else {
@@ -1,15 +1,21 @@
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core'; import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
import { NavigationEnd, Router, RouterLink } from '@angular/router'; import { NavigationEnd, Router, RouterLink } from '@angular/router';
import { filter } from 'rxjs'; import { filter } from 'rxjs';
import Keycloak from 'keycloak-js'; import Keycloak from 'keycloak-js';
import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular'; import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular';
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
import { Notifica } from '../../core/models/notifica.model';
import { NotificheService } from '../../core/services/notifiche';
interface NavItem { interface NavItem {
path: string; path: string;
label: string; label: string;
} }
const INTERVALLO_POLLING_MS = 30000;
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
imports: [RouterLink], imports: [RouterLink],
@@ -21,9 +27,20 @@ export class Header {
private readonly location = inject(Location); private readonly location = inject(Location);
private readonly keycloak = inject(Keycloak); private readonly keycloak = inject(Keycloak);
private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL); private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL);
private readonly notificheService = inject(NotificheService);
private readonly destroyRef = inject(DestroyRef);
readonly currentUrl = signal(this.router.url); readonly currentUrl = signal(this.router.url);
readonly notifiche = signal<Notifica[]>([]);
readonly countNonLette = signal(0);
readonly campanellinaAperta = signal(false);
readonly countBadge = computed(() => {
const count = this.countNonLette();
return count > 9 ? '9+' : String(count);
});
readonly isAuthenticated = computed(() => { readonly isAuthenticated = computed(() => {
this.keycloakEvent(); this.keycloakEvent();
return !!this.keycloak.authenticated; return !!this.keycloak.authenticated;
@@ -40,17 +57,46 @@ export class Header {
return url.startsWith('/attivita/dettaglio') || url.startsWith('/nuova-attivita') || url.startsWith('/modifica-attivita'); return url.startsWith('/attivita/dettaglio') || url.startsWith('/nuova-attivita') || url.startsWith('/modifica-attivita');
}); });
readonly navItems: NavItem[] = [ readonly isModeratore = computed(() => {
this.keycloakEvent();
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
});
readonly navItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{ path: '/', label: 'Home' }, { path: '/', label: 'Home' },
{ path: '/ricerca', label: 'Cerca' }, { path: '/ricerca', label: 'Cerca' },
{ path: '/mie-attivita', label: 'Le mie attività' }, { path: '/mie-attivita', label: 'Le mie attività' },
{ path: '/profilo', label: 'Profilo' },
]; ];
if (this.isModeratore()) {
items.push({ path: '/tassonomie', label: 'Tassonomie' });
}
return items;
});
constructor() { constructor() {
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe(() => { this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe(() => {
this.currentUrl.set(this.router.url); this.currentUrl.set(this.router.url);
}); });
// Il polling parte/si ferma seguendo lo stato di autenticazione: non ha senso interrogare
// l'endpoint privato delle notifiche per un utente non loggato.
let intervalId: ReturnType<typeof setInterval> | undefined;
effect(() => {
if (this.isAuthenticated()) {
this.aggiornaCountNonLette();
intervalId = setInterval(() => this.aggiornaCountNonLette(), INTERVALLO_POLLING_MS);
} else {
this.notifiche.set([]);
this.countNonLette.set(0);
}
});
this.destroyRef.onDestroy(() => {
if (intervalId !== undefined) {
clearInterval(intervalId);
}
});
} }
isActive(path: string): boolean { isActive(path: string): boolean {
@@ -68,4 +114,50 @@ export class Header {
logout(): void { logout(): void {
this.keycloak.logout({ redirectUri: window.location.origin + '/' }); this.keycloak.logout({ redirectUri: window.location.origin + '/' });
} }
private aggiornaCountNonLette(): void {
this.notificheService.getCountNonLette().subscribe({
next: ({ count }) => this.countNonLette.set(count),
});
}
toggleCampanellina(): void {
const apri = !this.campanellinaAperta();
this.campanellinaAperta.set(apri);
if (apri) {
this.notificheService.getLista().subscribe({
next: (lista) => this.notifiche.set(lista),
});
}
}
chiudiCampanellina(): void {
this.campanellinaAperta.set(false);
}
apriNotifica(notifica: Notifica): void {
if (!notifica.letta) {
this.notificheService.segnaLetta(notifica.id).subscribe({
next: () => {
this.notifiche.update((lista) =>
lista.map((n) => (n.id === notifica.id ? { ...n, letta: true } : n)),
);
this.countNonLette.update((count) => Math.max(0, count - 1));
},
});
}
this.campanellinaAperta.set(false);
if (notifica.link) {
this.router.navigateByUrl(notifica.link);
}
}
segnaTutteLette(): void {
this.notificheService.segnaTutteLette().subscribe({
next: () => {
this.notifiche.update((lista) => lista.map((n) => ({ ...n, letta: true })));
this.countNonLette.set(0);
},
});
}
} }
@@ -1,7 +1,7 @@
export const environment = { export const environment = {
production: false, production: false,
apiUrl: 'http://localhost:8080', apiUrl: 'http://localhost:8001',
keycloakBaseUrl: 'http://localhost:8081', keycloakBaseUrl: 'http://localhost:6999',
keycloakRealm: 'scouthub', keycloakRealm: 'scouthub',
keycloakClientId: 'scouthub-frontend', keycloakClientId: 'scouthub-frontend',
}; };
@@ -1,7 +1,7 @@
export const environment = { export const environment = {
production: true, production: true,
apiUrl: 'http://localhost:8080', apiUrl: '__API_URL__',
keycloakBaseUrl: 'http://localhost:8081', keycloakBaseUrl: '__KEYCLOAK_BASE_URL__',
keycloakRealm: 'scouthub', keycloakRealm: 'scouthub',
keycloakClientId: 'scouthub-frontend', keycloakClientId: 'scouthub-frontend',
}; };
+3
View File
@@ -18,6 +18,9 @@
--color-periodo-border: oklch(80% 0.04 260); --color-periodo-border: oklch(80% 0.04 260);
--color-periodo-text: oklch(35% 0.05 260); --color-periodo-text: oklch(35% 0.05 260);
--color-danger: oklch(50% 0.15 25); --color-danger: oklch(50% 0.15 25);
--color-attesa-bg: oklch(93% 0.06 90);
--color-attesa-border: oklch(80% 0.1 90);
--color-attesa-text: oklch(40% 0.1 90);
--color-neutral-bg: oklch(92% 0.005 60); --color-neutral-bg: oklch(92% 0.005 60);
--color-neutral-text: oklch(45% 0.02 55); --color-neutral-text: oklch(45% 0.02 55);
--color-neutral-border: oklch(85% 0.01 60); --color-neutral-border: oklch(85% 0.01 60);
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve --port 7003",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test"
+5 -5
View File
@@ -1,10 +1,10 @@
# 8082, non 8081: la porta 8081 è quella su cui il Keycloak del # 8000, non 6999: la porta 6999 e' quella su cui il Keycloak del
# docker-compose alla radice del progetto è esposto sull'host. # docker-compose alla radice del progetto e' esposto sull'host.
PORT=8082 PORT=8000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/scouthub_home?schema=public DATABASE_URL=postgresql://postgres:postgres@localhost:5432/scouthub_home?schema=public
FRONTEND_BASE_URL=http://localhost:4200 FRONTEND_BASE_URL=http://localhost:7000
KEYCLOAK_BASE_URL=http://localhost:8081 KEYCLOAK_BASE_URL=http://localhost:6999
KEYCLOAK_REALM=scouthub KEYCLOAK_REALM=scouthub
KEYCLOAK_ORG_SERVICE_CLIENT_ID=scouthub-home-be KEYCLOAK_ORG_SERVICE_CLIENT_ID=scouthub-home-be
KEYCLOAK_ORG_SERVICE_CLIENT_SECRET=CAMBIA-QUESTO-SECRET-IN-UN-VAULT KEYCLOAK_ORG_SERVICE_CLIENT_SECRET=CAMBIA-QUESTO-SECRET-IN-UN-VAULT
+1 -3
View File
@@ -13,6 +13,4 @@ RUN npm run build
EXPOSE 8082 EXPOSE 8082
# Niente migrazioni ancora create (prisma/migrations assente): db push sincronizza CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"]
# lo schema direttamente, coerente con lo stato early-stage del progetto.
CMD ["sh", "-c", "npx prisma db push --skip-generate && node dist/server.js"]
+1 -1
View File
@@ -77,7 +77,7 @@ interamente `src/keycloak-admin` e `src/db/prisma`.
Crea un nuovo gruppo scout: organizzazione Keycloak, un gruppo Keycloak per Crea un nuovo gruppo scout: organizzazione Keycloak, un gruppo Keycloak per
ciascun ruolo di default, e la riga corrispondente in `gruppo_scout`. ciascun ruolo di default, e la riga corrispondente in `gruppo_scout`.
- Richiede autenticazione + ruolo `admin-centrale` (temporaneo, vedi - Richiede autenticazione + ruolo `admin` (temporaneo, vedi
`src/routes/gruppi.routes.ts`) `src/routes/gruppi.routes.ts`)
- Body: `{ nome: string, regione?: string, ruoliDefault?: string[] }` - Body: `{ nome: string, regione?: string, ruoliDefault?: string[] }`
(`ruoliDefault` di default `["Capi", "Aiuto capi", "Censiti"]`) (`ruoliDefault` di default `["Capi", "Aiuto capi", "Censiti"]`)
@@ -0,0 +1,90 @@
-- CreateEnum
CREATE TYPE "StatoRichiesta" AS ENUM ('PENDING', 'APPROVATA', 'RIFIUTATA');
-- CreateEnum
CREATE TYPE "OrigineRichiesta" AS ENUM ('LINK', 'PROFILO');
-- CreateTable
CREATE TABLE "gruppo_scout" (
"org_id" TEXT NOT NULL,
"nome" TEXT NOT NULL,
"regione" TEXT,
"data_creazione" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gruppo_scout_pkey" PRIMARY KEY ("org_id")
);
-- CreateTable
CREATE TABLE "invito" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"email" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"ruolo" TEXT NOT NULL,
"scadenza" TIMESTAMP(3) NOT NULL,
"stato" TEXT NOT NULL,
CONSTRAINT "invito_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "richiesta_ingresso" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"stato" "StatoRichiesta" NOT NULL DEFAULT 'PENDING',
"origine" "OrigineRichiesta" NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "richiesta_ingresso_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "link_ingresso" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"attivo" BOOLEAN NOT NULL DEFAULT true,
"creato_da" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "link_ingresso_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "richiesta_creazione_gruppo" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"nome_proposto" TEXT NOT NULL,
"regione" TEXT,
"stato" "StatoRichiesta" NOT NULL DEFAULT 'PENDING',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "richiesta_creazione_gruppo_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "invito_token_key" ON "invito"("token");
-- CreateIndex
CREATE INDEX "richiesta_ingresso_email_idx" ON "richiesta_ingresso"("email");
-- CreateIndex
CREATE UNIQUE INDEX "link_ingresso_token_key" ON "link_ingresso"("token");
-- CreateIndex
CREATE INDEX "richiesta_creazione_gruppo_email_idx" ON "richiesta_creazione_gruppo"("email");
-- AddForeignKey
ALTER TABLE "invito" ADD CONSTRAINT "invito_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "gruppo_scout"("org_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "richiesta_ingresso" ADD CONSTRAINT "richiesta_ingresso_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "gruppo_scout"("org_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "link_ingresso" ADD CONSTRAINT "link_ingresso_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "gruppo_scout"("org_id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+56
View File
@@ -14,6 +14,8 @@ model GruppoScout {
dataCreazione DateTime @default(now()) @map("data_creazione") dataCreazione DateTime @default(now()) @map("data_creazione")
inviti Invito[] inviti Invito[]
richiesteIngresso RichiestaIngresso[]
linkIngresso LinkIngresso[]
@@map("gruppo_scout") @@map("gruppo_scout")
} }
@@ -31,3 +33,57 @@ model Invito {
@@map("invito") @@map("invito")
} }
enum StatoRichiesta {
PENDING
APPROVATA
RIFIUTATA
}
enum OrigineRichiesta {
LINK
PROFILO
}
model RichiestaIngresso {
id String @id @default(uuid())
userId String @map("user_id")
email String
orgId String @map("org_id")
stato StatoRichiesta @default(PENDING)
origine OrigineRichiesta
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
gruppoScout GruppoScout @relation(fields: [orgId], references: [orgId])
@@index([email])
@@map("richiesta_ingresso")
}
model LinkIngresso {
id String @id @default(uuid())
token String @unique
orgId String @map("org_id")
attivo Boolean @default(true)
creatoDa String @map("creato_da")
createdAt DateTime @default(now()) @map("created_at")
gruppoScout GruppoScout @relation(fields: [orgId], references: [orgId])
@@map("link_ingresso")
}
model RichiestaCreazioneGruppo {
id String @id @default(uuid())
userId String @map("user_id")
email String
nomeProposto String @map("nome_proposto")
regione String?
stato StatoRichiesta @default(PENDING)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([email])
@@map("richiesta_creazione_gruppo")
}
+8
View File
@@ -4,6 +4,10 @@ import { healthRouter } from './routes/health.routes';
import { gruppiRouter } from './routes/gruppi.routes'; import { gruppiRouter } from './routes/gruppi.routes';
import { invitiRouter } from './routes/inviti.routes'; import { invitiRouter } from './routes/inviti.routes';
import { membriRouter } from './routes/membri.routes'; import { membriRouter } from './routes/membri.routes';
import { linkIngressoRouter } from './routes/linkIngresso.routes';
import { richiesteIngressoRouter } from './routes/richiesteIngresso.routes';
import { richiesteCreazioneGruppoRouter } from './routes/richiesteCreazioneGruppo.routes';
import { utenteRouter } from './routes/utente.routes';
import { errorHandler } from './middleware/errorHandler'; import { errorHandler } from './middleware/errorHandler';
export const app = express(); export const app = express();
@@ -15,6 +19,10 @@ app.use(healthRouter);
app.use(gruppiRouter); app.use(gruppiRouter);
app.use(invitiRouter); app.use(invitiRouter);
app.use(membriRouter); app.use(membriRouter);
app.use(linkIngressoRouter);
app.use(richiesteIngressoRouter);
app.use(richiesteCreazioneGruppoRouter);
app.use(utenteRouter);
app.use((req, res) => { app.use((req, res) => {
res.status(404).json({ message: 'not found' }); res.status(404).json({ message: 'not found' });
@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { createGruppo } from '../services/gruppi.service'; import { createGruppo, listGruppi, listGruppiPubblico } from '../services/gruppi.service';
import { HttpError } from '../errors'; import { HttpError } from '../errors';
interface PostGruppoBody { interface PostGruppoBody {
@@ -34,9 +34,27 @@ function parseBody(body: PostGruppoBody): { nome: string; regione?: string; ruol
export async function postGruppo(req: Request, res: Response, next: NextFunction): Promise<void> { export async function postGruppo(req: Request, res: Response, next: NextFunction): Promise<void> {
try { try {
const input = parseBody(req.body ?? {}); const input = parseBody(req.body ?? {});
const result = await createGruppo({ ...input, userId: req.auth!.userId }); const result = await createGruppo(input);
res.status(201).json(result); res.status(201).json(result);
} catch (err) { } catch (err) {
next(err); next(err);
} }
} }
export async function getGruppi(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const gruppi = await listGruppi();
res.status(200).json(gruppi);
} catch (err) {
next(err);
}
}
export async function getGruppiElencoPubblico(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const gruppi = await listGruppiPubblico();
res.status(200).json(gruppi);
} catch (err) {
next(err);
}
}
@@ -20,12 +20,6 @@ function parseCreaInvitoBody(body: PostInvitoBody): { email: string; ruolo: stri
export async function postInvito(req: Request, res: Response, next: NextFunction): Promise<void> { export async function postInvito(req: Request, res: Response, next: NextFunction): Promise<void> {
try { try {
const { orgId } = req.params; const { orgId } = req.params;
// Un capo gruppo può invitare solo all'interno della propria organization.
if (req.auth?.organizationId !== orgId) {
throw new HttpError(403, "Non puoi invitare persone in un'organizzazione diversa dalla tua");
}
const { email, ruolo } = parseCreaInvitoBody(req.body ?? {}); const { email, ruolo } = parseCreaInvitoBody(req.body ?? {});
const result = await creaInvito({ orgId, email, ruolo }); const result = await creaInvito({ orgId, email, ruolo });
@@ -0,0 +1,42 @@
import { Request, Response, NextFunction } from 'express';
import { creaOTrovaLinkIngresso, getLinkIngressoPubblico, richiediIngresso } from '../services/linkIngresso.service';
import { HttpError } from '../errors';
export async function postLinkIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { orgId } = req.params;
const { giaEsistente, ...result } = await creaOTrovaLinkIngresso(orgId, req.auth!.userId);
res.status(giaEsistente ? 200 : 201).json(result);
} catch (err) {
next(err);
}
}
export async function getLinkIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { token } = req.params;
const link = await getLinkIngressoPubblico(token);
if (!link) {
throw new HttpError(404, 'Link di ingresso non trovato');
}
res.json(link);
} catch (err) {
next(err);
}
}
export async function postRichiediIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { token } = req.params;
const result = await richiediIngresso(token, {
userId: req.auth!.userId,
email: req.auth!.email,
});
res.status(201).json(result);
} catch (err) {
next(err);
}
}
@@ -1,18 +1,36 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { listaMembri, cambiaRuoloMembro, rimuoviMembro } from '../services/membri.service'; import { listaMembri, cambiaRuoloMembro, rimuoviMembro, aggiungiMembro } from '../services/membri.service';
import { HttpError } from '../errors'; import { HttpError } from '../errors';
function checkOrgAccess(req: Request): string { interface PostMembroBody {
email?: unknown;
ruolo?: unknown;
}
function parseAggiungiMembroBody(body: PostMembroBody): { email: string; ruolo: string } {
if (typeof body.email !== 'string' || body.email.trim().length === 0) {
throw new HttpError(400, "Il campo 'email' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.ruolo !== 'string' || body.ruolo.trim().length === 0) {
throw new HttpError(400, "Il campo 'ruolo' è obbligatorio ed è una stringa non vuota");
}
return { email: body.email, ruolo: body.ruolo };
}
export async function postMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { orgId } = req.params; const { orgId } = req.params;
if (req.auth?.organizationId !== orgId) { const { email, ruolo } = parseAggiungiMembroBody(req.body ?? {});
throw new HttpError(403, "Non puoi gestire membri di un'organizzazione diversa dalla tua"); const result = await aggiungiMembro({ orgId, email, ruolo });
res.status(201).json(result);
} catch (err) {
next(err);
} }
return orgId;
} }
export async function getMembri(req: Request, res: Response, next: NextFunction): Promise<void> { export async function getMembri(req: Request, res: Response, next: NextFunction): Promise<void> {
try { try {
const orgId = checkOrgAccess(req); const { orgId } = req.params;
const membri = await listaMembri(orgId); const membri = await listaMembri(orgId);
res.json(membri); res.json(membri);
} catch (err) { } catch (err) {
@@ -22,8 +40,7 @@ export async function getMembri(req: Request, res: Response, next: NextFunction)
export async function putRuoloMembro(req: Request, res: Response, next: NextFunction): Promise<void> { export async function putRuoloMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
try { try {
const orgId = checkOrgAccess(req); const { orgId, userId } = req.params;
const { userId } = req.params;
const { ruolo } = req.body ?? {}; const { ruolo } = req.body ?? {};
if (typeof ruolo !== 'string' || ruolo.trim().length === 0) { if (typeof ruolo !== 'string' || ruolo.trim().length === 0) {
@@ -39,8 +56,7 @@ export async function putRuoloMembro(req: Request, res: Response, next: NextFunc
export async function deleteMembro(req: Request, res: Response, next: NextFunction): Promise<void> { export async function deleteMembro(req: Request, res: Response, next: NextFunction): Promise<void> {
try { try {
const orgId = checkOrgAccess(req); const { orgId, userId } = req.params;
const { userId } = req.params;
await rimuoviMembro(orgId, userId); await rimuoviMembro(orgId, userId);
res.status(204).send(); res.status(204).send();
@@ -0,0 +1,74 @@
import { Request, Response, NextFunction } from 'express';
import {
creaRichiestaCreazioneGruppo,
listaRichiestePending,
approvaRichiesta,
rifiutaRichiesta,
} from '../services/richiesteCreazioneGruppo.service';
import { HttpError } from '../errors';
interface PostRichiestaBody {
nomeProposto?: unknown;
regione?: unknown;
}
function parsePostBody(body: PostRichiestaBody): { nomeProposto: string; regione?: string } {
if (typeof body.nomeProposto !== 'string' || body.nomeProposto.trim().length === 0) {
throw new HttpError(400, "Il campo 'nomeProposto' è obbligatorio ed è una stringa non vuota");
}
if (body.regione !== undefined && typeof body.regione !== 'string') {
throw new HttpError(400, "Il campo 'regione', se presente, deve essere una stringa");
}
return { nomeProposto: body.nomeProposto, regione: body.regione as string | undefined };
}
export async function postRichiestaCreazioneGruppo(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { nomeProposto, regione } = parsePostBody(req.body ?? {});
const result = await creaRichiestaCreazioneGruppo({
userId: req.auth!.userId,
email: req.auth!.email,
nomeProposto,
regione,
});
res.status(201).json(result);
} catch (err) {
next(err);
}
}
export async function getRichiesteCreazioneGruppo(_req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const richieste = await listaRichiestePending();
res.json(richieste);
} catch (err) {
next(err);
}
}
interface PutRichiestaBody {
esito?: unknown;
}
function parsePutBody(body: PutRichiestaBody): 'approvata' | 'rifiutata' {
if (body.esito !== 'approvata' && body.esito !== 'rifiutata') {
throw new HttpError(400, "Il campo 'esito' deve essere 'approvata' o 'rifiutata'");
}
return body.esito;
}
export async function putRichiestaCreazioneGruppo(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const esito = parsePutBody(req.body ?? {});
const result = esito === 'approvata' ? await approvaRichiesta(id) : await rifiutaRichiesta(id);
res.status(200).json(result);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,67 @@
import { Request, Response, NextFunction } from 'express';
import {
creaRichiestaIngresso,
listaRichiestePending,
approvaRichiesta,
rifiutaRichiesta,
ORIGINE_RICHIESTA,
} from '../services/richiesteIngresso.service';
import { HttpError } from '../errors';
export async function postRichiestaIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { orgId } = req.params;
const result = await creaRichiestaIngresso(
orgId,
{ userId: req.auth!.userId, email: req.auth!.email },
ORIGINE_RICHIESTA.PROFILO,
);
res.status(201).json(result);
} catch (err) {
next(err);
}
}
export async function getRichiesteIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { orgId } = req.params;
const richieste = await listaRichiestePending(orgId);
res.json(richieste);
} catch (err) {
next(err);
}
}
interface PutRichiestaBody {
esito?: unknown;
ruolo?: unknown;
}
function parsePutBody(body: PutRichiestaBody): { esito: 'approvata' | 'rifiutata'; ruolo?: string } {
if (body.esito !== 'approvata' && body.esito !== 'rifiutata') {
throw new HttpError(400, "Il campo 'esito' deve essere 'approvata' o 'rifiutata'");
}
if (body.esito === 'approvata') {
if (typeof body.ruolo !== 'string' || body.ruolo.trim().length === 0) {
throw new HttpError(400, "Il campo 'ruolo' è obbligatorio quando esito è 'approvata'");
}
return { esito: body.esito, ruolo: body.ruolo };
}
return { esito: body.esito };
}
export async function putRichiestaIngresso(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { orgId, id } = req.params;
const { esito, ruolo } = parsePutBody(req.body ?? {});
const result =
esito === 'approvata' ? await approvaRichiesta(orgId, id, ruolo!) : await rifiutaRichiesta(orgId, id);
res.status(200).json(result);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,55 @@
import { Request, Response, NextFunction } from 'express';
import { getProfiloUtente, aggiornaProfiloUtente, cambiaPasswordUtente } from '../services/utente.service';
import { HttpError } from '../errors';
interface PutProfiloBody {
email?: unknown;
nome?: unknown;
cognome?: unknown;
}
function parseProfiloBody(body: PutProfiloBody): { email: string; nome: string; cognome: string } {
if (typeof body.email !== 'string' || body.email.trim().length === 0) {
throw new HttpError(400, "Il campo 'email' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
}
if (typeof body.cognome !== 'string' || body.cognome.trim().length === 0) {
throw new HttpError(400, "Il campo 'cognome' è obbligatorio ed è una stringa non vuota");
}
return { email: body.email.trim(), nome: body.nome.trim(), cognome: body.cognome.trim() };
}
export async function getMe(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const profilo = await getProfiloUtente(req.auth!.userId);
res.json(profilo);
} catch (err) {
next(err);
}
}
export async function putMe(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const input = parseProfiloBody(req.body ?? {});
await aggiornaProfiloUtente(req.auth!.userId, input);
res.status(200).json(input);
} catch (err) {
next(err);
}
}
export async function putMePassword(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { password } = req.body ?? {};
if (typeof password !== 'string' || password.length < 8) {
throw new HttpError(400, "Il campo 'password' è obbligatorio e deve avere almeno 8 caratteri");
}
await cambiaPasswordUtente(req.auth!.userId, password);
res.status(204).send();
} catch (err) {
next(err);
}
}
+17 -8
View File
@@ -1,11 +1,12 @@
import { keycloakAdminHttp } from './httpClient';
import { GRUPPO_PADRE_PREFIX } from './organizations';
export async function assignUserToGroup(userId: string, groupId: string): Promise<void> { export async function assignUserToGroup(userId: string, groupId: string): Promise<void> {
// TODO: PUT {adminBaseUrl}/users/{userId}/groups/{groupId} await keycloakAdminHttp.put(`/users/${userId}/groups/${groupId}`);
throw new Error('Not implemented');
} }
export async function removeUserFromGroup(userId: string, groupId: string): Promise<void> { export async function removeUserFromGroup(userId: string, groupId: string): Promise<void> {
// TODO: DELETE {adminBaseUrl}/users/{userId}/groups/{groupId} await keycloakAdminHttp.delete(`/users/${userId}/groups/${groupId}`);
throw new Error('Not implemented');
} }
export interface UserGroup { export interface UserGroup {
@@ -14,8 +15,16 @@ export interface UserGroup {
} }
export async function getUserGroupsInOrganization(orgId: string, userId: string): Promise<UserGroup[]> { export async function getUserGroupsInOrganization(orgId: string, userId: string): Promise<UserGroup[]> {
// TODO: GET {adminBaseUrl}/users/{userId}/groups, filtrati ai gruppi che const response = await keycloakAdminHttp.get<Array<{ id: string; name: string; path: string }>>(
// appartengono all'albero dei gruppi dell'organizzazione orgId (o endpoint `/users/${userId}/groups`,
// dedicato, da verificare in base alla versione di Keycloak). );
throw new Error('Not implemented');
// I gruppi ruolo di un'organizzazione vivono tutti sotto il gruppo padre
// "org-{orgId}" (vedi organizations.ts): filtriamo per path invece che
// interrogare Keycloak org per org, perché l'endpoint utente non supporta
// un filtro nativo per organizzazione.
const prefix = `/${GRUPPO_PADRE_PREFIX}${orgId}/`;
return response.data
.filter((group) => group.path.startsWith(prefix))
.map((group) => ({ groupId: group.id, nome: group.name }));
} }
+2 -1
View File
@@ -9,4 +9,5 @@ export {
} from './organizations'; } from './organizations';
export { assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization } from './groups'; export { assignUserToGroup, removeUserFromGroup, getUserGroupsInOrganization } from './groups';
export { assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles } from './roles'; export { assignRealmRoleToUser, removeRealmRoleFromUser, getUserRealmRoles } from './roles';
export { findUserByEmail, createUser } from './users'; export { findUserByEmail, getUserById, updateUserProfile, resetUserPassword } from './users';
export type { KeycloakUserProfile, UpdateUserProfileInput } from './users';
@@ -43,7 +43,7 @@ export async function createOrganization(nome: string): Promise<{ orgId: string
// dell'organizzazione (non elencato da GET /groups, ma verificato contro // dell'organizzazione (non elencato da GET /groups, ma verificato contro
// un'istanza reale: un POST /groups con quel nome esatto risponde comunque // un'istanza reale: un POST /groups con quel nome esatto risponde comunque
// 409 "already exists"). Va quindi evitato un nome che collida con quello. // 409 "already exists"). Va quindi evitato un nome che collida con quello.
const GRUPPO_PADRE_PREFIX = 'org-'; export const GRUPPO_PADRE_PREFIX = 'org-';
export async function createOrganizationGroup(orgId: string, nomeGruppo: string): Promise<{ groupId: string }> { export async function createOrganizationGroup(orgId: string, nomeGruppo: string): Promise<{ groupId: string }> {
// Le Organizations di Keycloak 26 non hanno un concetto nativo di "gruppo // Le Organizations di Keycloak 26 non hanno un concetto nativo di "gruppo
@@ -79,9 +79,18 @@ export async function addMemberToOrganization(orgId: string, userId: string): Pr
// Verificato contro un'istanza Keycloak 26.7 reale: l'endpoint richiede // Verificato contro un'istanza Keycloak 26.7 reale: l'endpoint richiede
// Content-Type application/json con il solo id utente come stringa JSON nel // Content-Type application/json con il solo id utente come stringa JSON nel
// body (non un oggetto, e non text/plain: entrambi rispondono 415). // body (non un oggetto, e non text/plain: entrambi rispondono 415).
try {
await keycloakAdminHttp.post(`/organizations/${orgId}/members`, JSON.stringify(userId), { await keycloakAdminHttp.post(`/organizations/${orgId}/members`, JSON.stringify(userId), {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} catch (err) {
// Idempotente: un retry dopo un fallimento parziale (es. l'assegnazione
// del ruolo realm successiva fallita) non deve rompersi solo perché
// l'utente era già stato aggiunto all'org nel tentativo precedente.
if (!axios.isAxiosError(err) || err.response?.status !== 409) {
throw err;
}
}
} }
export interface OrganizationMember { export interface OrganizationMember {
+21 -12
View File
@@ -1,20 +1,29 @@
import { keycloakAdminHttp } from './httpClient';
interface RealmRoleRepresentation {
id: string;
name: string;
}
// Ruoli assegnati automaticamente da Keycloak a ogni utente (non gestiti da
// questa app): il composite "default-roles-{realm}" e i ruoli che include.
const RUOLI_DEFAULT_KEYCLOAK = new Set(['offline_access', 'uma_authorization']);
function isRuoloDefaultKeycloak(nome: string): boolean {
return RUOLI_DEFAULT_KEYCLOAK.has(nome) || nome.startsWith('default-roles-');
}
export async function assignRealmRoleToUser(userId: string, ruolo: string): Promise<void> { export async function assignRealmRoleToUser(userId: string, ruolo: string): Promise<void> {
// TODO: POST {adminBaseUrl}/users/{userId}/role-mappings/realm con il const { data: ruoloRepresentation } = await keycloakAdminHttp.get<RealmRoleRepresentation>(`/roles/${ruolo}`);
// rappresentante del ruolo (richiede prima GET {adminBaseUrl}/roles/{ruolo} await keycloakAdminHttp.post(`/users/${userId}/role-mappings/realm`, [ruoloRepresentation]);
// per ottenerne id e name).
throw new Error('Not implemented');
} }
export async function removeRealmRoleFromUser(userId: string, ruolo: string): Promise<void> { export async function removeRealmRoleFromUser(userId: string, ruolo: string): Promise<void> {
// TODO: DELETE {adminBaseUrl}/users/{userId}/role-mappings/realm con il const { data: ruoloRepresentation } = await keycloakAdminHttp.get<RealmRoleRepresentation>(`/roles/${ruolo}`);
// rappresentante del ruolo. await keycloakAdminHttp.delete(`/users/${userId}/role-mappings/realm`, { data: [ruoloRepresentation] });
throw new Error('Not implemented');
} }
export async function getUserRealmRoles(userId: string): Promise<string[]> { export async function getUserRealmRoles(userId: string): Promise<string[]> {
// TODO: GET {adminBaseUrl}/users/{userId}/role-mappings/realm. Il contratto const response = await keycloakAdminHttp.get<RealmRoleRepresentation[]>(`/users/${userId}/role-mappings/realm`);
// di questa funzione è di restituire solo i ruoli scout "custom" (es. le return response.data.map((ruolo) => ruolo.name).filter((nome) => !isRuoloDefaultKeycloak(nome));
// voci di ruoliDefault create in POST /gruppi), escludendo i ruoli di
// default di Keycloak (offline_access, uma_authorization, ecc.).
throw new Error('Not implemented');
} }
+53 -7
View File
@@ -1,13 +1,59 @@
import { keycloakAdminHttp } from './httpClient';
import { KeycloakUserSummary } from './types'; import { KeycloakUserSummary } from './types';
export async function findUserByEmail(email: string): Promise<KeycloakUserSummary | null> { export async function findUserByEmail(email: string): Promise<KeycloakUserSummary | null> {
// TODO: GET {adminBaseUrl}/users?email={email}&exact=true, restituire il const response = await keycloakAdminHttp.get<Array<{ id: string }>>('/users', {
// primo risultato mappato a { id } oppure null se l'array è vuoto. params: { email, exact: true },
throw new Error('Not implemented'); });
const [utente] = response.data;
return utente ? { id: utente.id } : null;
} }
export async function createUser(email: string, datiProfilo: object): Promise<{ userId: string }> { export interface KeycloakUserProfile {
// TODO: POST {adminBaseUrl}/users con { email, ...datiProfilo, enabled: true }, id: string;
// leggere l'id dall'header Location della risposta. email: string;
throw new Error('Not implemented'); firstName: string;
lastName: string;
}
export async function getUserById(userId: string): Promise<KeycloakUserProfile> {
const response = await keycloakAdminHttp.get<{
id: string;
email?: string;
firstName?: string;
lastName?: string;
}>(`/users/${userId}`);
return {
id: response.data.id,
email: response.data.email ?? '',
firstName: response.data.firstName ?? '',
lastName: response.data.lastName ?? '',
};
}
export interface UpdateUserProfileInput {
email: string;
firstName: string;
lastName: string;
}
// Il realm ha `registrationEmailAsUsername: true`, quindi username ed email
// devono restare sincronizzati: aggiornare l'email senza lo username
// lascerebbe l'utente con un login (username) diverso dalla nuova email.
export async function updateUserProfile(userId: string, input: UpdateUserProfileInput): Promise<void> {
await keycloakAdminHttp.put(`/users/${userId}`, {
email: input.email,
username: input.email,
firstName: input.firstName,
lastName: input.lastName,
});
}
export async function resetUserPassword(userId: string, password: string): Promise<void> {
await keycloakAdminHttp.put(`/users/${userId}/reset-password`, {
type: 'password',
value: password,
temporary: false,
});
} }
@@ -0,0 +1,20 @@
import { Request, Response, NextFunction } from 'express';
// admin opera su qualsiasi organizzazione; capo-gruppo resta
// vincolato alla propria (req.auth.organizationId deve coincidere con
// req.params.orgId).
export function requireOrgAccess(req: Request, res: Response, next: NextFunction): void {
const roles = req.auth?.roles ?? [];
if (roles.includes('admin')) {
next();
return;
}
if (roles.includes('capo-gruppo') && req.auth?.organizationId === req.params.orgId) {
next();
return;
}
res.status(403).json({ message: "Non sei autorizzato ad operare su questa organizzazione" });
}
+12 -4
View File
@@ -1,10 +1,18 @@
import { Router } from 'express'; import { Router } from 'express';
import { authenticate } from '../middleware/authenticate'; import { authenticate } from '../middleware/authenticate';
import { requireRole } from '../middleware/requireRole'; import { requireRole } from '../middleware/requireRole';
import { postGruppo } from '../controllers/gruppi.controller'; import { postGruppo, getGruppi, getGruppiElencoPubblico } from '../controllers/gruppi.controller';
export const gruppiRouter = Router(); export const gruppiRouter = Router();
// TODO: "admin-centrale" è temporaneo, in attesa di definire i ruoli reali // "admin" è la porta d'accesso diretta e definitiva a queste due route: un
// abilitati alla creazione di un nuovo gruppo scout. // utente normale non le chiama mai a mano, ma passa dal flusso self-service
gruppiRouter.post('/gruppi', authenticate, requireRole('admin-centrale'), postGruppo); // di richiesta creazione gruppo (POST /richieste-creazione-gruppo), che alla
// review positiva invoca createGruppo() internamente (vedi
// richiesteCreazioneGruppo.service.ts:98), bypassando questo controllo.
gruppiRouter.post('/gruppi', authenticate, requireRole('admin'), postGruppo);
gruppiRouter.get('/gruppi', authenticate, requireRole('admin'), getGruppi);
// Elenco minimale (orgId/nome) aperto a qualsiasi utente autenticato, usato
// dal percorso "richiedi di entrare in un gruppo esistente" del profilo.
gruppiRouter.get('/gruppi/elenco-pubblico', authenticate, getGruppiElencoPubblico);
+2 -2
View File
@@ -1,11 +1,11 @@
import { Router } from 'express'; import { Router } from 'express';
import { authenticate } from '../middleware/authenticate'; import { authenticate } from '../middleware/authenticate';
import { requireRole } from '../middleware/requireRole'; import { requireOrgAccess } from '../middleware/requireOrgAccess';
import { postInvito, getInvito, postAccettaInvito } from '../controllers/inviti.controller'; import { postInvito, getInvito, postAccettaInvito } from '../controllers/inviti.controller';
export const invitiRouter = Router(); export const invitiRouter = Router();
invitiRouter.post('/gruppi/:orgId/inviti', authenticate, requireRole('capo-gruppo'), postInvito); invitiRouter.post('/gruppi/:orgId/inviti', authenticate, requireOrgAccess, postInvito);
invitiRouter.get('/inviti/:token', getInvito); invitiRouter.get('/inviti/:token', getInvito);

Some files were not shown because too many files have changed in this diff Show More