Compare commits
10
Commits
6a55cfa991
...
91f404ac96
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f404ac96 | ||
|
|
0fdf395733 | ||
|
|
de921abcd6 | ||
|
|
8d1a3e0d18 | ||
|
|
5855352c8c | ||
|
|
1326a3c887 | ||
|
|
a4cca3f2c1 | ||
|
|
7cd0a1ff1b | ||
|
|
5b7c82d359 | ||
|
|
ac92ca43ce |
+29
-14
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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`)
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
+44
@@ -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;
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,56 +82,55 @@ 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([]),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
effect(() => {
|
this.autocompleteQuery(
|
||||||
const query = this.categoriaQuery();
|
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([]),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
effect(() => {
|
this.autocompleteQuery(
|
||||||
const query = this.periodoQuery();
|
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([]),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
effect(() => {
|
this.autocompleteQuery(
|
||||||
const query = this.materialeQuery();
|
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)),
|
|
||||||
error: () => this.materialeSuggestions.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.
|
||||||
|
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(
|
||||||
risultati: { id: number | null; nome: string | null }[],
|
risultati: { id: number | null; nome: string | null }[],
|
||||||
esclusi: Set<number>
|
esclusi: Set<number>
|
||||||
@@ -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(() => {
|
||||||
{ path: '/', label: 'Home' },
|
this.keycloakEvent();
|
||||||
{ path: '/ricerca', label: 'Cerca' },
|
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
|
||||||
{ path: '/mie-attivita', label: 'Le mie attività' },
|
});
|
||||||
{ path: '/profilo', label: 'Profilo' },
|
|
||||||
];
|
readonly navItems = computed<NavItem[]>(() => {
|
||||||
|
const items: NavItem[] = [
|
||||||
|
{ path: '/', label: 'Home' },
|
||||||
|
{ path: '/ricerca', label: 'Cerca' },
|
||||||
|
{ path: '/mie-attivita', label: 'Le mie attività' },
|
||||||
|
];
|
||||||
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -13,7 +13,9 @@ model GruppoScout {
|
|||||||
regione String?
|
regione String?
|
||||||
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
const { orgId } = req.params;
|
email?: unknown;
|
||||||
if (req.auth?.organizationId !== orgId) {
|
ruolo?: unknown;
|
||||||
throw new HttpError(403, "Non puoi gestire membri di un'organizzazione diversa dalla tua");
|
}
|
||||||
|
|
||||||
|
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 { email, ruolo } = parseAggiungiMembroBody(req.body ?? {});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
await keycloakAdminHttp.post(`/organizations/${orgId}/members`, JSON.stringify(userId), {
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
await keycloakAdminHttp.post(`/organizations/${orgId}/members`, JSON.stringify(userId), {
|
||||||
});
|
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 {
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user