Compare commits
8 Commits
611c554490
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
271acacb1d | ||
|
|
f8206b5840 | ||
|
|
96b0dc76aa | ||
|
|
6a07c3e922 | ||
|
|
032f6a08df | ||
|
|
fe73662903 | ||
|
|
cc4e174880 | ||
|
|
34a8ebc6fb |
21
.env.example
21
.env.example
@@ -30,21 +30,18 @@ LOG_LEVEL=debug # warning/error in produzione
|
|||||||
# Usati sia da Laravel sia dal container mysql (mappati nel compose).
|
# Usati sia da Laravel sia dal container mysql (mappati nel compose).
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=db # nome del servizio docker
|
DB_HOST=db # nome del servizio docker
|
||||||
DB_PORT=3306
|
DB_PORT=3306 # porta di connessione INTERNA (mysql nel container)
|
||||||
|
DB_HOST_PORT=3310 # porta pubblicata sull'host; ≠ 3306 per non collidere
|
||||||
DB_DATABASE=dyncoll
|
DB_DATABASE=dyncoll
|
||||||
DB_USERNAME=dyncoll
|
DB_USERNAME=dyncoll
|
||||||
DB_PASSWORD= # segreto: compilare
|
DB_PASSWORD= # segreto: compilare
|
||||||
DB_ROOT_PASSWORD= # segreto: solo per il container mysql (root)
|
DB_ROOT_PASSWORD= # segreto: solo per il container mysql (root)
|
||||||
|
|
||||||
# --- Database legacy (v1) — sorgente per l'ETL `php artisan v1:import` --------
|
# NB: le variabili DB_LEGACY_* (sorgente in SOLA LETTURA per l'ETL
|
||||||
# Connessione in SOLA LETTURA verso il MySQL di dyncoll.v1.
|
# `php artisan v1:import`) NON stanno qui di proposito: servono solo durante la
|
||||||
# Attiva solo durante l'import; richiede rete docker condivisa o tunnel.
|
# migrazione una-tantum da dyncoll.v1, non nella configurazione standard. Al
|
||||||
DB_LEGACY_CONNECTION=mysql
|
# momento del cutover aggiungerle a mano al .env con i valori corretti
|
||||||
DB_LEGACY_HOST=dyncoll_v1_db # container/host del DB v1
|
# dell'ambiente (host/porta/credenziali possono differire dal locale).
|
||||||
DB_LEGACY_PORT=3306
|
|
||||||
DB_LEGACY_DATABASE=lund
|
|
||||||
DB_LEGACY_USERNAME=readonly
|
|
||||||
DB_LEGACY_PASSWORD= # segreto: compilare
|
|
||||||
|
|
||||||
# --- Redis (cache, sessioni, code, Horizon) ----------------------------------
|
# --- Redis (cache, sessioni, code, Horizon) ----------------------------------
|
||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
@@ -62,6 +59,10 @@ FILESYSTEM_DISK=local
|
|||||||
# --- Autenticazione (Sanctum / Fortify) --------------------------------------
|
# --- Autenticazione (Sanctum / Fortify) --------------------------------------
|
||||||
SANCTUM_STATEFUL_DOMAINS=dyncoll-dev.local,localhost,localhost:8080
|
SANCTUM_STATEFUL_DOMAINS=dyncoll-dev.local,localhost,localhost:8080
|
||||||
SESSION_DOMAIN=.dyncoll-dev.local
|
SESSION_DOMAIN=.dyncoll-dev.local
|
||||||
|
# Sicurezza del cookie di sessione (app servita in HTTPS via Traefik).
|
||||||
|
SESSION_SECURE_COOKIE=true
|
||||||
|
SESSION_SAME_SITE=lax
|
||||||
|
SESSION_HTTP_ONLY=true
|
||||||
|
|
||||||
# --- Mail (SMTP) -------------------------------------------------------------
|
# --- Mail (SMTP) -------------------------------------------------------------
|
||||||
# DEV: Mailpit (servizio nel docker-compose.override.yml). UI: http://localhost:8025
|
# DEV: Mailpit (servizio nel docker-compose.override.yml). UI: http://localhost:8025
|
||||||
|
|||||||
54
.github/workflows/docs.yml
vendored
Normal file
54
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Builds the MkDocs documentation and publishes it to GitHub Pages using the
|
||||||
|
# native Pages flow (no gh-pages branch). Runs on every push to main that
|
||||||
|
# touches the docs.
|
||||||
|
#
|
||||||
|
# One-time repository setup: Settings → Pages → Source: "GitHub Actions".
|
||||||
|
name: docs
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "docs/**"
|
||||||
|
- "mkdocs.yml"
|
||||||
|
- ".github/workflows/docs.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
|
||||||
|
# Pinned to the version served by the local `docs` compose service
|
||||||
|
# (squidfunk/mkdocs-material): keep the two in sync when upgrading.
|
||||||
|
- run: pip install mkdocs-material==9.7.6
|
||||||
|
|
||||||
|
- run: mkdocs build --strict
|
||||||
|
|
||||||
|
- uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: site
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -25,7 +25,7 @@ nazionale svedese.
|
|||||||
| OpenAPI | **scegliere UNO** tra `darkaonline/l5-swagger` o `dedoc/scramble` (ora ci sono entrambi) |
|
| OpenAPI | **scegliere UNO** tra `darkaonline/l5-swagger` o `dedoc/scramble` (ora ci sono entrambi) |
|
||||||
| Linked Art | `ml/json-ld` (serializzazione) + `opis/json-schema` (validazione contro gli schema RAÄ) |
|
| Linked Art | `ml/json-ld` (serializzazione) + `opis/json-schema` (validazione contro gli schema RAÄ) |
|
||||||
| Frontend | Vite + TypeScript, MPA con isole, Tailwind v4 + daisyUI, Leaflet |
|
| Frontend | Vite + TypeScript, MPA con isole, Tailwind v4 + daisyUI, Leaflet |
|
||||||
| Docs | MkDocs Material (`documentation/`) |
|
| Docs | MkDocs Material (`docs/` + `mkdocs.yml` alla root; publish via GitHub Pages, `.github/workflows/docs.yml`) |
|
||||||
| Qualità | SonarQube locale (`http://sonar.local`) |
|
| Qualità | SonarQube locale (`http://sonar.local`) |
|
||||||
|
|
||||||
## 3. Architettura dati (decisione chiave)
|
## 3. Architettura dati (decisione chiave)
|
||||||
@@ -115,13 +115,15 @@ Motivi e asimmetria backend/frontend:
|
|||||||
Usare i target del `Makefile` (incapsulano la strategia host/container del §6bis):
|
Usare i target del `Makefile` (incapsulano la strategia host/container del §6bis):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up # avvia l'intero stack dev (Vite HMR + backend + db + redis + docs + mailpit)
|
make up # avvia l'intero stack dev (Vite HMR + backend + db + redis + mailpit)
|
||||||
make migrate # artisan migrate (container come UID host)
|
make migrate # artisan migrate (container come UID host)
|
||||||
make import # ETL da v1 (quando implementato)
|
make import # ETL da v1 (quando implementato)
|
||||||
make artisan c="test --coverage-clover=coverage.xml" # test backend
|
make artisan c="test --coverage-clover=coverage.xml" # test backend
|
||||||
make be-update # composer update (host)
|
make be-update # composer update (host)
|
||||||
make fe-install # npm install host (package-lock + type-check IDE)
|
make fe-install # npm install host (package-lock + type-check IDE)
|
||||||
make fe-rebuild # ricostruisce l'immagine frontend dopo modifiche a package.json
|
make fe-rebuild # ricostruisce l'immagine frontend dopo modifiche a package.json
|
||||||
|
make docs # anteprima MkDocs (http://localhost:8001/adc/) — publish: GitHub Pages
|
||||||
|
make docs-check # build --strict della doc, come in CI (prima del push)
|
||||||
make permissions # fix permessi storage (una tantum, sudo)
|
make permissions # fix permessi storage (una tantum, sudo)
|
||||||
make help # elenco completo
|
make help # elenco completo
|
||||||
```
|
```
|
||||||
|
|||||||
121
LICENSES/CC0-1.0.txt
Normal file
121
LICENSES/CC0-1.0.txt
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
Creative Commons Legal Code
|
||||||
|
|
||||||
|
CC0 1.0 Universal
|
||||||
|
|
||||||
|
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
||||||
|
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
|
||||||
|
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
||||||
|
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
||||||
|
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
|
||||||
|
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
|
||||||
|
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
|
||||||
|
HEREUNDER.
|
||||||
|
|
||||||
|
Statement of Purpose
|
||||||
|
|
||||||
|
The laws of most jurisdictions throughout the world automatically confer
|
||||||
|
exclusive Copyright and Related Rights (defined below) upon the creator
|
||||||
|
and subsequent owner(s) (each and all, an "owner") of an original work of
|
||||||
|
authorship and/or a database (each, a "Work").
|
||||||
|
|
||||||
|
Certain owners wish to permanently relinquish those rights to a Work for
|
||||||
|
the purpose of contributing to a commons of creative, cultural and
|
||||||
|
scientific works ("Commons") that the public can reliably and without fear
|
||||||
|
of later claims of infringement build upon, modify, incorporate in other
|
||||||
|
works, reuse and redistribute as freely as possible in any form whatsoever
|
||||||
|
and for any purposes, including without limitation commercial purposes.
|
||||||
|
These owners may contribute to the Commons to promote the ideal of a free
|
||||||
|
culture and the further production of creative, cultural and scientific
|
||||||
|
works, or to gain reputation or greater distribution for their Work in
|
||||||
|
part through the use and efforts of others.
|
||||||
|
|
||||||
|
For these and/or other purposes and motivations, and without any
|
||||||
|
expectation of additional consideration or compensation, the person
|
||||||
|
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
|
||||||
|
is an owner of Copyright and Related Rights in the Work, voluntarily
|
||||||
|
elects to apply CC0 to the Work and publicly distribute the Work under its
|
||||||
|
terms, with knowledge of his or her Copyright and Related Rights in the
|
||||||
|
Work and the meaning and intended legal effect of CC0 on those rights.
|
||||||
|
|
||||||
|
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||||
|
protected by copyright and related or neighboring rights ("Copyright and
|
||||||
|
Related Rights"). Copyright and Related Rights include, but are not
|
||||||
|
limited to, the following:
|
||||||
|
|
||||||
|
i. the right to reproduce, adapt, distribute, perform, display,
|
||||||
|
communicate, and translate a Work;
|
||||||
|
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||||
|
iii. publicity and privacy rights pertaining to a person's image or
|
||||||
|
likeness depicted in a Work;
|
||||||
|
iv. rights protecting against unfair competition in regards to a Work,
|
||||||
|
subject to the limitations in paragraph 4(a), below;
|
||||||
|
v. rights protecting the extraction, dissemination, use and reuse of data
|
||||||
|
in a Work;
|
||||||
|
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||||
|
European Parliament and of the Council of 11 March 1996 on the legal
|
||||||
|
protection of databases, and under any national implementation
|
||||||
|
thereof, including any amended or successor version of such
|
||||||
|
directive); and
|
||||||
|
vii. other similar, equivalent or corresponding rights throughout the
|
||||||
|
world based on applicable law or treaty, and any national
|
||||||
|
implementations thereof.
|
||||||
|
|
||||||
|
2. Waiver. To the greatest extent permitted by, but not in contravention
|
||||||
|
of, applicable law, Affirmer hereby overtly, fully, permanently,
|
||||||
|
irrevocably and unconditionally waives, abandons, and surrenders all of
|
||||||
|
Affirmer's Copyright and Related Rights and associated claims and causes
|
||||||
|
of action, whether now known or unknown (including existing as well as
|
||||||
|
future claims and causes of action), in the Work (i) in all territories
|
||||||
|
worldwide, (ii) for the maximum duration provided by applicable law or
|
||||||
|
treaty (including future time extensions), (iii) in any current or future
|
||||||
|
medium and for any number of copies, and (iv) for any purpose whatsoever,
|
||||||
|
including without limitation commercial, advertising or promotional
|
||||||
|
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
|
||||||
|
member of the public at large and to the detriment of Affirmer's heirs and
|
||||||
|
successors, fully intending that such Waiver shall not be subject to
|
||||||
|
revocation, rescission, cancellation, termination, or any other legal or
|
||||||
|
equitable action to disrupt the quiet enjoyment of the Work by the public
|
||||||
|
as contemplated by Affirmer's express Statement of Purpose.
|
||||||
|
|
||||||
|
3. Public License Fallback. Should any part of the Waiver for any reason
|
||||||
|
be judged legally invalid or ineffective under applicable law, then the
|
||||||
|
Waiver shall be preserved to the maximum extent permitted taking into
|
||||||
|
account Affirmer's express Statement of Purpose. In addition, to the
|
||||||
|
extent the Waiver is so judged Affirmer hereby grants to each affected
|
||||||
|
person a royalty-free, non transferable, non sublicensable, non exclusive,
|
||||||
|
irrevocable and unconditional license to exercise Affirmer's Copyright and
|
||||||
|
Related Rights in the Work (i) in all territories worldwide, (ii) for the
|
||||||
|
maximum duration provided by applicable law or treaty (including future
|
||||||
|
time extensions), (iii) in any current or future medium and for any number
|
||||||
|
of copies, and (iv) for any purpose whatsoever, including without
|
||||||
|
limitation commercial, advertising or promotional purposes (the
|
||||||
|
"License"). The License shall be deemed effective as of the date CC0 was
|
||||||
|
applied by Affirmer to the Work. Should any part of the License for any
|
||||||
|
reason be judged legally invalid or ineffective under applicable law, such
|
||||||
|
partial invalidity or ineffectiveness shall not invalidate the remainder
|
||||||
|
of the License, and in such case Affirmer hereby affirms that he or she
|
||||||
|
will not (i) exercise any of his or her remaining Copyright and Related
|
||||||
|
Rights in the Work or (ii) assert any associated claims and causes of
|
||||||
|
action with respect to the Work, in either case contrary to Affirmer's
|
||||||
|
express Statement of Purpose.
|
||||||
|
|
||||||
|
4. Limitations and Disclaimers.
|
||||||
|
|
||||||
|
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||||
|
surrendered, licensed or otherwise affected by this document.
|
||||||
|
b. Affirmer offers the Work as-is and makes no representations or
|
||||||
|
warranties of any kind concerning the Work, express, implied,
|
||||||
|
statutory or otherwise, including without limitation warranties of
|
||||||
|
title, merchantability, fitness for a particular purpose, non
|
||||||
|
infringement, or the absence of latent or other defects, accuracy, or
|
||||||
|
the present or absence of errors, whether or not discoverable, all to
|
||||||
|
the greatest extent permissible under applicable law.
|
||||||
|
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||||
|
that may apply to the Work or any use thereof, including without
|
||||||
|
limitation any person's Copyright and Related Rights in the Work.
|
||||||
|
Further, Affirmer disclaims responsibility for obtaining any necessary
|
||||||
|
consents, permissions or other rights required for any use of the
|
||||||
|
Work.
|
||||||
|
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||||
|
party to this document and has no duty or obligation with respect to
|
||||||
|
this CC0 or use of the Work.
|
||||||
31
Makefile
31
Makefile
@@ -12,13 +12,17 @@ DC := docker compose
|
|||||||
EXEC := $(DC) exec -u $(UID):$(GID) backend
|
EXEC := $(DC) exec -u $(UID):$(GID) backend
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help up down build logs composer be-install be-update artisan migrate import tinker fe-install fe-add fe-rebuild fe-dev fe-build permissions
|
.PHONY: help up down build logs composer be-install be-update artisan migrate import tinker test fe-install fe-add fe-rebuild fe-dev fe-build fe-lint-css fe-lint-css-fix docs docs-check permissions
|
||||||
|
|
||||||
|
# Stessa versione pinnata nella CI (.github/workflows/docs.yml): allineare insieme.
|
||||||
|
MKDOCS_IMG := squidfunk/mkdocs-material:9.7.6
|
||||||
|
MKDOCS_VOL := -v $(CURDIR)/mkdocs.yml:/docs/mkdocs.yml:ro -v $(CURDIR)/docs:/docs/docs:ro
|
||||||
|
|
||||||
help: ## Mostra questo aiuto
|
help: ## Mostra questo aiuto
|
||||||
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}'
|
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}'
|
||||||
|
|
||||||
## --- Stack ---
|
## --- Stack ---
|
||||||
up: ## Avvia l'intero stack dev: Vite HMR + backend(serve) + db + redis + docs + mailpit
|
up: ## Avvia l'intero stack dev: Vite HMR + backend(serve) + db + redis + mailpit
|
||||||
$(DC) up -d
|
$(DC) up -d
|
||||||
down: ## Ferma lo stack
|
down: ## Ferma lo stack
|
||||||
$(DC) down
|
$(DC) down
|
||||||
@@ -42,6 +46,20 @@ import: ## ETL da v1 (container)
|
|||||||
$(EXEC) php artisan v1:import
|
$(EXEC) php artisan v1:import
|
||||||
tinker: ## REPL artisan (container)
|
tinker: ## REPL artisan (container)
|
||||||
$(EXEC) php artisan tinker
|
$(EXEC) php artisan tinker
|
||||||
|
test: ## Suite di test sul DB dedicato db-test (es: make test c="--filter=AuthTest")
|
||||||
|
# Le -e iniettano VERE env nel processo di test → finiscono in $$_SERVER, che
|
||||||
|
# l'env() di Laravel legge per primo. Così vincono sul container (DB_HOST=db,
|
||||||
|
# DB_DATABASE=dyncoll) SENZA dipendere dai force di phpunit.xml. DB_HOST=db-test
|
||||||
|
# = isolamento fisico: la suite non può nemmeno raggiungere il db di sviluppo.
|
||||||
|
$(DC) exec -u $(UID):$(GID) \
|
||||||
|
-e APP_ENV=testing \
|
||||||
|
-e DB_HOST=db-test \
|
||||||
|
-e DB_DATABASE=dyncoll_test \
|
||||||
|
-e CACHE_STORE=array \
|
||||||
|
-e SESSION_DRIVER=array \
|
||||||
|
-e QUEUE_CONNECTION=sync \
|
||||||
|
-e MAIL_MAILER=array \
|
||||||
|
backend php artisan test $(c)
|
||||||
|
|
||||||
## --- Frontend / Node ---
|
## --- Frontend / Node ---
|
||||||
# In dev Vite gira NEL container (make up): HMR via bind-mount, node_modules musl
|
# In dev Vite gira NEL container (make up): HMR via bind-mount, node_modules musl
|
||||||
@@ -58,6 +76,15 @@ fe-dev: ## ALTERNATIVA: vite dev server su host senza Docker (conflitto porta 51
|
|||||||
cd frontend && npm run dev
|
cd frontend && npm run dev
|
||||||
fe-build: ## build di produzione su host (smoke test locale)
|
fe-build: ## build di produzione su host (smoke test locale)
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
|
fe-lint-css: ## Stylelint (check) nel container — gira anche nel pre-commit
|
||||||
|
$(DC) exec -T frontend npm run lint:css
|
||||||
|
fe-lint-css-fix: ## Stylelint con --fix (container come UID host: scrive sul bind-mount)
|
||||||
|
$(DC) exec -u $(UID):$(GID) -T frontend npm run lint:css:fix
|
||||||
|
## --- Documentazione (pubblicata da GitHub Pages, .github/workflows/docs.yml) ---
|
||||||
|
docs: ## Anteprima locale MkDocs con live reload su http://localhost:8001/adc/ (Ctrl+C per uscire; la 8000 è del backend)
|
||||||
|
docker run --rm -p 8001:8000 $(MKDOCS_VOL) $(MKDOCS_IMG) serve --dev-addr=0.0.0.0:8000
|
||||||
|
docs-check: ## Build --strict come in CI (fallisce su link rotti): da lanciare prima del push
|
||||||
|
docker run --rm $(MKDOCS_VOL) $(MKDOCS_IMG) build --strict --site-dir /tmp/site
|
||||||
|
|
||||||
## --- Manutenzione ---
|
## --- Manutenzione ---
|
||||||
permissions: ## Permessi storage scrivibili da php-fpm (www-data) — richiede sudo, una tantum
|
permissions: ## Permessi storage scrivibili da php-fpm (www-data) — richiede sudo, una tantum
|
||||||
|
|||||||
15
REUSE.toml
15
REUSE.toml
@@ -15,7 +15,7 @@ SPDX-License-Identifier = "EUPL-1.2"
|
|||||||
|
|
||||||
# Documentazione utente (sito MkDocs): CC-BY-4.0.
|
# Documentazione utente (sito MkDocs): CC-BY-4.0.
|
||||||
[[annotations]]
|
[[annotations]]
|
||||||
path = "documentation/**"
|
path = ["docs/**", "mkdocs.yml"]
|
||||||
precedence = "override"
|
precedence = "override"
|
||||||
SPDX-FileCopyrightText = "2026 Giuseppe Naponiello"
|
SPDX-FileCopyrightText = "2026 Giuseppe Naponiello"
|
||||||
SPDX-License-Identifier = "CC-BY-4.0"
|
SPDX-License-Identifier = "CC-BY-4.0"
|
||||||
@@ -27,3 +27,16 @@ path = "frontend/public/vendor/3dhop/**"
|
|||||||
precedence = "override"
|
precedence = "override"
|
||||||
SPDX-FileCopyrightText = "2014-2020 Visual Computing Lab, ISTI - CNR"
|
SPDX-FileCopyrightText = "2014-2020 Visual Computing Lab, ISTI - CNR"
|
||||||
SPDX-License-Identifier = "GPL-3.0-or-later"
|
SPDX-License-Identifier = "GPL-3.0-or-later"
|
||||||
|
|
||||||
|
# Testi delle licenze offerti in download insieme ai media (colonna `file` della
|
||||||
|
# tabella `licenses`, vedi LicenseSeeder), serviti al frontend via storage:link.
|
||||||
|
# NON sono le licenze del repository: non possono stare in LICENSES/ (un testo
|
||||||
|
# non referenziato dal codice fa fallire `reuse lint` come "unused license").
|
||||||
|
# Creative Commons dedica il testo delle proprie licenze al pubblico dominio (CC0):
|
||||||
|
# https://creativecommons.org/policies/#license — da qui l'annotazione, che a sua
|
||||||
|
# volta richiede LICENSES/CC0-1.0.txt nella root.
|
||||||
|
[[annotations]]
|
||||||
|
path = "backend/storage/app/public/licenses/**"
|
||||||
|
precedence = "override"
|
||||||
|
SPDX-FileCopyrightText = "Creative Commons Corporation"
|
||||||
|
SPDX-License-Identifier = "CC0-1.0"
|
||||||
|
|||||||
32
backend/app/Actions/Fortify/AuthenticateUser.php
Normal file
32
backend/app/Actions/Fortify/AuthenticateUser.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login custom: oltre a verificare le credenziali, blocca gli utenti con
|
||||||
|
* `disabled_at` impostato (es. legacy `is_active=false`). I soft-deleted sono
|
||||||
|
* già esclusi dalla query di default (scope SoftDeletes sul model User), non
|
||||||
|
* serve un controllo esplicito.
|
||||||
|
*
|
||||||
|
* Registrata via `Fortify::authenticateUsing()`: viene consultata sia dal
|
||||||
|
* pre-check 2FA (`RedirectIfTwoFactorAuthenticatable`) sia dal fallback
|
||||||
|
* (`AttemptToAuthenticate`), quindi un solo punto di applicazione del gate.
|
||||||
|
*/
|
||||||
|
class AuthenticateUser
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): ?User
|
||||||
|
{
|
||||||
|
$user = User::where(Fortify::username(), $request->input(Fortify::username()))->first();
|
||||||
|
|
||||||
|
if (! $user || ! Hash::check((string) $request->input('password'), $user->password)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->disabled_at === null ? $user : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
backend/app/Actions/Fortify/CreateNewUser.php
Normal file
43
backend/app/Actions/Fortify/CreateNewUser.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
|
|
||||||
|
class CreateNewUser implements CreatesNewUsers
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and create a newly registered user.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function create(array $input): User
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(User::class),
|
||||||
|
],
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
return User::create([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'password' => Hash::make($input['password']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/app/Actions/Fortify/PasswordValidationRules.php
Normal file
19
backend/app/Actions/Fortify/PasswordValidationRules.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
trait PasswordValidationRules
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate passwords.
|
||||||
|
*
|
||||||
|
* @return array<int, Rule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function passwordRules(): array
|
||||||
|
{
|
||||||
|
return ['required', 'string', Password::default(), 'confirmed'];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/app/Actions/Fortify/ResetUserPassword.php
Normal file
32
backend/app/Actions/Fortify/ResetUserPassword.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||||
|
|
||||||
|
class ResetUserPassword implements ResetsUserPasswords
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and reset the user's forgotten password.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function reset(User $user, array $input): void
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($input['password']),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
backend/app/Actions/Fortify/UpdateUserPassword.php
Normal file
35
backend/app/Actions/Fortify/UpdateUserPassword.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||||
|
|
||||||
|
class UpdateUserPassword implements UpdatesUserPasswords
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and update the user's password.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function update(User $user, array $input): void
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'current_password' => ['required', 'string', 'current_password:web'],
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
], [
|
||||||
|
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||||
|
])->validateWithBag('updatePassword');
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($input['password']),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
61
backend/app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
61
backend/app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||||
|
|
||||||
|
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate and update the given user's profile information.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function update(User $user, array $input): void
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users')->ignore($user->id),
|
||||||
|
],
|
||||||
|
])->validateWithBag('updateProfileInformation');
|
||||||
|
|
||||||
|
if ($input['email'] !== $user->email &&
|
||||||
|
$user instanceof MustVerifyEmail) {
|
||||||
|
$this->updateVerifiedUser($user, $input);
|
||||||
|
} else {
|
||||||
|
$user->forceFill([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the given verified user's profile information.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*/
|
||||||
|
protected function updateVerifiedUser(User $user, array $input): void
|
||||||
|
{
|
||||||
|
$user->forceFill([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'email_verified_at' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$user->sendEmailVerificationNotification();
|
||||||
|
}
|
||||||
|
}
|
||||||
82
backend/app/Actions/PurgeUserAction.php
Normal file
82
backend/app/Actions/PurgeUserAction.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Exceptions\CannotDeleteSystemUserException;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellazione definitiva di un utente conforme al GDPR (diritto all'oblio).
|
||||||
|
*
|
||||||
|
* Non rimuove la riga (va preservata l'integrità referenziale verso i contenuti
|
||||||
|
* e la traccia di audit): i contenuti posseduti vengono riassegnati all'utente
|
||||||
|
* di sistema e i dati personali anonimizzati in-place. Il record resta
|
||||||
|
* soft-deleted ma, marcato `anonymized_at`, esce dal cestino e dalle liste.
|
||||||
|
*
|
||||||
|
* Idempotente: un utente già anonimizzato viene ignorato.
|
||||||
|
*/
|
||||||
|
class PurgeUserAction
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Modelli posseduti (con colonna `user_id`) da riassegnare all'utente di
|
||||||
|
* sistema prima dell'anonimizzazione. Aggiungere qui i modelli di contenuto
|
||||||
|
* man mano che vengono creati (devono usare SoftDeletes).
|
||||||
|
*
|
||||||
|
* @var list<class-string>
|
||||||
|
*/
|
||||||
|
private const OWNED_MODELS = [];
|
||||||
|
|
||||||
|
public function execute(User $user): void
|
||||||
|
{
|
||||||
|
if ($user->is_system) {
|
||||||
|
throw CannotDeleteSystemUserException::make();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->anonymized_at !== null) {
|
||||||
|
return; // già anonimizzato: idempotente
|
||||||
|
}
|
||||||
|
|
||||||
|
$system = User::where('is_system', true)->firstOrFail();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($user, $system) {
|
||||||
|
$this->reassignContent($user, $system);
|
||||||
|
$this->anonymize($user);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Riassegna all'utente di sistema tutti i contenuti posseduti, inclusi
|
||||||
|
* quelli a loro volta nel cestino.
|
||||||
|
*/
|
||||||
|
private function reassignContent(User $user, User $system): void
|
||||||
|
{
|
||||||
|
foreach (self::OWNED_MODELS as $model) {
|
||||||
|
$model::withTrashed()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->update(['user_id' => $system->id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Azzera i dati personali mantenendo la riga per l'integrità referenziale.
|
||||||
|
*/
|
||||||
|
private function anonymize(User $user): void
|
||||||
|
{
|
||||||
|
$user->forceFill([
|
||||||
|
'name' => 'Deleted user',
|
||||||
|
'email' => "deleted-{$user->id}@anonymized.invalid",
|
||||||
|
'password' => Hash::make(Str::random(60)),
|
||||||
|
'two_factor_secret' => null,
|
||||||
|
'two_factor_recovery_codes' => null,
|
||||||
|
'two_factor_confirmed_at' => null,
|
||||||
|
'two_factor_setup_completed_at' => null,
|
||||||
|
'email_verified_at' => null,
|
||||||
|
'remember_token' => null,
|
||||||
|
'must_change_password' => false,
|
||||||
|
'anonymized_at' => now(),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/app/Console/Commands/PurgeAnonymizableUsers.php
Normal file
54
backend/app/Console/Commands/PurgeAnonymizableUsers.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Actions\PurgeUserAction;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonimizza (GDPR) gli utenti soft-deleted oltre la retention, riassegnandone
|
||||||
|
* i contenuti all'utente di sistema. Idempotente, pensato per esecuzione
|
||||||
|
* schedulata giornaliera (vedi routes/console.php).
|
||||||
|
*/
|
||||||
|
class PurgeAnonymizableUsers extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'users:purge
|
||||||
|
{--days=30 : Days kept in the trash before anonymization}
|
||||||
|
{--dry-run : Show the affected users without anonymizing}';
|
||||||
|
|
||||||
|
protected $description = 'Anonymize users deleted beyond the retention window (GDPR right to be forgotten)';
|
||||||
|
|
||||||
|
public function handle(PurgeUserAction $purge): int
|
||||||
|
{
|
||||||
|
$days = (int) $this->option('days');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
|
||||||
|
$users = User::onlyTrashed()
|
||||||
|
->where('is_system', false)
|
||||||
|
->whereNull('anonymized_at')
|
||||||
|
->where('deleted_at', '<=', now()->subDays($days))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($users->isEmpty()) {
|
||||||
|
$this->info('No users to anonymize.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->line(sprintf(' [dry-run] #%d %s', $user->id, $user->email));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$purge->execute($user);
|
||||||
|
$this->line(sprintf(' ✓ anonymized user #%d', $user->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(($dryRun ? '[dry-run] ' : '').'Processed users: '.$users->count());
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/app/Console/Commands/V1ImportCommand.php
Normal file
91
backend/app/Console/Commands/V1ImportCommand.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Etl\Contracts\Importer;
|
||||||
|
use App\Etl\Importers\InstitutionImporter;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ETL idempotente dal DB legacy (v1) allo schema v2.
|
||||||
|
*
|
||||||
|
* Orchestra gli importer in ORDINE DI DIPENDENZA (institutions prima delle entità
|
||||||
|
* che le referenziano, es. artifacts). Ogni importer legge dalla connessione
|
||||||
|
* `legacy` e scrive su quella di default. Serve sia per i refresh di sviluppo
|
||||||
|
* sia per la migrazione di cutover.
|
||||||
|
*/
|
||||||
|
class V1ImportCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'v1:import
|
||||||
|
{--only=* : Limit to specific importers (e.g. --only=institutions)}
|
||||||
|
{--dry-run : Read and count without writing}';
|
||||||
|
|
||||||
|
protected $description = 'Import data from the legacy (v1) database into the v2 schema (idempotent)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importer in ordine di dipendenza.
|
||||||
|
*
|
||||||
|
* @var list<class-string<Importer>>
|
||||||
|
*/
|
||||||
|
private array $importers = [
|
||||||
|
InstitutionImporter::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
|
$only = array_map('strtolower', (array) $this->option('only'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::connection('legacy')->getPdo();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error('Legacy connection unavailable: '.$e->getMessage());
|
||||||
|
$this->line('Set DB_LEGACY_* in the environment (see config/database.php → connections.legacy).');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY-RUN: no data will be written.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hadError = false;
|
||||||
|
|
||||||
|
foreach ($this->importers as $class) {
|
||||||
|
/** @var Importer $importer */
|
||||||
|
$importer = app($class);
|
||||||
|
|
||||||
|
if ($only !== [] && ! in_array($importer->key(), $only, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("→ {$importer->label()}");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$summary = $importer->import($dryRun);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error(" {$importer->key()} failed: ".$e->getMessage());
|
||||||
|
$hadError = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($summary->warnings as $warning) {
|
||||||
|
$this->warn(' ! '.$warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
' %screated %d, updated %d, skipped %d (total %d)',
|
||||||
|
$dryRun ? '[dry-run] ' : '',
|
||||||
|
$summary->created,
|
||||||
|
$summary->updated,
|
||||||
|
$summary->skipped,
|
||||||
|
$summary->total(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hadError ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/app/Enums/InstitutionLinkType.php
Normal file
33
backend/app/Enums/InstitutionLinkType.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipologia di risorsa web collegata a un'istituzione.
|
||||||
|
*
|
||||||
|
* Vocabolario stabile, controllato dallo sviluppatore: backed enum (non MySQL
|
||||||
|
* ENUM né lookup table). Gli artifact useranno un enum separato (ArtifactLinkType)
|
||||||
|
* con un proprio vocabolario.
|
||||||
|
*/
|
||||||
|
enum InstitutionLinkType: string
|
||||||
|
{
|
||||||
|
case Official = 'official';
|
||||||
|
case Ticketing = 'ticketing';
|
||||||
|
case Social = 'social';
|
||||||
|
case Catalogue = 'catalogue';
|
||||||
|
case Other = 'other';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etichetta leggibile per la UI (inglese: lingua ufficiale dell'applicazione).
|
||||||
|
*/
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Official => 'Official website',
|
||||||
|
self::Ticketing => 'Online ticketing',
|
||||||
|
self::Social => 'Social media',
|
||||||
|
self::Catalogue => 'Online catalogue',
|
||||||
|
self::Other => 'Other resource',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/app/Enums/LicenseKind.php
Normal file
32
backend/app/Enums/LicenseKind.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Natura della voce nella tabella `licenses`.
|
||||||
|
*
|
||||||
|
* Non tutto ciò che si applica a record e media è una licenza in senso
|
||||||
|
* stretto: il Public Domain Mark è un *marchio* (dichiara uno stato
|
||||||
|
* esistente, non concede diritti) e gli statement di RightsStatements.org
|
||||||
|
* etichettano lo stato dei diritti senza concederne. La distinzione serve a
|
||||||
|
* UI e download: solo le voci `License` hanno un testo legale da includere
|
||||||
|
* nello zip dei media (colonna `file`).
|
||||||
|
*/
|
||||||
|
enum LicenseKind: string
|
||||||
|
{
|
||||||
|
case License = 'license';
|
||||||
|
case Mark = 'mark';
|
||||||
|
case RightsStatement = 'rights_statement';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etichetta leggibile per la UI (inglese: lingua ufficiale dell'applicazione).
|
||||||
|
*/
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::License => 'License',
|
||||||
|
self::Mark => 'Public domain mark',
|
||||||
|
self::RightsStatement => 'Rights statement',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/app/Etl/Contracts/Importer.php
Normal file
31
backend/app/Etl/Contracts/Importer.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Etl\Contracts;
|
||||||
|
|
||||||
|
use App\Etl\ImportSummary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un importer ETL trasforma una porzione del DB legacy (v1) nello schema v2.
|
||||||
|
*
|
||||||
|
* Contratto comune a tutti gli importer (institutions, poi artifacts, ...): il
|
||||||
|
* comando `v1:import` li orchestra in ordine di dipendenza. Ogni import deve
|
||||||
|
* essere IDEMPOTENTE (upsert su `legacy_id`) e leggere SOLO dalla connessione
|
||||||
|
* `legacy`, scrivere SOLO sulla connessione di default (v2).
|
||||||
|
*/
|
||||||
|
interface Importer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Chiave breve per il filtro `--only` (es. "institutions").
|
||||||
|
*/
|
||||||
|
public function key(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etichetta leggibile per l'output del comando.
|
||||||
|
*/
|
||||||
|
public function label(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esegue l'import. In dry-run non scrive nulla, ma calcola comunque i conteggi.
|
||||||
|
*/
|
||||||
|
public function import(bool $dryRun): ImportSummary;
|
||||||
|
}
|
||||||
28
backend/app/Etl/ImportSummary.php
Normal file
28
backend/app/Etl/ImportSummary.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Etl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esito di un import: conteggi e avvisi non bloccanti (da stampare nel comando).
|
||||||
|
*/
|
||||||
|
class ImportSummary
|
||||||
|
{
|
||||||
|
public int $created = 0;
|
||||||
|
|
||||||
|
public int $updated = 0;
|
||||||
|
|
||||||
|
public int $skipped = 0;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public array $warnings = [];
|
||||||
|
|
||||||
|
public function warn(string $message): void
|
||||||
|
{
|
||||||
|
$this->warnings[] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function total(): int
|
||||||
|
{
|
||||||
|
return $this->created + $this->updated + $this->skipped;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
backend/app/Etl/Importers/InstitutionImporter.php
Normal file
162
backend/app/Etl/Importers/InstitutionImporter.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Etl\Importers;
|
||||||
|
|
||||||
|
use App\Enums\InstitutionLinkType;
|
||||||
|
use App\Etl\Contracts\Importer;
|
||||||
|
use App\Etl\ImportSummary;
|
||||||
|
use App\Models\Institution;
|
||||||
|
use App\Models\InstitutionLink;
|
||||||
|
use App\Models\Lists\InstitutionCategory;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importa le istituzioni da v1 (`institution`) nello schema v2.
|
||||||
|
*
|
||||||
|
* - Upsert su `legacy_id` → idempotente (re-run aggiorna, non duplica).
|
||||||
|
* - `category` (v1) → `category_id` (v2): mappa 1:1 perché il lookup è seedato con
|
||||||
|
* gli stessi id legacy. Categoria assente nel lookup (es. 1 "uncategorized") → skip + warning.
|
||||||
|
* - `uuid` NON copiato dal legacy: lo rigenera il model (orderedUuid) alla creazione.
|
||||||
|
* - `url` (campo singolo v1) → riga `institution_links` con type=official, solo se valorizzato.
|
||||||
|
* - Pulizia dati: trim su name/abbreviation/address/city/logo.
|
||||||
|
*
|
||||||
|
* NB l'auditing è già off in console (audit.console=false), quindi l'import non genera audit.
|
||||||
|
* NB la migrazione dei FILE logo (da v1 a storage/app/public) è fuori scope di questa fetta:
|
||||||
|
* qui si copia solo il valore stringa della colonna.
|
||||||
|
*/
|
||||||
|
class InstitutionImporter implements Importer
|
||||||
|
{
|
||||||
|
private const SOURCE = 'legacy';
|
||||||
|
|
||||||
|
public function key(): string
|
||||||
|
{
|
||||||
|
return 'institutions';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Institutions';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function import(bool $dryRun): ImportSummary
|
||||||
|
{
|
||||||
|
$summary = new ImportSummary;
|
||||||
|
|
||||||
|
/** @var list<int> $validCategoryIds */
|
||||||
|
$validCategoryIds = InstitutionCategory::query()->pluck('id')->map(intval(...))->all();
|
||||||
|
|
||||||
|
if ($validCategoryIds === []) {
|
||||||
|
$summary->warn('Lookup institution_categories vuoto: esegui prima `db:seed`. Import saltato.');
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = DB::connection(self::SOURCE)
|
||||||
|
->table('institution')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($rows, $dryRun, $summary, $validCategoryIds): void {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$this->importRow($row, $dryRun, $validCategoryIds, $summary);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importa (upsert) una singola istituzione legacy. Categoria assente nel
|
||||||
|
* lookup v2 → skip con warning.
|
||||||
|
*
|
||||||
|
* @param list<int> $validCategoryIds
|
||||||
|
*/
|
||||||
|
private function importRow(object $row, bool $dryRun, array $validCategoryIds, ImportSummary $summary): void
|
||||||
|
{
|
||||||
|
$categoryId = (int) $row->category;
|
||||||
|
|
||||||
|
if (! in_array($categoryId, $validCategoryIds, true)) {
|
||||||
|
$summary->warn(sprintf(
|
||||||
|
"Institution legacy #%d ('%s') : category %d assente nel lookup v2 → saltata.",
|
||||||
|
$row->id,
|
||||||
|
trim((string) $row->name),
|
||||||
|
$categoryId,
|
||||||
|
));
|
||||||
|
$summary->skipped++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
Institution::query()->where('legacy_id', (int) $row->id)->exists()
|
||||||
|
? $summary->updated++
|
||||||
|
: $summary->created++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$institution = Institution::updateOrCreate(
|
||||||
|
['legacy_id' => (int) $row->id],
|
||||||
|
$this->mapAttributes($row, $categoryId),
|
||||||
|
);
|
||||||
|
|
||||||
|
$institution->wasRecentlyCreated ? $summary->created++ : $summary->updated++;
|
||||||
|
|
||||||
|
$this->syncOfficialLink($institution, (string) ($row->url ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappa una riga legacy sugli attributi del model v2 (con pulizia dei dati).
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapAttributes(object $row, int $categoryId): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category_id' => $categoryId,
|
||||||
|
'name' => trim((string) $row->name),
|
||||||
|
'abbreviation' => trim((string) $row->abbreviation),
|
||||||
|
'address' => trim((string) $row->address),
|
||||||
|
'city' => trim((string) $row->city),
|
||||||
|
'lat' => $row->lat,
|
||||||
|
'lon' => $row->lon,
|
||||||
|
'logo' => trim((string) $row->logo),
|
||||||
|
'color' => $this->cleanColor($row->color),
|
||||||
|
'is_storage_place' => (bool) $row->is_storage_place,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colore valido o fallback al default v2.
|
||||||
|
*/
|
||||||
|
private function cleanColor(mixed $color): string
|
||||||
|
{
|
||||||
|
$color = trim((string) ($color ?? ''));
|
||||||
|
|
||||||
|
return $color !== '' ? $color : '#c5cae9';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincronizza il link "official" dall'unico `url` legacy. Idempotente
|
||||||
|
* (upsert su institution_id + type). URL vuoto/blank → nessun link.
|
||||||
|
*/
|
||||||
|
private function syncOfficialLink(Institution $institution, string $url): void
|
||||||
|
{
|
||||||
|
$url = trim($url);
|
||||||
|
|
||||||
|
if ($url === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
InstitutionLink::updateOrCreate(
|
||||||
|
[
|
||||||
|
'institution_id' => $institution->id,
|
||||||
|
'type' => InstitutionLinkType::Official,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'url' => $url,
|
||||||
|
'sort_order' => 0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/app/Exceptions/CannotDeleteSystemUserException.php
Normal file
17
backend/app/Exceptions/CannotDeleteSystemUserException.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sollevata quando si tenta di anonimizzare/eliminare l'utente di sistema,
|
||||||
|
* che deve restare sempre presente come destinatario dei contenuti riassegnati.
|
||||||
|
*/
|
||||||
|
class CannotDeleteSystemUserException extends RuntimeException
|
||||||
|
{
|
||||||
|
public static function make(): self
|
||||||
|
{
|
||||||
|
return new self('The system user cannot be deleted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
72
backend/app/Http/Controllers/AuthController.php
Normal file
72
backend/app/Http/Controllers/AuthController.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Cookie;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utente autenticato corrente (include l'accessor setup_status).
|
||||||
|
* Risposta al primo livello, come da convenzione Sanctum SPA.
|
||||||
|
*/
|
||||||
|
public function me(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($request->user());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout: chiude la sessione stateful e ripulisce i cookie.
|
||||||
|
* Sovrascrive la rotta omonima di Fortify (registrata dopo → ha precedenza).
|
||||||
|
*/
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->tearDownSession($request);
|
||||||
|
|
||||||
|
return $this->forgetAuthCookies($this->messageResponse('auth.logout_success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancellazione self-service dell'account: soft-delete + logout.
|
||||||
|
* L'anonimizzazione definitiva (GDPR) la fa l'Admin o la retention.
|
||||||
|
*/
|
||||||
|
public function destroyAccount(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
abort_if($user->is_system, 403);
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
$this->tearDownSession($request);
|
||||||
|
|
||||||
|
return $this->forgetAuthCookies($this->messageResponse('account.deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalida la sessione corrente e rigenera il token CSRF.
|
||||||
|
*/
|
||||||
|
private function tearDownSession(Request $request): void
|
||||||
|
{
|
||||||
|
Auth::guard('web')->logout();
|
||||||
|
|
||||||
|
if ($request->hasSession()) {
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca per la cancellazione i cookie di sessione e XSRF lato client.
|
||||||
|
*/
|
||||||
|
private function forgetAuthCookies(JsonResponse $response): JsonResponse
|
||||||
|
{
|
||||||
|
return $response
|
||||||
|
->withCookie(Cookie::forget(config('session.cookie')))
|
||||||
|
->withCookie(Cookie::forget('XSRF-TOKEN'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreInstitutionCategoryRequest;
|
||||||
|
use App\Http\Requests\UpdateInstitutionCategoryRequest;
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use App\Models\Lists\InstitutionCategory;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD del lookup categorie di istituzione (scrittura riservata agli Admin).
|
||||||
|
*
|
||||||
|
* NB gli id 2/3/4/6 sono allineati al legacy e usati dall'ETL: cancellarne uno
|
||||||
|
* in uso è bloccato (409). Nessun soft delete su questo lookup.
|
||||||
|
*/
|
||||||
|
class InstitutionCategoryController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenco delle categorie.
|
||||||
|
*/
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->collectionResponse(InstitutionCategory::orderBy('value')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una categoria.
|
||||||
|
*/
|
||||||
|
public function store(StoreInstitutionCategoryRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$category = InstitutionCategory::create($request->validated());
|
||||||
|
|
||||||
|
return $this->createdResponse($category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio di una categoria.
|
||||||
|
*/
|
||||||
|
public function show(InstitutionCategory $institutionCategory): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->okResponse($institutionCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna una categoria.
|
||||||
|
*/
|
||||||
|
public function update(
|
||||||
|
UpdateInstitutionCategoryRequest $request,
|
||||||
|
InstitutionCategory $institutionCategory
|
||||||
|
): JsonResponse {
|
||||||
|
$institutionCategory->update($request->validated());
|
||||||
|
|
||||||
|
return $this->updatedResponse($institutionCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una categoria (se non assegnata ad alcuna istituzione).
|
||||||
|
*/
|
||||||
|
public function destroy(InstitutionCategory $institutionCategory): JsonResponse
|
||||||
|
{
|
||||||
|
if ($institutionCategory->isInUse()) {
|
||||||
|
return $this->conflictResponse('Cannot delete: category assigned to at least one institution.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$institutionCategory->delete();
|
||||||
|
|
||||||
|
return $this->deletedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indica se la categoria è in uso (bloccata per l'eliminazione).
|
||||||
|
*/
|
||||||
|
public function usage(InstitutionCategory $institutionCategory): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'in_use' => $institutionCategory->isInUse(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
backend/app/Http/Controllers/InstitutionController.php
Normal file
172
backend/app/Http/Controllers/InstitutionController.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\IndexInstitutionRequest;
|
||||||
|
use App\Http\Requests\StoreInstitutionRequest;
|
||||||
|
use App\Http\Requests\UpdateInstitutionRequest;
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use App\Models\Institution;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD delle istituzioni (scrittura riservata agli Admin via tier di rotta).
|
||||||
|
*
|
||||||
|
* Identificate verso l'esterno dallo `uuid` (route key), non dall'id interno.
|
||||||
|
* Soft delete: destroy = cestina, restore = ripristina, forceDestroy = elimina
|
||||||
|
* definitivamente (e rimuove il file logo + i link via cascade FK).
|
||||||
|
*/
|
||||||
|
class InstitutionController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/** Sottocartella del disco `public` dove vivono i loghi. */
|
||||||
|
private const LOGO_DIR = 'institution_logo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenco paginato, con filtri opzionali (ricerca, categoria, cestino).
|
||||||
|
*/
|
||||||
|
public function index(IndexInstitutionRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = Institution::query()
|
||||||
|
->with('category')
|
||||||
|
->orderBy('name');
|
||||||
|
|
||||||
|
$this->applyTrashed($query, $request->input('trashed'));
|
||||||
|
$this->applySearch($query, $request->input('search'));
|
||||||
|
|
||||||
|
if ($request->filled('category_id')) {
|
||||||
|
$query->where('category_id', $request->integer('category_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$institutions = $query->paginate($request->integer('per_page') ?: 20);
|
||||||
|
|
||||||
|
return $this->paginatedCollectionResponse($institutions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un'istituzione (con upload del logo).
|
||||||
|
*/
|
||||||
|
public function store(StoreInstitutionRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
$data['logo'] = $this->storeLogo($request->file('logo'));
|
||||||
|
|
||||||
|
$institution = Institution::create($data);
|
||||||
|
|
||||||
|
return $this->createdResponse($institution->load('category'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio (con categoria e link).
|
||||||
|
*/
|
||||||
|
public function show(Institution $institution): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->okResponse($institution->load(['category', 'links']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna un'istituzione. Logo opzionale: se presente sostituisce il vecchio.
|
||||||
|
*/
|
||||||
|
public function update(UpdateInstitutionRequest $request, Institution $institution): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
if ($request->hasFile('logo')) {
|
||||||
|
$this->deleteLogo($institution->logo);
|
||||||
|
$data['logo'] = $this->storeLogo($request->file('logo'));
|
||||||
|
} else {
|
||||||
|
unset($data['logo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$institution->update($data);
|
||||||
|
|
||||||
|
return $this->updatedResponse($institution->load('category'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cestina (soft delete).
|
||||||
|
*/
|
||||||
|
public function destroy(Institution $institution): JsonResponse
|
||||||
|
{
|
||||||
|
$institution->delete();
|
||||||
|
|
||||||
|
return $this->deletedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ripristina un'istituzione cestinata.
|
||||||
|
*/
|
||||||
|
public function restore(Institution $institution): JsonResponse
|
||||||
|
{
|
||||||
|
$institution->restore();
|
||||||
|
|
||||||
|
return $this->restoredResponse($institution->load('category'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina definitivamente (rimuove il file logo; i link cadono in cascata DB).
|
||||||
|
*/
|
||||||
|
public function forceDestroy(Institution $institution): JsonResponse
|
||||||
|
{
|
||||||
|
$this->deleteLogo($institution->logo);
|
||||||
|
$institution->forceDelete();
|
||||||
|
|
||||||
|
return $this->deletedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salva il file logo sul disco `public` e ne restituisce il path relativo.
|
||||||
|
*/
|
||||||
|
private function storeLogo(UploadedFile $file): string
|
||||||
|
{
|
||||||
|
return $file->store(self::LOGO_DIR, 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rimuove un file logo dal disco `public`, se presente.
|
||||||
|
*/
|
||||||
|
private function deleteLogo(?string $path): void
|
||||||
|
{
|
||||||
|
if (filled($path)) {
|
||||||
|
Storage::disk('public')->delete($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include i soft-deleted: `with` (tutti) o `only` (solo cestinati).
|
||||||
|
*
|
||||||
|
* @param Builder<Institution> $query
|
||||||
|
*/
|
||||||
|
private function applyTrashed(Builder $query, ?string $trashed): void
|
||||||
|
{
|
||||||
|
match ($trashed) {
|
||||||
|
'with' => $query->withTrashed(),
|
||||||
|
'only' => $query->onlyTrashed(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ricerca parziale su nome, città e sigla.
|
||||||
|
*
|
||||||
|
* @param Builder<Institution> $query
|
||||||
|
*/
|
||||||
|
private function applySearch(Builder $query, ?string $search): void
|
||||||
|
{
|
||||||
|
if (blank($search)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$term = '%'.$search.'%';
|
||||||
|
|
||||||
|
$query->where(function (Builder $q) use ($term): void {
|
||||||
|
$q->where('name', 'like', $term)
|
||||||
|
->orWhere('city', 'like', $term)
|
||||||
|
->orWhere('abbreviation', 'like', $term);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
69
backend/app/Http/Controllers/InstitutionLinkController.php
Normal file
69
backend/app/Http/Controllers/InstitutionLinkController.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreInstitutionLinkRequest;
|
||||||
|
use App\Http\Requests\UpdateInstitutionLinkRequest;
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use App\Models\Institution;
|
||||||
|
use App\Models\InstitutionLink;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risorse web di un'istituzione (sito ufficiale, ticketing, social, ...).
|
||||||
|
*
|
||||||
|
* Risorsa annidata e scoped sotto `institutions/{institution}`: il link deve
|
||||||
|
* appartenere all'istituzione del path. Nessun soft delete (solo Institution lo ha).
|
||||||
|
*/
|
||||||
|
class InstitutionLinkController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenco dei link di un'istituzione, ordinati.
|
||||||
|
*/
|
||||||
|
public function index(Institution $institution): JsonResponse
|
||||||
|
{
|
||||||
|
$links = $institution->links()->orderBy('sort_order')->get();
|
||||||
|
|
||||||
|
return $this->collectionResponse($links);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiunge un link all'istituzione.
|
||||||
|
*/
|
||||||
|
public function store(StoreInstitutionLinkRequest $request, Institution $institution): JsonResponse
|
||||||
|
{
|
||||||
|
$link = $institution->links()->create($request->validated());
|
||||||
|
|
||||||
|
return $this->createdResponse($link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio di un link.
|
||||||
|
*/
|
||||||
|
public function show(Institution $institution, InstitutionLink $link): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->okResponse($link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna un link.
|
||||||
|
*/
|
||||||
|
public function update(UpdateInstitutionLinkRequest $request, Institution $institution, InstitutionLink $link): JsonResponse
|
||||||
|
{
|
||||||
|
$link->update($request->validated());
|
||||||
|
|
||||||
|
return $this->updatedResponse($link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina un link.
|
||||||
|
*/
|
||||||
|
public function destroy(Institution $institution, InstitutionLink $link): JsonResponse
|
||||||
|
{
|
||||||
|
$link->delete();
|
||||||
|
|
||||||
|
return $this->deletedResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
96
backend/app/Http/Controllers/LicenseController.php
Normal file
96
backend/app/Http/Controllers/LicenseController.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\IndexLicenseRequest;
|
||||||
|
use App\Http\Requests\StoreLicenseRequest;
|
||||||
|
use App\Http\Requests\UpdateLicenseRequest;
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use App\Models\Lists\License;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD del vocabolario licenze (scrittura riservata agli Admin).
|
||||||
|
*
|
||||||
|
* NB gli id 1-3 sono allineati al legacy e verranno usati dall'ETL per
|
||||||
|
* mappare le FK di artifact/media: per ritirare una licenza usare
|
||||||
|
* `active = false`, non la cancellazione. Nessun soft delete su questo lookup.
|
||||||
|
*/
|
||||||
|
class LicenseController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenco delle licenze, ordinate per `sort_order` (dalla più aperta alla
|
||||||
|
* più restrittiva). Filtri: `scope=record|media` (select dei form),
|
||||||
|
* `active=1` (esclude le licenze ritirate).
|
||||||
|
*/
|
||||||
|
public function index(IndexLicenseRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = License::query()->orderBy('sort_order')->orderBy('acronym');
|
||||||
|
|
||||||
|
match ($request->input('scope')) {
|
||||||
|
'record' => $query->forRecord(),
|
||||||
|
'media' => $query->forMedia(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($request->filled('active')) {
|
||||||
|
$query->where('active', $request->boolean('active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->collectionResponse($query->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una licenza.
|
||||||
|
*/
|
||||||
|
public function store(StoreLicenseRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$license = License::create($request->validated());
|
||||||
|
|
||||||
|
return $this->createdResponse($license);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio di una licenza.
|
||||||
|
*/
|
||||||
|
public function show(License $license): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->okResponse($license);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna una licenza (anche parzialmente, es. solo `active`).
|
||||||
|
*/
|
||||||
|
public function update(UpdateLicenseRequest $request, License $license): JsonResponse
|
||||||
|
{
|
||||||
|
$license->update($request->validated());
|
||||||
|
|
||||||
|
return $this->updatedResponse($license);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una licenza (se non assegnata ad alcun contenuto).
|
||||||
|
*/
|
||||||
|
public function destroy(License $license): JsonResponse
|
||||||
|
{
|
||||||
|
if ($license->isInUse()) {
|
||||||
|
return $this->conflictResponse('Cannot delete: license assigned to at least one record or media file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$license->delete();
|
||||||
|
|
||||||
|
return $this->deletedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indica se la licenza è in uso (bloccata per l'eliminazione).
|
||||||
|
*/
|
||||||
|
public function usage(License $license): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'in_use' => $license->isInUse(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
backend/app/Http/Controllers/UserAffiliationController.php
Normal file
138
backend/app/Http/Controllers/UserAffiliationController.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\IndexUserAffiliationRequest;
|
||||||
|
use App\Http\Requests\StoreUserAffiliationRequest;
|
||||||
|
use App\Http\Requests\UpdateUserAffiliationRequest;
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use App\Models\UserAffiliation;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD delle affiliazioni utente-ente (scrittura riservata agli Admin via tier
|
||||||
|
* di rotta). Risorsa standalone: institution_id, user_id e user_position_id
|
||||||
|
* viaggiano nel body, non nella route (l'affiliazione ha due "genitori" forti).
|
||||||
|
*
|
||||||
|
* Soft delete: destroy = cestina, restore = ripristina, forceDestroy = elimina
|
||||||
|
* definitivamente.
|
||||||
|
*/
|
||||||
|
class UserAffiliationController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenco paginato, con filtri opzionali (ente, utente, posizione, aperte/chiuse, cestino).
|
||||||
|
*/
|
||||||
|
public function index(IndexUserAffiliationRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = UserAffiliation::query()
|
||||||
|
->with(['institution', 'user', 'userPosition'])
|
||||||
|
->orderByDesc('start_year');
|
||||||
|
|
||||||
|
$this->applyTrashed($query, $request->input('trashed'));
|
||||||
|
|
||||||
|
if ($request->filled('institution_id')) {
|
||||||
|
$query->where('institution_id', $request->integer('institution_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('user_id')) {
|
||||||
|
$query->where('user_id', $request->integer('user_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('user_position_id')) {
|
||||||
|
$query->where('user_position_id', $request->integer('user_position_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('open')) {
|
||||||
|
$request->boolean('open')
|
||||||
|
? $query->whereNotNull('is_open')
|
||||||
|
: $query->whereNull('is_open');
|
||||||
|
}
|
||||||
|
|
||||||
|
$affiliations = $query->paginate($request->integer('per_page') ?: 20);
|
||||||
|
|
||||||
|
return $this->paginatedCollectionResponse($affiliations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea un'affiliazione.
|
||||||
|
*/
|
||||||
|
public function store(StoreUserAffiliationRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$affiliation = UserAffiliation::create($request->validated());
|
||||||
|
|
||||||
|
return $this->createdResponse($affiliation->load(['institution', 'user', 'userPosition']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio (con ente, utente e posizione).
|
||||||
|
*/
|
||||||
|
public function show(UserAffiliation $userAffiliation): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->okResponse($userAffiliation->load(['institution', 'user', 'userPosition']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna un'affiliazione.
|
||||||
|
*/
|
||||||
|
public function update(UpdateUserAffiliationRequest $request, UserAffiliation $userAffiliation): JsonResponse
|
||||||
|
{
|
||||||
|
$userAffiliation->update($request->validated());
|
||||||
|
|
||||||
|
return $this->updatedResponse($userAffiliation->load(['institution', 'user', 'userPosition']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cestina (soft delete).
|
||||||
|
*/
|
||||||
|
public function destroy(UserAffiliation $userAffiliation): JsonResponse
|
||||||
|
{
|
||||||
|
$userAffiliation->delete();
|
||||||
|
|
||||||
|
return $this->deletedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ripristina un'affiliazione cestinata. Bloccato (409) se nel frattempo è
|
||||||
|
* stata aperta un'altra affiliazione per lo stesso ente+utente: va prima
|
||||||
|
* chiusa/eliminata quella attuale, oppure si elimina definitivamente questa.
|
||||||
|
*/
|
||||||
|
public function restore(UserAffiliation $userAffiliation): JsonResponse
|
||||||
|
{
|
||||||
|
if ($userAffiliation->hasConflictingOpenAffiliation()) {
|
||||||
|
return $this->conflictResponse(
|
||||||
|
'Cannot restore: another open affiliation already exists for this user at this institution.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userAffiliation->restore();
|
||||||
|
|
||||||
|
return $this->restoredResponse($userAffiliation->load(['institution', 'user', 'userPosition']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina definitivamente un'affiliazione.
|
||||||
|
*/
|
||||||
|
public function forceDestroy(UserAffiliation $userAffiliation): JsonResponse
|
||||||
|
{
|
||||||
|
$userAffiliation->forceDelete();
|
||||||
|
|
||||||
|
return $this->deletedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include i soft-deleted: `with` (tutti) o `only` (solo cestinati).
|
||||||
|
*
|
||||||
|
* @param Builder<UserAffiliation> $query
|
||||||
|
*/
|
||||||
|
private function applyTrashed(Builder $query, ?string $trashed): void
|
||||||
|
{
|
||||||
|
match ($trashed) {
|
||||||
|
'with' => $query->withTrashed(),
|
||||||
|
'only' => $query->onlyTrashed(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
85
backend/app/Http/Controllers/UserController.php
Normal file
85
backend/app/Http/Controllers/UserController.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\IndexUserRequest;
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gestione utenti riservata agli amministratori (sola lettura per ora:
|
||||||
|
* la creazione/invito e la modifica arriveranno con la gestione account).
|
||||||
|
*
|
||||||
|
* Ogni utente è serializzato col ruolo correlato e l'accessor `setup_status`
|
||||||
|
* (password_required / 2fa_setup_required / complete), utile alla dashboard admin.
|
||||||
|
*/
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenco paginato degli utenti, con filtri opzionali (ricerca, ruolo, cestino).
|
||||||
|
*/
|
||||||
|
public function index(IndexUserRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = User::query()
|
||||||
|
->with('role')
|
||||||
|
->orderBy('name');
|
||||||
|
|
||||||
|
$this->applyTrashed($query, $request->input('trashed'));
|
||||||
|
$this->applySearch($query, $request->input('search'));
|
||||||
|
|
||||||
|
if ($request->filled('role_id')) {
|
||||||
|
$query->where('role_id', $request->integer('role_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = $query->paginate($request->integer('per_page') ?: 20);
|
||||||
|
|
||||||
|
return $this->paginatedCollectionResponse($users);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio di un singolo utente (anche cestinato/anonimizzato).
|
||||||
|
*/
|
||||||
|
public function show(User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$user->load('role');
|
||||||
|
|
||||||
|
return $this->okResponse($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include i soft-deleted nell'elenco: `with` (tutti) o `only` (solo cestinati).
|
||||||
|
*
|
||||||
|
* @param Builder<User> $query
|
||||||
|
*/
|
||||||
|
private function applyTrashed(Builder $query, ?string $trashed): void
|
||||||
|
{
|
||||||
|
match ($trashed) {
|
||||||
|
'with' => $query->withTrashed(),
|
||||||
|
'only' => $query->onlyTrashed(),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtra per nome o email (ricerca parziale, case-insensitive).
|
||||||
|
*
|
||||||
|
* @param Builder<User> $query
|
||||||
|
*/
|
||||||
|
private function applySearch(Builder $query, ?string $search): void
|
||||||
|
{
|
||||||
|
if (blank($search)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$term = '%'.$search.'%';
|
||||||
|
|
||||||
|
$query->where(function (Builder $q) use ($term): void {
|
||||||
|
$q->where('name', 'like', $term)
|
||||||
|
->orWhere('email', 'like', $term);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/app/Http/Controllers/UserPositionController.php
Normal file
28
backend/app/Http/Controllers/UserPositionController.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use App\Models\Lists\UserPosition;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class UserPositionController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenco delle position.
|
||||||
|
*/
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->collectionResponse(UserPosition::all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio di una position.
|
||||||
|
*/
|
||||||
|
public function show(UserPosition $userPosition): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->okResponse($userPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/app/Http/Controllers/UserRoleController.php
Normal file
38
backend/app/Http/Controllers/UserRoleController.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Traits\ApiResponse;
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class UserRoleController extends Controller
|
||||||
|
{
|
||||||
|
use ApiResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elenco dei ruoli.
|
||||||
|
*/
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->collectionResponse(UserRole::all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio di un ruolo.
|
||||||
|
*/
|
||||||
|
public function show(UserRole $userRole): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->okResponse($userRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numero di utenti con questo ruolo (statistica per Admin).
|
||||||
|
*/
|
||||||
|
public function usage(UserRole $userRole): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'user_count' => $userRole->users()->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/app/Http/Middleware/EnsureSetupComplete.php
Normal file
40
backend/app/Http/Middleware/EnsureSetupComplete.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureSetupComplete
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): mixed
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$setupStatus = null;
|
||||||
|
|
||||||
|
if ($user->must_change_password) {
|
||||||
|
$setupStatus = 'password_required';
|
||||||
|
} elseif (is_null($user->two_factor_setup_completed_at)) {
|
||||||
|
$setupStatus = '2fa_setup_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($setupStatus !== null) {
|
||||||
|
return response()->json([
|
||||||
|
'setup_status' => $setupStatus,
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/app/Http/Middleware/ForceJsonResponse.php
Normal file
22
backend/app/Http/Middleware/ForceJsonResponse.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ForceJsonResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$request->headers->set('Accept', 'application/json');
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/app/Http/Middleware/IsAdmin.php
Normal file
25
backend/app/Http/Middleware/IsAdmin.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class IsAdmin
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! Auth::check() || Auth::user()->role?->name !== 'Admin') {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/app/Http/Requests/IndexInstitutionRequest.php
Normal file
31
backend/app/Http/Requests/IndexInstitutionRequest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class IndexInstitutionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione è gestita dal middleware di rotta (tier.app).
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
|
'category_id' => ['nullable', 'integer', 'exists:institution_categories,id'],
|
||||||
|
'trashed' => ['nullable', Rule::in(['with', 'only'])],
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/app/Http/Requests/IndexLicenseRequest.php
Normal file
33
backend/app/Http/Requests/IndexLicenseRequest.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class IndexLicenseRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Rotta pubblica: l'elenco delle licenze è informazione pubblica.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `scope` filtra le select dei form: `record` per artifact/model (solo
|
||||||
|
* licenze aperte), `media` per i file caricati (tutte). `active` esclude
|
||||||
|
* le licenze ritirate dagli admin.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'scope' => ['nullable', Rule::in(['record', 'media'])],
|
||||||
|
'active' => ['nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/app/Http/Requests/IndexUserAffiliationRequest.php
Normal file
33
backend/app/Http/Requests/IndexUserAffiliationRequest.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class IndexUserAffiliationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione è gestita dal middleware di rotta (tier.app).
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'institution_id' => ['nullable', 'integer', 'exists:institutions,id'],
|
||||||
|
'user_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||||
|
'user_position_id' => ['nullable', 'integer', 'exists:user_positions,id'],
|
||||||
|
'open' => ['nullable', 'boolean'],
|
||||||
|
'trashed' => ['nullable', Rule::in(['with', 'only'])],
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/app/Http/Requests/IndexUserRequest.php
Normal file
31
backend/app/Http/Requests/IndexUserRequest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class IndexUserRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (auth + admin + setup) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
|
'role_id' => ['nullable', 'integer', 'exists:user_roles,id'],
|
||||||
|
'trashed' => ['nullable', Rule::in(['with', 'only'])],
|
||||||
|
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreInstitutionCategoryRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'value' => ['required', 'string', 'max:25', 'unique:institution_categories,value'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/app/Http/Requests/StoreInstitutionLinkRequest.php
Normal file
37
backend/app/Http/Requests/StoreInstitutionLinkRequest.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\InstitutionLinkType;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreInstitutionLinkRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->mergeIfMissing(['sort_order' => 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'url' => ['required', 'url', 'max:2000'],
|
||||||
|
'type' => ['required', Rule::enum(InstitutionLinkType::class)],
|
||||||
|
'label' => ['nullable', 'string', 'max:100'],
|
||||||
|
'sort_order' => ['required', 'integer', 'min:0'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/app/Http/Requests/StoreInstitutionRequest.php
Normal file
48
backend/app/Http/Requests/StoreInstitutionRequest.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreInstitutionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default per i campi con valore di colonna (color, is_storage_place):
|
||||||
|
* così non si inseriscono mai NULL su colonne NOT NULL.
|
||||||
|
*/
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->mergeIfMissing([
|
||||||
|
'color' => '#c5cae9',
|
||||||
|
'is_storage_place' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category_id' => ['required', 'integer', 'exists:institution_categories,id'],
|
||||||
|
'name' => ['required', 'string', 'max:255', 'unique:institutions,name'],
|
||||||
|
'abbreviation' => ['required', 'string', 'max:5', 'unique:institutions,abbreviation'],
|
||||||
|
'address' => ['required', 'string', 'max:255'],
|
||||||
|
'city' => ['required', 'string', 'max:100'],
|
||||||
|
'lat' => ['required', 'numeric', 'between:-90,90'],
|
||||||
|
'lon' => ['required', 'numeric', 'between:-180,180'],
|
||||||
|
'logo' => ['required', 'image', 'max:5120'],
|
||||||
|
'color' => ['required', 'string', 'max:50'],
|
||||||
|
'is_storage_place' => ['required', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/app/Http/Requests/StoreLicenseRequest.php
Normal file
41
backend/app/Http/Requests/StoreLicenseRequest.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\LicenseKind;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreLicenseRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:200', 'unique:licenses,name'],
|
||||||
|
'acronym' => ['required', 'string', 'max:50', 'unique:licenses,acronym'],
|
||||||
|
// Solo id della SPDX License List ufficiale: per marchi e rights
|
||||||
|
// statement (che non vi compaiono) deve restare NULL.
|
||||||
|
'spdx_id' => ['nullable', 'string', 'max:50', 'unique:licenses,spdx_id'],
|
||||||
|
'kind' => ['required', Rule::enum(LicenseKind::class)],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'uri' => ['nullable', 'url', 'max:2000'],
|
||||||
|
'file' => ['nullable', 'string', 'max:512'],
|
||||||
|
'for_record' => ['sometimes', 'boolean'],
|
||||||
|
'for_media' => ['sometimes', 'boolean'],
|
||||||
|
'sort_order' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'active' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/app/Http/Requests/StoreUserAffiliationRequest.php
Normal file
28
backend/app/Http/Requests/StoreUserAffiliationRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Http\Traits\ValidatesUserAffiliation;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreUserAffiliationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use ValidatesUserAffiliation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return $this->userAffiliationRules();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateInstitutionCategoryRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$id = $this->route('institution_category')->id;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'value' => ['required', 'string', 'max:25', Rule::unique('institution_categories', 'value')->ignore($id)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/app/Http/Requests/UpdateInstitutionLinkRequest.php
Normal file
37
backend/app/Http/Requests/UpdateInstitutionLinkRequest.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\InstitutionLinkType;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateInstitutionLinkRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->mergeIfMissing(['sort_order' => 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'url' => ['required', 'url', 'max:2000'],
|
||||||
|
'type' => ['required', Rule::enum(InstitutionLinkType::class)],
|
||||||
|
'label' => ['nullable', 'string', 'max:100'],
|
||||||
|
'sort_order' => ['required', 'integer', 'min:0'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/app/Http/Requests/UpdateInstitutionRequest.php
Normal file
40
backend/app/Http/Requests/UpdateInstitutionRequest.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateInstitutionRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$id = $this->route('institution')->id;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'category_id' => ['required', 'integer', 'exists:institution_categories,id'],
|
||||||
|
'name' => ['required', 'string', 'max:255', Rule::unique('institutions', 'name')->ignore($id)],
|
||||||
|
'abbreviation' => ['required', 'string', 'max:5', Rule::unique('institutions', 'abbreviation')->ignore($id)],
|
||||||
|
'address' => ['required', 'string', 'max:255'],
|
||||||
|
'city' => ['required', 'string', 'max:100'],
|
||||||
|
'lat' => ['required', 'numeric', 'between:-90,90'],
|
||||||
|
'lon' => ['required', 'numeric', 'between:-180,180'],
|
||||||
|
// Opzionale in update: se assente si mantiene il logo esistente.
|
||||||
|
'logo' => ['nullable', 'image', 'max:5120'],
|
||||||
|
'color' => ['required', 'string', 'max:50'],
|
||||||
|
'is_storage_place' => ['required', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
43
backend/app/Http/Requests/UpdateLicenseRequest.php
Normal file
43
backend/app/Http/Requests/UpdateLicenseRequest.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Enums\LicenseKind;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateLicenseRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiornamento parziale: ogni campo è validato solo se presente.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$id = $this->route('license')->id;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['sometimes', 'required', 'string', 'max:200', Rule::unique('licenses', 'name')->ignore($id)],
|
||||||
|
'acronym' => ['sometimes', 'required', 'string', 'max:50', Rule::unique('licenses', 'acronym')->ignore($id)],
|
||||||
|
'spdx_id' => ['nullable', 'string', 'max:50', Rule::unique('licenses', 'spdx_id')->ignore($id)],
|
||||||
|
'kind' => ['sometimes', 'required', Rule::enum(LicenseKind::class)],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'uri' => ['nullable', 'url', 'max:2000'],
|
||||||
|
'file' => ['nullable', 'string', 'max:512'],
|
||||||
|
'for_record' => ['sometimes', 'boolean'],
|
||||||
|
'for_media' => ['sometimes', 'boolean'],
|
||||||
|
'sort_order' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'active' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/app/Http/Requests/UpdateUserAffiliationRequest.php
Normal file
28
backend/app/Http/Requests/UpdateUserAffiliationRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Http\Traits\ValidatesUserAffiliation;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateUserAffiliationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use ValidatesUserAffiliation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return $this->userAffiliationRules($this->route('user_affiliation')->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
backend/app/Http/Traits/ApiResponse.php
Normal file
130
backend/app/Http/Traits/ApiResponse.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risposte JSON standardizzate per i controller API.
|
||||||
|
*
|
||||||
|
* I messaggi sono presi da config/messages.php. Passando un $prefix si possono
|
||||||
|
* usare messaggi specifici per risorsa (es. "user_roles.created"); senza prefix
|
||||||
|
* si usano le chiavi generiche di primo livello.
|
||||||
|
*/
|
||||||
|
trait ApiResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 200 — Lista paginata: { message, data, meta }.
|
||||||
|
*/
|
||||||
|
protected function paginatedCollectionResponse(LengthAwarePaginator $paginator, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.index" : 'messages.index'),
|
||||||
|
'data' => $paginator->items(),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $paginator->currentPage(),
|
||||||
|
'last_page' => $paginator->lastPage(),
|
||||||
|
'per_page' => $paginator->perPage(),
|
||||||
|
'total' => $paginator->total(),
|
||||||
|
],
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 200 — Collezione di risorse.
|
||||||
|
*/
|
||||||
|
protected function collectionResponse(mixed $collection, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.index" : 'messages.index'),
|
||||||
|
'data' => $collection,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 200 — Singola risorsa.
|
||||||
|
*/
|
||||||
|
protected function okResponse(mixed $data, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.show" : 'messages.show'),
|
||||||
|
'data' => $data,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 201 — Risorsa creata.
|
||||||
|
*/
|
||||||
|
protected function createdResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.created" : 'messages.created'),
|
||||||
|
'data' => $model,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 200 — Risorsa aggiornata.
|
||||||
|
*/
|
||||||
|
protected function updatedResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.updated" : 'messages.updated'),
|
||||||
|
'data' => $model,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 200 — Risorsa eliminata.
|
||||||
|
*/
|
||||||
|
protected function deletedResponse(?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.deleted" : 'messages.deleted'),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 200 — Risorsa ripristinata (soft delete restore).
|
||||||
|
*/
|
||||||
|
protected function restoredResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config($prefix ? "messages.{$prefix}.restored" : 'messages.restored'),
|
||||||
|
'data' => $model,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 409 — Conflitto: operazione bloccata (es. risorsa in uso).
|
||||||
|
*/
|
||||||
|
protected function conflictResponse(string $message): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => $message,
|
||||||
|
'in_use' => true,
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risposta con solo messaggio — chiave config es. "auth.login_success".
|
||||||
|
*/
|
||||||
|
protected function messageResponse(string $configKey, int $status = 200): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config("messages.{$configKey}"),
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risposta con messaggio e dati — chiave config es. "auth.login_success".
|
||||||
|
*/
|
||||||
|
protected function dataResponse(mixed $data, string $configKey, int $status = 200): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => config("messages.{$configKey}"),
|
||||||
|
'data' => $data,
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/app/Http/Traits/ValidatesUserAffiliation.php
Normal file
40
backend/app/Http/Traits/ValidatesUserAffiliation.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regole condivise da Store/UpdateUserAffiliationRequest. Unica differenza tra
|
||||||
|
* le due: l'update ignora se stesso nel controllo di unicità sull'incarico aperto.
|
||||||
|
*/
|
||||||
|
trait ValidatesUserAffiliation
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, mixed>>
|
||||||
|
*/
|
||||||
|
protected function userAffiliationRules(?int $ignoreId = null): array
|
||||||
|
{
|
||||||
|
$rules = [
|
||||||
|
'institution_id' => ['required', 'integer', 'exists:institutions,id'],
|
||||||
|
'user_id' => ['required', 'integer', 'exists:users,id'],
|
||||||
|
'user_position_id' => ['required', 'integer', 'exists:user_positions,id'],
|
||||||
|
'start_year' => ['required', 'integer', 'min:1800', 'max:'.now()->year],
|
||||||
|
'end_year' => ['nullable', 'integer', 'min:1800', 'gte:start_year'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Un utente non può avere più di un incarico aperto (end_year nullo) nello
|
||||||
|
// stesso ente: il guard definitivo è l'indice unique sulla colonna virtuale
|
||||||
|
// is_open, qui serve solo a restituire un 422 leggibile invece di un 500 SQL.
|
||||||
|
// I soft-deleted non contano: un cestinato non blocca un nuovo incarico aperto.
|
||||||
|
if (! $this->filled('end_year')) {
|
||||||
|
$rules['institution_id'][] = Rule::unique('user_affiliations', 'institution_id')
|
||||||
|
->where('user_id', $this->input('user_id'))
|
||||||
|
->whereNull('end_year')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($ignoreId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
backend/app/Models/Institution.php
Normal file
100
backend/app/Models/Institution.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Lists\InstitutionCategory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||||
|
use OwenIt\Auditing\Contracts\Auditable;
|
||||||
|
|
||||||
|
class Institution extends Model implements Auditable
|
||||||
|
{
|
||||||
|
use AuditableTrait;
|
||||||
|
use HasFactory;
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'category_id',
|
||||||
|
'name',
|
||||||
|
'abbreviation',
|
||||||
|
'address',
|
||||||
|
'city',
|
||||||
|
'lat',
|
||||||
|
'lon',
|
||||||
|
'logo',
|
||||||
|
'color',
|
||||||
|
'is_storage_place',
|
||||||
|
'legacy_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identificatore esterno stabile, esposto nelle rotte pubbliche/LOD e usato
|
||||||
|
* come token nei canonical URI Linked Art. NON fillable: generato qui sotto
|
||||||
|
* (o impostato esplicitamente dall'ETL), mai via mass assignment.
|
||||||
|
*/
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Institution $institution): void {
|
||||||
|
if (empty($institution->uuid)) {
|
||||||
|
$institution->uuid = (string) Str::orderedUuid();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category_id' => 'integer',
|
||||||
|
'lat' => 'float',
|
||||||
|
'lon' => 'float',
|
||||||
|
'is_storage_place' => 'boolean',
|
||||||
|
'legacy_id' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La rotta pubblica risolve per uuid, non per id auto-increment.
|
||||||
|
*/
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categoria dell'istituzione.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<InstitutionCategory, $this>
|
||||||
|
*/
|
||||||
|
public function category(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(InstitutionCategory::class, 'category_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risorse web collegate (sito ufficiale, ticketing, social, ...).
|
||||||
|
*
|
||||||
|
* @return HasMany<InstitutionLink, $this>
|
||||||
|
*/
|
||||||
|
public function links(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(InstitutionLink::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storico delle affiliazioni (staff) di questa istituzione.
|
||||||
|
*
|
||||||
|
* @return HasMany<UserAffiliation, $this>
|
||||||
|
*/
|
||||||
|
public function affiliations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserAffiliation::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
backend/app/Models/InstitutionLink.php
Normal file
46
backend/app/Models/InstitutionLink.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\InstitutionLinkType;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||||
|
use OwenIt\Auditing\Contracts\Auditable;
|
||||||
|
|
||||||
|
class InstitutionLink extends Model implements Auditable
|
||||||
|
{
|
||||||
|
use AuditableTrait;
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'institution_id',
|
||||||
|
'url',
|
||||||
|
'type',
|
||||||
|
'label',
|
||||||
|
'sort_order',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'institution_id' => 'integer',
|
||||||
|
'type' => InstitutionLinkType::class,
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Istituzione a cui appartiene il link.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<Institution, $this>
|
||||||
|
*/
|
||||||
|
public function institution(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Institution::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/app/Models/Lists/InstitutionCategory.php
Normal file
56
backend/app/Models/Lists/InstitutionCategory.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Lists;
|
||||||
|
|
||||||
|
use App\Models\Institution;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||||
|
use OwenIt\Auditing\Contracts\Auditable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup delle categorie di istituzione.
|
||||||
|
*
|
||||||
|
* Tabella controllata: gli id sono fissi e coincidono con quelli del DB legacy
|
||||||
|
* (v1) così l'ETL mappa institutions.category_id 1:1 senza tradurre. La categoria
|
||||||
|
* legacy 1 ("uncategorized", mai usata) NON viene riportata.
|
||||||
|
*/
|
||||||
|
class InstitutionCategory extends Model implements Auditable
|
||||||
|
{
|
||||||
|
use AuditableTrait;
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
// Id allineati al legacy (vedi seeder). Nessun id 1 e nessun id 5.
|
||||||
|
public const LIBRARY = 'library';
|
||||||
|
|
||||||
|
public const MUSEUM = 'museum';
|
||||||
|
|
||||||
|
public const PUBLIC_ADMINISTRATION = 'public administration';
|
||||||
|
|
||||||
|
public const RESEARCH_INSTITUTE = 'research institute';
|
||||||
|
|
||||||
|
protected $table = 'institution_categories';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'value',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Istituzioni che appartengono a questa categoria.
|
||||||
|
*
|
||||||
|
* @return HasMany<Institution, $this>
|
||||||
|
*/
|
||||||
|
public function institutions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Institution::class, 'category_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La categoria è assegnata ad almeno un'istituzione.
|
||||||
|
*/
|
||||||
|
public function isInUse(): bool
|
||||||
|
{
|
||||||
|
return $this->institutions()->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
99
backend/app/Models/Lists/License.php
Normal file
99
backend/app/Models/Lists/License.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Lists;
|
||||||
|
|
||||||
|
use App\Enums\LicenseKind;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||||
|
use OwenIt\Auditing\Contracts\Auditable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vocabolario delle licenze per record (artifact/model) e media.
|
||||||
|
*
|
||||||
|
* Tabella controllata, popolata dal LicenseSeeder in installazione (non
|
||||||
|
* dall'ETL). Gli id 1-3 sono FISSI e coincidono con il DB legacy (v1:
|
||||||
|
* 1=Public Domain, 2=CC0, 3=CC BY) così l'ETL mapperà le FK dei contenuti
|
||||||
|
* 1:1 senza tradurre.
|
||||||
|
*
|
||||||
|
* Il ritiro di una licenza si fa con `active = false` (le select dei form la
|
||||||
|
* nascondono), mai cancellando la riga: i contenuti già licenziati la
|
||||||
|
* referenziano.
|
||||||
|
*/
|
||||||
|
class License extends Model implements Auditable
|
||||||
|
{
|
||||||
|
use AuditableTrait;
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'acronym',
|
||||||
|
'spdx_id',
|
||||||
|
'kind',
|
||||||
|
'description',
|
||||||
|
'uri',
|
||||||
|
'file',
|
||||||
|
'for_record',
|
||||||
|
'for_media',
|
||||||
|
'sort_order',
|
||||||
|
'active',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'kind' => LicenseKind::class,
|
||||||
|
'for_record' => 'boolean',
|
||||||
|
'for_media' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
'active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Licenze ammesse per i record (artifact/model): solo quelle aperte.
|
||||||
|
*
|
||||||
|
* @param Builder<License> $query
|
||||||
|
*/
|
||||||
|
public function scopeForRecord(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('for_record', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Licenze ammesse per i media (immagini, documenti, video, modelli 3D).
|
||||||
|
*
|
||||||
|
* @param Builder<License> $query
|
||||||
|
*/
|
||||||
|
public function scopeForMedia(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('for_media', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solo le licenze attive (proposte nei form di inserimento/modifica).
|
||||||
|
*
|
||||||
|
* @param Builder<License> $query
|
||||||
|
*/
|
||||||
|
public function scopeActive(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La licenza è assegnata ad almeno un contenuto.
|
||||||
|
*
|
||||||
|
* NB: oggi nessuna tabella referenzia `licenses`, quindi nessuna riga
|
||||||
|
* risulta in uso; quando arriveranno le tabelle di artifact/model/media
|
||||||
|
* (con FK su `licenses`) questo metodo dovrà controllarne l'esistenza,
|
||||||
|
* come fa InstitutionCategory::isInUse().
|
||||||
|
*/
|
||||||
|
public function isInUse(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/app/Models/Lists/UserPosition.php
Normal file
28
backend/app/Models/Lists/UserPosition.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Lists;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||||
|
use OwenIt\Auditing\Contracts\Auditable;
|
||||||
|
|
||||||
|
class UserPosition extends Model implements Auditable
|
||||||
|
{
|
||||||
|
use AuditableTrait;
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const PROFESSOR = 'Professor';
|
||||||
|
|
||||||
|
public const RESEARCHER = 'Researcher';
|
||||||
|
|
||||||
|
public const PHD = 'PhD';
|
||||||
|
|
||||||
|
public const STUDENT = 'Student';
|
||||||
|
|
||||||
|
public const ADMINISTRATIVE = 'Administrative personnel';
|
||||||
|
|
||||||
|
protected $table = 'user_positions';
|
||||||
|
|
||||||
|
protected $fillable = ['value'];
|
||||||
|
}
|
||||||
41
backend/app/Models/Lists/UserRole.php
Normal file
41
backend/app/Models/Lists/UserRole.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Lists;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||||
|
use OwenIt\Auditing\Contracts\Auditable;
|
||||||
|
|
||||||
|
class UserRole extends Model implements Auditable
|
||||||
|
{
|
||||||
|
use AuditableTrait;
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const ADMIN = 'Admin';
|
||||||
|
|
||||||
|
public const SUPERVISOR = 'Supervisor';
|
||||||
|
|
||||||
|
public const USER = 'User';
|
||||||
|
|
||||||
|
public const GUEST = 'Guest';
|
||||||
|
|
||||||
|
protected $table = 'user_roles';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utenti che hanno questo ruolo.
|
||||||
|
*
|
||||||
|
* @return HasMany<User, $this>
|
||||||
|
*/
|
||||||
|
public function users(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class, 'role_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,60 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use App\Models\Lists\UserRole;
|
||||||
use Database\Factories\UserFactory;
|
use App\Notifications\ResetPasswordNotification;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
use OwenIt\Auditing\Contracts\Auditable;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password'])]
|
class User extends Authenticatable implements Auditable, MustVerifyEmail
|
||||||
#[Hidden(['password', 'remember_token'])]
|
|
||||||
class User extends Authenticatable
|
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
||||||
|
|
||||||
|
use \OwenIt\Auditing\Auditable;
|
||||||
|
use TwoFactorAuthenticatable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $appends = ['setup_status'];
|
||||||
|
|
||||||
|
protected $auditExclude = ['password', 'remember_token', 'two_factor_secret'];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
'role_id',
|
||||||
|
'is_system',
|
||||||
|
'must_change_password',
|
||||||
|
'two_factor_secret',
|
||||||
|
'two_factor_recovery_codes',
|
||||||
|
'two_factor_setup_completed_at',
|
||||||
|
'email_verified_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be hidden for serialization.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
'two_factor_secret',
|
||||||
|
'two_factor_recovery_codes',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
@@ -27,6 +67,54 @@ class User extends Authenticatable
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'role_id' => 'integer',
|
||||||
|
'must_change_password' => 'boolean',
|
||||||
|
'two_factor_setup_completed_at' => 'datetime',
|
||||||
|
'two_factor_confirmed_at' => 'datetime',
|
||||||
|
'is_system' => 'boolean',
|
||||||
|
'anonymized_at' => 'datetime',
|
||||||
|
'disabled_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruolo applicativo dell'utente.
|
||||||
|
*
|
||||||
|
* @return BelongsTo<UserRole, $this>
|
||||||
|
*/
|
||||||
|
public function role(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(UserRole::class, 'role_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storico delle affiliazioni (incarichi) di questo utente.
|
||||||
|
*
|
||||||
|
* @return HasMany<UserAffiliation, $this>
|
||||||
|
*/
|
||||||
|
public function affiliations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserAffiliation::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the password reset notification.
|
||||||
|
*/
|
||||||
|
public function sendPasswordResetNotification($token)
|
||||||
|
{
|
||||||
|
$this->notify(new ResetPasswordNotification($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSetupStatusAttribute(): string
|
||||||
|
{
|
||||||
|
if ($this->must_change_password) {
|
||||||
|
return 'password_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_null($this->two_factor_setup_completed_at)) {
|
||||||
|
return '2fa_setup_required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'complete';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
backend/app/Models/UserAffiliation.php
Normal file
97
backend/app/Models/UserAffiliation.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserPosition;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||||
|
use OwenIt\Auditing\Contracts\Auditable;
|
||||||
|
|
||||||
|
class UserAffiliation extends Model implements Auditable
|
||||||
|
{
|
||||||
|
use AuditableTrait;
|
||||||
|
use HasFactory;
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'institution_id',
|
||||||
|
'user_id',
|
||||||
|
'user_position_id',
|
||||||
|
'start_year',
|
||||||
|
'end_year',
|
||||||
|
'legacy_user_id',
|
||||||
|
'legacy_institution_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookkeeping ETL, non rilevanti per l'admin UI.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'legacy_user_id',
|
||||||
|
'legacy_institution_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'institution_id' => 'integer',
|
||||||
|
'user_id' => 'integer',
|
||||||
|
'user_position_id' => 'integer',
|
||||||
|
'start_year' => 'integer',
|
||||||
|
'end_year' => 'integer',
|
||||||
|
'is_open' => 'boolean',
|
||||||
|
'legacy_user_id' => 'integer',
|
||||||
|
'legacy_institution_id' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Institution, $this>
|
||||||
|
*/
|
||||||
|
public function institution(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Institution::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<UserPosition, $this>
|
||||||
|
*/
|
||||||
|
public function userPosition(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(UserPosition::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esiste già un'altra affiliazione aperta per lo stesso ente+utente: un
|
||||||
|
* restore qui urterebbe l'indice unique su (institution_id, user_id, is_open).
|
||||||
|
*/
|
||||||
|
public function hasConflictingOpenAffiliation(): bool
|
||||||
|
{
|
||||||
|
if (filled($this->end_year)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::query()
|
||||||
|
->where('institution_id', $this->institution_id)
|
||||||
|
->where('user_id', $this->user_id)
|
||||||
|
->where('id', '!=', $this->id)
|
||||||
|
->whereNotNull('is_open')
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/app/Notifications/ResetPasswordNotification.php
Normal file
54
backend/app/Notifications/ResetPasswordNotification.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class ResetPasswordNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct(public readonly string $token)
|
||||||
|
{
|
||||||
|
// Il token viene passato al costruttore e reso disponibile come proprietà pubblica
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->line('The introduction to the notification.')
|
||||||
|
->action('Notification Action', url('/'))
|
||||||
|
->line('Thank you for using our application!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
53
backend/app/Notifications/WelcomeNotification.php
Normal file
53
backend/app/Notifications/WelcomeNotification.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
class WelcomeNotification extends Notification
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new notification instance.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->line('The introduction to the notification.')
|
||||||
|
->action('Notification Action', url('/'))
|
||||||
|
->line('Thank you for using our application!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -19,6 +22,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
// Rate limiter delle rotte pubbliche (gruppo `throttle:public` in routes/api.php).
|
||||||
|
// Nessun utente loggato → si limita per IP. Tarare il numero sul traffico reale.
|
||||||
|
RateLimiter::for('public', fn (Request $request) => Limit::perMinute(60)->by($request->ip()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
backend/app/Providers/FortifyServiceProvider.php
Normal file
58
backend/app/Providers/FortifyServiceProvider.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Actions\Fortify\AuthenticateUser;
|
||||||
|
use App\Actions\Fortify\CreateNewUser;
|
||||||
|
use App\Actions\Fortify\ResetUserPassword;
|
||||||
|
use App\Actions\Fortify\UpdateUserPassword;
|
||||||
|
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
|
||||||
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
|
class FortifyServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
Fortify::authenticateUsing(new AuthenticateUser);
|
||||||
|
Fortify::createUsersUsing(CreateNewUser::class);
|
||||||
|
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||||
|
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||||
|
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||||
|
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
|
||||||
|
|
||||||
|
RateLimiter::for('login', function (Request $request) {
|
||||||
|
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||||
|
|
||||||
|
return Limit::perMinute(5)->by($throttleKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('two-factor', function (Request $request) {
|
||||||
|
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('passkeys', function (Request $request) {
|
||||||
|
$credentialId = $request->input('credential.id');
|
||||||
|
|
||||||
|
return Limit::perMinute(10)->by(
|
||||||
|
($credentialId ?: $request->session()->getId()).'|'.$request->ip()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,51 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsureSetupComplete;
|
||||||
|
use App\Http\Middleware\ForceJsonResponse;
|
||||||
|
use App\Http\Middleware\IsAdmin;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
|
api: __DIR__.'/../routes/api.php', // NOSONAR
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
|
then: function (): void {
|
||||||
|
// Auto-discovery: ogni file in routes/api/ è caricato come gruppo sotto
|
||||||
|
// prefisso `api` e stack `api`. Aggiungere un modello = creare un file lì,
|
||||||
|
// senza toccare questo bootstrap. glob() ordina alfabeticamente → ordine
|
||||||
|
// di registrazione deterministico. (Dopo aver aggiunto un file, se usi la
|
||||||
|
// route cache rilancia `route:cache`, come per qualunque nuova rotta.)
|
||||||
|
foreach (glob(base_path('routes/api/*.php')) as $routeFile) {
|
||||||
|
Route::middleware('api')->prefix('api')->group($routeFile);
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
// per Sanctum
|
||||||
|
$middleware->statefulApi();
|
||||||
|
// App API-only: nessuna rotta 'login' esiste. Senza questo, Laravel usa
|
||||||
|
// il suo default redirectGuestsTo(route('login')) e un utente non
|
||||||
|
// autenticato che richiede una pagina senza Accept: application/json
|
||||||
|
// (es. browser diretto) genera RouteNotFoundException (500) invece di 401.
|
||||||
|
$middleware->redirectGuestsTo(null);
|
||||||
|
// Configurazione CORS
|
||||||
|
$middleware->preventRequestForgery(except: ['api/*']);
|
||||||
|
$middleware->alias([
|
||||||
|
'admin' => IsAdmin::class,
|
||||||
|
'setup.complete' => EnsureSetupComplete::class,
|
||||||
|
]);
|
||||||
|
// Tier riusabili nei file di routes/api/ — fonte di verità unica.
|
||||||
|
// (nomi `tier.*` per non confliggere con l'alias `admin`.)
|
||||||
|
$middleware->group('tier.app', ['auth:sanctum', 'verified', 'setup.complete']);
|
||||||
|
$middleware->group('tier.admin', ['auth:sanctum', 'verified', 'admin', 'setup.complete']);
|
||||||
|
$middleware->api(append: [ForceJsonResponse::class]);
|
||||||
|
$middleware->trustProxies(at: '*');
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
use App\Providers\AppServiceProvider;
|
||||||
|
use App\Providers\FortifyServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
|
FortifyServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"name": "laravel/laravel",
|
"name": "laravel/laravel",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "The skeleton application for the Laravel framework.",
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework", "v.13.15.0"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
|
|||||||
204
backend/config/audit.php
Normal file
204
backend/config/audit.php
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use OwenIt\Auditing\Models\Audit;
|
||||||
|
use OwenIt\Auditing\Resolvers\IpAddressResolver;
|
||||||
|
use OwenIt\Auditing\Resolvers\UrlResolver;
|
||||||
|
use OwenIt\Auditing\Resolvers\UserAgentResolver;
|
||||||
|
use OwenIt\Auditing\Resolvers\UserResolver;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'enabled' => env('AUDITING_ENABLED', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Implementation
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Define which Audit model implementation should be used.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'implementation' => Audit::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| User Morph prefix & Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Define the morph prefix and authentication guards for the User resolver.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'user' => [
|
||||||
|
'morph_prefix' => 'user',
|
||||||
|
'guards' => [
|
||||||
|
'web',
|
||||||
|
'api',
|
||||||
|
],
|
||||||
|
'resolver' => UserResolver::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Resolvers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Define the IP Address, User Agent and URL resolver implementations.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'resolvers' => [
|
||||||
|
'ip_address' => IpAddressResolver::class,
|
||||||
|
'user_agent' => UserAgentResolver::class,
|
||||||
|
'url' => UrlResolver::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Events
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The Eloquent events that trigger an Audit.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'events' => [
|
||||||
|
'created',
|
||||||
|
'updated',
|
||||||
|
'deleted',
|
||||||
|
'restored',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Strict Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Enable the strict mode when auditing?
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'strict' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Global exclude
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Have something you always want to exclude by default? - add it here.
|
||||||
|
| Note that this is overwritten (not merged) with local exclude
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'exclude' => [],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Empty Values
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Should Audit records be stored when the recorded old_values & new_values
|
||||||
|
| are both empty?
|
||||||
|
|
|
||||||
|
| Some events may be empty on purpose. Use allowed_empty_values to exclude
|
||||||
|
| those from the empty values check. For example when auditing
|
||||||
|
| model retrieved events which will never have new and old values.
|
||||||
|
|
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'empty_values' => true,
|
||||||
|
'allowed_empty_values' => [
|
||||||
|
'retrieved',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Allowed Array Values
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Should the array values be audited?
|
||||||
|
|
|
||||||
|
| By default, array values are not allowed. This is to prevent performance
|
||||||
|
| issues when storing large amounts of data. You can override this by
|
||||||
|
| setting allow_array_values to true.
|
||||||
|
*/
|
||||||
|
'allowed_array_values' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Timestamps
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Should the created_at, updated_at and deleted_at timestamps be audited?
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'timestamps' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Threshold
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Specify a threshold for the amount of Audit records a model can have.
|
||||||
|
| Zero means no limit.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'threshold' => 0,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The default audit driver used to keep track of changes.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => 'database',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Driver Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Available audit drivers and respective configurations.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'drivers' => [
|
||||||
|
'database' => [
|
||||||
|
'table' => 'audits',
|
||||||
|
'connection' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Queue Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Available audit queue configurations.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'queue' => [
|
||||||
|
'enable' => false,
|
||||||
|
'connection' => 'sync',
|
||||||
|
'queue' => 'default',
|
||||||
|
'delay' => 0,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Audit Console
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Whether console events should be audited (eg. php artisan db:seed).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'console' => false,
|
||||||
|
];
|
||||||
45
backend/config/cors.php
Normal file
45
backend/config/cors.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your settings for cross-origin resource sharing
|
||||||
|
| or "CORS". This determines what cross-origin operations may execute
|
||||||
|
| in web browsers. You are free to adjust these settings as needed.
|
||||||
|
|
|
||||||
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Le rotte Fortify (login/logout/2FA/reset) sono sotto prefisso `api/`,
|
||||||
|
// quindi `api/*` le copre tutte; `sanctum/csrf-cookie` rilascia il cookie XSRF.
|
||||||
|
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||||
|
|
||||||
|
'allowed_methods' => ['*'],
|
||||||
|
|
||||||
|
// Origini autorizzate per le richieste cross-origin con credenziali (cookie).
|
||||||
|
// Con `supports_credentials => true` NON è ammesso il wildcard `*`: serve la
|
||||||
|
// lista esatta delle origini. In dev l'unica che porta il cookie di sessione è
|
||||||
|
// APP_URL, perché SESSION_DOMAIN è scopato a `.dyncoll-dev.local`.
|
||||||
|
// FRONTEND_URL (se valorizzato) fa da override e accetta più origini separate
|
||||||
|
// da virgola; altrimenti si usa APP_URL.
|
||||||
|
'allowed_origins' => array_values(array_filter(array_map(
|
||||||
|
'trim',
|
||||||
|
explode(',', (string) env('FRONTEND_URL', env('APP_URL', 'https://dyncoll-dev.local')))
|
||||||
|
))),
|
||||||
|
|
||||||
|
'allowed_origins_patterns' => [],
|
||||||
|
|
||||||
|
'allowed_headers' => ['*'],
|
||||||
|
|
||||||
|
'exposed_headers' => [],
|
||||||
|
|
||||||
|
'max_age' => 0,
|
||||||
|
|
||||||
|
'supports_credentials' => true,
|
||||||
|
|
||||||
|
];
|
||||||
@@ -17,7 +17,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
'default' => env('DB_CONNECTION', 'mysql'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@@ -84,6 +84,31 @@ return [
|
|||||||
]) : [],
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sorgente legacy (dyncoll v1) in sola lettura per l'ETL `php artisan v1:import`.
|
||||||
|
* Le DB_LEGACY_* NON stanno in .env.example: si impostano nell'ambiente che esegue
|
||||||
|
* l'import (dev: dump di produzione restorato in locale; cutover: DB v1 reale).
|
||||||
|
* `strict=false` per tollerare il sql_mode del DB v1.
|
||||||
|
*/
|
||||||
|
'legacy' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DB_LEGACY_URL'),
|
||||||
|
'host' => env('DB_LEGACY_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_LEGACY_PORT', '3306'),
|
||||||
|
'database' => env('DB_LEGACY_DATABASE', 'dyncoll'),
|
||||||
|
'username' => env('DB_LEGACY_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_LEGACY_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_LEGACY_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_LEGACY_COLLATION', 'utf8mb4_0900_ai_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => false,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
Mysql::ATTR_SSL_CA => env('DB_LEGACY_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
'pgsql' => [
|
'pgsql' => [
|
||||||
'driver' => 'pgsql',
|
'driver' => 'pgsql',
|
||||||
'url' => env('DB_URL'),
|
'url' => env('DB_URL'),
|
||||||
|
|||||||
160
backend/config/fortify.php
Normal file
160
backend/config/fortify.php
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Guard
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which authentication guard Fortify will use while
|
||||||
|
| authenticating users. This value should correspond with one of your
|
||||||
|
| guards that is already present in your "auth" configuration file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guard' => 'web',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Password Broker
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which password broker Fortify can use when a user
|
||||||
|
| is resetting their password. This configured value should match one
|
||||||
|
| of your password brokers setup in your "auth" configuration file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => 'users',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Username / Email
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value defines which model attribute should be considered as your
|
||||||
|
| application's "username" field. Typically, this might be the email
|
||||||
|
| address of the users but you are free to change this value here.
|
||||||
|
|
|
||||||
|
| Out of the box, Fortify expects forgot password and reset password
|
||||||
|
| requests to have a field named 'email'. If the application uses
|
||||||
|
| another name for the field you may define it below as needed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'username' => 'email',
|
||||||
|
|
||||||
|
'email' => 'email',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Lowercase Usernames
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value defines whether usernames should be lowercased before saving
|
||||||
|
| them in the database, as some database system string fields are case
|
||||||
|
| sensitive. You may disable this for your application if necessary.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lowercase_usernames' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Home Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the path where users will get redirected during
|
||||||
|
| authentication or password reset when the operations are successful
|
||||||
|
| and the user is authenticated. You are free to change this value.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'home' => '/home',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Routes Prefix / Subdomain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which prefix Fortify will assign to all the routes
|
||||||
|
| that it registers with the application. If necessary, you may change
|
||||||
|
| subdomain under which all of the Fortify routes will be available.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => 'api',
|
||||||
|
|
||||||
|
'domain' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fortify Routes Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which middleware Fortify will assign to the routes
|
||||||
|
| that it registers with the application. If necessary, you may change
|
||||||
|
| these middleware but typically this provided default is preferred.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Rate Limiting
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By default, Fortify will throttle logins to five requests per minute for
|
||||||
|
| every email and IP address combination. However, if you would like to
|
||||||
|
| specify a custom rate limiter to call then you may specify it here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'limiters' => [
|
||||||
|
'login' => 'login',
|
||||||
|
'two-factor' => 'two-factor',
|
||||||
|
'passkeys' => 'passkeys',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Register View Routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify if the routes returning views should be disabled as
|
||||||
|
| you may not need them when building your own application. This may be
|
||||||
|
| especially true if you're writing a custom single-page application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'views' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Features
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Some of the Fortify features are optional. You may disable the features
|
||||||
|
| by removing them from this array. You're free to only remove some of
|
||||||
|
| these features or you can even remove all of these if you need to.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'features' => [
|
||||||
|
Features::registration(),
|
||||||
|
Features::resetPasswords(),
|
||||||
|
Features::emailVerification(),
|
||||||
|
Features::updateProfileInformation(),
|
||||||
|
Features::updatePasswords(),
|
||||||
|
Features::twoFactorAuthentication([
|
||||||
|
'confirm' => true,
|
||||||
|
'confirmPassword' => true,
|
||||||
|
// 'window' => 0,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
37
backend/config/messages.php
Normal file
37
backend/config/messages.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Messaggi CRUD generici
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Usati dal trait App\Http\Traits\ApiResponse quando non viene passato un
|
||||||
|
| prefisso specifico per risorsa. Per messaggi dedicati a una risorsa basta
|
||||||
|
| aggiungere un sotto-array (es. 'user_roles' => ['created' => '...']).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'index' => 'List retrieved successfully.',
|
||||||
|
'show' => 'Resource retrieved successfully.',
|
||||||
|
'created' => 'Resource created successfully.',
|
||||||
|
'updated' => 'Resource updated successfully.',
|
||||||
|
'deleted' => 'Resource deleted successfully.',
|
||||||
|
'restored' => 'Resource restored successfully.',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Autenticazione / account
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'auth' => [
|
||||||
|
'logout_success' => 'Logged out successfully.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'account' => [
|
||||||
|
'deleted' => 'Account deleted successfully.',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
102
backend/config/sanctum.php
Normal file
102
backend/config/sanctum.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||||
|
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Stateful Domains
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Requests from the following domains / hosts will receive stateful API
|
||||||
|
| authentication cookies. Typically, these should include your local
|
||||||
|
| and production domains which access your API via a frontend SPA.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stateful' => explode(
|
||||||
|
',',
|
||||||
|
env(
|
||||||
|
'SANCTUM_STATEFUL_DOMAINS',
|
||||||
|
sprintf(
|
||||||
|
'%s%s',
|
||||||
|
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||||
|
Sanctum::currentApplicationUrlWithPort(),
|
||||||
|
// Sanctum::currentRequestHost(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This array contains the authentication guards that will be checked when
|
||||||
|
| Sanctum is trying to authenticate a request. If none of these guards
|
||||||
|
| are able to authenticate the request, Sanctum will use the bearer
|
||||||
|
| token that's present on an incoming request for authentication.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guards' => [
|
||||||
|
'web' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
'sanctum' => [
|
||||||
|
'driver' => 'sanctum',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Expiration Minutes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value controls the number of minutes until an issued token will be
|
||||||
|
| considered expired. This will override any values set in the token's
|
||||||
|
| "expires_at" attribute, but first-party sessions are not affected.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Token Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||||
|
| security scanning initiatives maintained by open source platforms
|
||||||
|
| that notify developers if they commit tokens into repositories.
|
||||||
|
|
|
||||||
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sanctum Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When authenticating your first-party SPA with Sanctum you may need to
|
||||||
|
| customize some of the middleware Sanctum uses while processing the
|
||||||
|
| request. You may change the middleware listed below as required.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => [
|
||||||
|
'authenticate_session' => AuthenticateSession::class,
|
||||||
|
'encrypt_cookies' => EncryptCookies::class,
|
||||||
|
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
34
backend/database/factories/InstitutionFactory.php
Normal file
34
backend/database/factories/InstitutionFactory.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Institution;
|
||||||
|
use App\Models\Lists\InstitutionCategory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Institution>
|
||||||
|
*/
|
||||||
|
class InstitutionFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Institution::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category_id' => InstitutionCategory::factory(),
|
||||||
|
'name' => fake()->unique()->company(),
|
||||||
|
'abbreviation' => strtoupper(fake()->unique()->lexify('?????')),
|
||||||
|
'address' => fake()->streetAddress(),
|
||||||
|
'city' => fake()->city(),
|
||||||
|
'lat' => fake()->latitude(),
|
||||||
|
'lon' => fake()->longitude(),
|
||||||
|
'logo' => 'default.jpg',
|
||||||
|
'color' => '#c5cae9',
|
||||||
|
'is_storage_place' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/database/factories/InstitutionLinkFactory.php
Normal file
30
backend/database/factories/InstitutionLinkFactory.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\InstitutionLinkType;
|
||||||
|
use App\Models\Institution;
|
||||||
|
use App\Models\InstitutionLink;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<InstitutionLink>
|
||||||
|
*/
|
||||||
|
class InstitutionLinkFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = InstitutionLink::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'institution_id' => Institution::factory(),
|
||||||
|
'url' => fake()->url(),
|
||||||
|
'type' => InstitutionLinkType::Official,
|
||||||
|
'label' => null,
|
||||||
|
'sort_order' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories\Lists;
|
||||||
|
|
||||||
|
use App\Models\Lists\InstitutionCategory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<InstitutionCategory>
|
||||||
|
*/
|
||||||
|
class InstitutionCategoryFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = InstitutionCategory::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'value' => fake()->unique()->word(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
backend/database/factories/Lists/LicenseFactory.php
Normal file
63
backend/database/factories/Lists/LicenseFactory.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories\Lists;
|
||||||
|
|
||||||
|
use App\Enums\LicenseKind;
|
||||||
|
use App\Models\Lists\License;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<License>
|
||||||
|
*/
|
||||||
|
class LicenseFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = License::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ucfirst(fake()->unique()->words(3, true)),
|
||||||
|
'acronym' => strtoupper(fake()->unique()->bothify('??-#.#')),
|
||||||
|
'spdx_id' => null,
|
||||||
|
'kind' => LicenseKind::License,
|
||||||
|
'description' => fake()->sentence(),
|
||||||
|
'uri' => fake()->url(),
|
||||||
|
'file' => null,
|
||||||
|
'for_record' => false,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => fake()->numberBetween(0, 100),
|
||||||
|
'active' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Licenza aperta ammessa anche per i record.
|
||||||
|
*/
|
||||||
|
public function forRecord(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => ['for_record' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rights statement (nessun testo legale, nessun id SPDX).
|
||||||
|
*/
|
||||||
|
public function rightsStatement(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'kind' => LicenseKind::RightsStatement,
|
||||||
|
'spdx_id' => null,
|
||||||
|
'file' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Licenza ritirata dagli admin (esclusa dalle select dei form).
|
||||||
|
*/
|
||||||
|
public function inactive(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => ['active' => false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/database/factories/Lists/UserPositionFactory.php
Normal file
26
backend/database/factories/Lists/UserPositionFactory.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories\Lists;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserPosition;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<UserPosition>
|
||||||
|
*/
|
||||||
|
class UserPositionFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected $model = UserPosition::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'value' => fake()->unique()->word(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/database/factories/Lists/UserRoleFactory.php
Normal file
25
backend/database/factories/Lists/UserRoleFactory.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories\Lists;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<UserRole>
|
||||||
|
*/
|
||||||
|
class UserRoleFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = UserRole::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ucfirst($this->faker->unique()->word()),
|
||||||
|
'description' => $this->faker->sentence(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/database/factories/UserAffiliationFactory.php
Normal file
33
backend/database/factories/UserAffiliationFactory.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Institution;
|
||||||
|
use App\Models\Lists\UserPosition;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserAffiliation;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<UserAffiliation>
|
||||||
|
*/
|
||||||
|
class UserAffiliationFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = UserAffiliation::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'institution_id' => Institution::factory(),
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'user_position_id' => UserPosition::factory(),
|
||||||
|
'start_year' => fake()->numberBetween(1980, (int) date('Y')),
|
||||||
|
'end_year' => null,
|
||||||
|
'legacy_user_id' => null,
|
||||||
|
'legacy_institution_id' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_roles', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->unique();
|
||||||
|
$table->string('description')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_roles');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('role_id')->nullable()->after('id');
|
||||||
|
$table->foreign('role_id')->references('id')->on('user_roles')->onDelete('set null');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['role_id']);
|
||||||
|
$table->dropColumn('role_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->boolean('must_change_password')->default(true)->after('password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('must_change_password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
// Utente di sistema: riceve i contenuti riassegnati e non è eliminabile.
|
||||||
|
$table->boolean('is_system')->default(false)->after('role_id');
|
||||||
|
|
||||||
|
// Traccia l'avvenuta anonimizzazione (GDPR). Il record resta ai fini
|
||||||
|
// di integrità referenziale ma esce dal cestino e dalle liste utenti.
|
||||||
|
$table->timestamp('anonymized_at')->nullable()->after('deleted_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['is_system', 'anonymized_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->text('two_factor_secret')
|
||||||
|
->after('password')
|
||||||
|
->nullable();
|
||||||
|
|
||||||
|
$table->text('two_factor_recovery_codes')
|
||||||
|
->after('two_factor_secret')
|
||||||
|
->nullable();
|
||||||
|
|
||||||
|
$table->timestamp('two_factor_confirmed_at')
|
||||||
|
->after('two_factor_recovery_codes')
|
||||||
|
->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'two_factor_secret',
|
||||||
|
'two_factor_recovery_codes',
|
||||||
|
'two_factor_confirmed_at',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$connection = config('audit.drivers.database.connection', config('database.default'));
|
||||||
|
$table = config('audit.drivers.database.table', 'audits');
|
||||||
|
|
||||||
|
Schema::connection($connection)->create($table, function (Blueprint $table) {
|
||||||
|
|
||||||
|
$morphPrefix = config('audit.user.morph_prefix', 'user');
|
||||||
|
|
||||||
|
$table->bigIncrements('id');
|
||||||
|
$table->string($morphPrefix.'_type')->nullable();
|
||||||
|
$table->unsignedBigInteger($morphPrefix.'_id')->nullable();
|
||||||
|
$table->string('event');
|
||||||
|
$table->morphs('auditable');
|
||||||
|
$table->text('old_values')->nullable();
|
||||||
|
$table->text('new_values')->nullable();
|
||||||
|
$table->text('url')->nullable();
|
||||||
|
$table->ipAddress('ip_address')->nullable();
|
||||||
|
$table->string('user_agent', 1023)->nullable();
|
||||||
|
$table->string('tags')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index([$morphPrefix.'_id', $morphPrefix.'_type']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$connection = config('audit.drivers.database.connection', config('database.default'));
|
||||||
|
$table = config('audit.drivers.database.table', 'audits');
|
||||||
|
|
||||||
|
Schema::connection($connection)->drop($table);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->timestamp('two_factor_setup_completed_at')
|
||||||
|
->nullable()
|
||||||
|
->after('two_factor_confirmed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('two_factor_setup_completed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('institution_categories', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('value', 25)->unique();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('institution_categories');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('institutions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('category_id')
|
||||||
|
->constrained('institution_categories')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->restrictOnDelete();
|
||||||
|
$table->string('name')->unique();
|
||||||
|
$table->string('abbreviation', 5)->unique();
|
||||||
|
$table->string('address');
|
||||||
|
$table->string('city', 100)->index();
|
||||||
|
$table->decimal('lat', 10, 6);
|
||||||
|
$table->decimal('lon', 10, 6);
|
||||||
|
$table->string('logo');
|
||||||
|
$table->string('color', 50)->default('#c5cae9');
|
||||||
|
$table->uuid('uuid')->unique();
|
||||||
|
$table->boolean('is_storage_place')->default(true);
|
||||||
|
// Id del record nel DB legacy (v1): chiave di upsert per l'ETL e per
|
||||||
|
// risolvere le FK delle tabelle dipendenti (es. artifact.owner).
|
||||||
|
$table->unsignedBigInteger('legacy_id')->nullable()->unique();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('institutions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('institution_links', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('institution_id')
|
||||||
|
->constrained()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->string('url', 2000);
|
||||||
|
// Vincolato a App\Enums\InstitutionLinkType (cast + validazione lato app).
|
||||||
|
$table->string('type', 30);
|
||||||
|
$table->string('label', 100)->nullable();
|
||||||
|
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['institution_id', 'sort_order']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('institution_links');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_positions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('value', 25)->unique();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_positions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_affiliations', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('institution_id')
|
||||||
|
->constrained('institutions')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')
|
||||||
|
->constrained('users')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_position_id')
|
||||||
|
->constrained('user_positions')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->integer('start_year');
|
||||||
|
$table->integer('end_year')->nullable();
|
||||||
|
$table->softDeletes();
|
||||||
|
// Aperta = end_year nullo E non cestinata: un soft delete libera lo slot
|
||||||
|
// per un nuovo incarico aperto, senza dover aspettare un restore/force.
|
||||||
|
$table->boolean('is_open')->virtualAs('IF(`end_year` IS NULL AND `deleted_at` IS NULL, 1, NULL)');
|
||||||
|
// Legacy: in v1 affiliazione/posizione vivevano dentro "person" (un solo
|
||||||
|
// incarico per persona), qui normalizzate per permettere più affiliazioni.
|
||||||
|
// legacy_user_id identifica univocamente la riga "person" d'origine (chiave
|
||||||
|
// di upsert per l'ETL); legacy_institution_id è solo un riferimento
|
||||||
|
// informativo, condiviso da più affiliazioni verso lo stesso ente.
|
||||||
|
$table->unsignedBigInteger('legacy_user_id')->nullable()->unique();
|
||||||
|
$table->unsignedBigInteger('legacy_institution_id')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->unique(['institution_id', 'user_id', 'is_open']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_affiliations');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
// Id del record nel DB legacy (v1): chiave di upsert per l'ETL e per
|
||||||
|
// risolvere le FK delle tabelle dipendenti (come institutions.legacy_id).
|
||||||
|
$table->unsignedBigInteger('legacy_id')->nullable()->unique()->after('id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('legacy_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
// Login disabilitato (es. legacy is_active=false): blocco reversibile
|
||||||
|
// dell'accesso, indipendente da deleted_at (cestino) e anonymized_at
|
||||||
|
// (GDPR) — un utente disabilitato resta a tutti gli effetti nelle
|
||||||
|
// liste/affiliazioni, solo non può autenticarsi.
|
||||||
|
$table->timestamp('disabled_at')->nullable()->after('anonymized_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('disabled_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Vocabolario delle licenze applicabili a record (artifact/model) e media.
|
||||||
|
*
|
||||||
|
* - `spdx_id`: identificatore SPDX ufficiale, NULL per marchi e rights
|
||||||
|
* statement (che non stanno nella SPDX License List) — mai inventarne.
|
||||||
|
* - `uri`: URI canonico della licenza (creativecommons.org /
|
||||||
|
* rightsstatements.org), usato anche nel JSON-LD Linked Art.
|
||||||
|
* - `file`: nome del testo legale in LICENSES/media/ da includere nello
|
||||||
|
* zip di download; NULL dove un testo non esiste (mark/statement).
|
||||||
|
* - `for_record`/`for_media`: scope della licenza, filtrano le select dei
|
||||||
|
* form (i record ammettono solo licenze aperte, i media tutte).
|
||||||
|
* - `active`: disattivazione soft da parte degli admin senza rompere le
|
||||||
|
* FK dei contenuti già licenziati.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('licenses', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name', 200)->unique();
|
||||||
|
$table->string('acronym', 50)->unique();
|
||||||
|
$table->string('spdx_id', 50)->nullable()->unique();
|
||||||
|
$table->string('kind', 20)->default('license');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->string('uri', 2000)->nullable();
|
||||||
|
$table->string('file', 512)->nullable();
|
||||||
|
$table->boolean('for_record')->default(false);
|
||||||
|
$table->boolean('for_media')->default(true);
|
||||||
|
$table->unsignedInteger('sort_order')->default(0);
|
||||||
|
$table->boolean('active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('licenses');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
@@ -11,15 +10,17 @@ class DatabaseSeeder extends Seeder
|
|||||||
use WithoutModelEvents;
|
use WithoutModelEvents;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed the application's database.
|
* Seed dell'applicazione. L'ordine conta: i ruoli prima dell'utente di
|
||||||
|
* sistema (che richiede il ruolo Admin).
|
||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
$this->call([
|
||||||
|
UserRoleSeeder::class,
|
||||||
User::factory()->create([
|
SystemUserSeeder::class,
|
||||||
'name' => 'Test User',
|
InstitutionCategorySeeder::class,
|
||||||
'email' => 'test@example.com',
|
UserPositionSeeder::class,
|
||||||
|
LicenseSeeder::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
backend/database/seeders/InstitutionCategorySeeder.php
Normal file
33
backend/database/seeders/InstitutionCategorySeeder.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Lists\InstitutionCategory;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class InstitutionCategorySeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Categorie di istituzione. Idempotente.
|
||||||
|
*
|
||||||
|
* Gli id sono FISSI e coincidono con il DB legacy (v1) così l'ETL mappa
|
||||||
|
* institutions.category_id 1:1 senza tradurre. La categoria 1 ("uncategorized",
|
||||||
|
* mai usata) e la 5 (inesistente nel legacy) sono volutamente assenti.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$categories = [
|
||||||
|
2 => InstitutionCategory::LIBRARY,
|
||||||
|
3 => InstitutionCategory::MUSEUM,
|
||||||
|
4 => InstitutionCategory::PUBLIC_ADMINISTRATION,
|
||||||
|
6 => InstitutionCategory::RESEARCH_INSTITUTE,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($categories as $id => $value) {
|
||||||
|
InstitutionCategory::updateOrCreate(
|
||||||
|
['id' => $id],
|
||||||
|
['value' => $value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
223
backend/database/seeders/LicenseSeeder.php
Normal file
223
backend/database/seeders/LicenseSeeder.php
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Enums\LicenseKind;
|
||||||
|
use App\Models\Lists\License;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vocabolario delle licenze. Idempotente, gira in installazione (la tabella
|
||||||
|
* NON è popolata dall'ETL).
|
||||||
|
*
|
||||||
|
* Gli id 1-3 sono FISSI e coincidono con il DB legacy (v1: 1=Public Domain,
|
||||||
|
* 2=CC0, 3=CC BY) così l'ETL mapperà le FK di artifact/media 1:1.
|
||||||
|
*
|
||||||
|
* Set iniziale: famiglia Creative Commons 4.0 + Public Domain Mark + i tre
|
||||||
|
* rights statement più comuni (InC, InC-EDU, CNE). Gli altri statement di
|
||||||
|
* RightsStatements.org (InC-NC, NoC-NC, NKC, UND, ...) si aggiungono al
|
||||||
|
* bisogno dal pannello admin.
|
||||||
|
*
|
||||||
|
* `file` è il nome del testo legale in storage/app/public/licenses/ (incluso
|
||||||
|
* nello zip di download dei media e servito al frontend via storage:link);
|
||||||
|
* NULL per mark e rights statement, che non hanno un testo legale. I testi CC
|
||||||
|
* sono dedicati al pubblico dominio da Creative Commons:
|
||||||
|
* https://creativecommons.org/policies/#license
|
||||||
|
*/
|
||||||
|
class LicenseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
foreach ($this->licenses() as $id => $license) {
|
||||||
|
License::updateOrCreate(['id' => $id], $license);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function licenses(): array
|
||||||
|
{
|
||||||
|
return $this->openLicenses() + $this->rightsStatements();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Domain Mark + famiglia Creative Commons 4.0.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function openLicenses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
1 => [
|
||||||
|
'name' => 'Public Domain Mark 1.0',
|
||||||
|
'acronym' => 'PDM 1.0',
|
||||||
|
'spdx_id' => null,
|
||||||
|
'kind' => LicenseKind::Mark,
|
||||||
|
'description' => 'Marks works that are already free of known copyright worldwide (for '
|
||||||
|
.'instance because copyright has expired). It is not a license: it declares '
|
||||||
|
.'an existing status and grants no new permission.',
|
||||||
|
'uri' => 'https://creativecommons.org/publicdomain/mark/1.0/',
|
||||||
|
'file' => null,
|
||||||
|
'for_record' => false,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => 10,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
2 => [
|
||||||
|
'name' => 'CC0 1.0 Universal',
|
||||||
|
'acronym' => 'CC0 1.0',
|
||||||
|
'spdx_id' => 'CC0-1.0',
|
||||||
|
'kind' => LicenseKind::License,
|
||||||
|
'description' => 'Public domain dedication: the rights holder waives all copyright and '
|
||||||
|
.'related rights worldwide. The work can be copied, modified and reused for '
|
||||||
|
.'any purpose, even commercially, without attribution.',
|
||||||
|
'uri' => 'https://creativecommons.org/publicdomain/zero/1.0/',
|
||||||
|
'file' => 'CC0-1.0.txt',
|
||||||
|
'for_record' => true,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => 20,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
3 => [
|
||||||
|
'name' => 'Attribution 4.0 International',
|
||||||
|
'acronym' => 'CC BY 4.0',
|
||||||
|
'spdx_id' => 'CC-BY-4.0',
|
||||||
|
'kind' => LicenseKind::License,
|
||||||
|
'description' => 'Reuse, redistribution and modification allowed for any purpose, including '
|
||||||
|
.'commercial, provided that credit is given to the creator.',
|
||||||
|
'uri' => 'https://creativecommons.org/licenses/by/4.0/',
|
||||||
|
'file' => 'CC-BY-4.0.txt',
|
||||||
|
'for_record' => true,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => 30,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
4 => [
|
||||||
|
'name' => 'Attribution-ShareAlike 4.0 International',
|
||||||
|
'acronym' => 'CC BY-SA 4.0',
|
||||||
|
'spdx_id' => 'CC-BY-SA-4.0',
|
||||||
|
'kind' => LicenseKind::License,
|
||||||
|
'description' => 'Same permissions as CC BY, but derivative works must be distributed under '
|
||||||
|
.'the same license (share-alike).',
|
||||||
|
'uri' => 'https://creativecommons.org/licenses/by-sa/4.0/',
|
||||||
|
'file' => 'CC-BY-SA-4.0.txt',
|
||||||
|
'for_record' => false,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => 40,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
5 => [
|
||||||
|
'name' => 'Attribution-NonCommercial 4.0 International',
|
||||||
|
'acronym' => 'CC BY-NC 4.0',
|
||||||
|
'spdx_id' => 'CC-BY-NC-4.0',
|
||||||
|
'kind' => LicenseKind::License,
|
||||||
|
'description' => 'Reuse and modification allowed with attribution, for non-commercial '
|
||||||
|
.'purposes only.',
|
||||||
|
'uri' => 'https://creativecommons.org/licenses/by-nc/4.0/',
|
||||||
|
'file' => 'CC-BY-NC-4.0.txt',
|
||||||
|
'for_record' => false,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => 50,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
6 => [
|
||||||
|
'name' => 'Attribution-NonCommercial-ShareAlike 4.0 International',
|
||||||
|
'acronym' => 'CC BY-NC-SA 4.0',
|
||||||
|
'spdx_id' => 'CC-BY-NC-SA-4.0',
|
||||||
|
'kind' => LicenseKind::License,
|
||||||
|
'description' => 'Non-commercial reuse and modification with attribution; derivative works '
|
||||||
|
.'must keep the same license.',
|
||||||
|
'uri' => 'https://creativecommons.org/licenses/by-nc-sa/4.0/',
|
||||||
|
'file' => 'CC-BY-NC-SA-4.0.txt',
|
||||||
|
'for_record' => false,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => 60,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
7 => [
|
||||||
|
'name' => 'Attribution-NoDerivatives 4.0 International',
|
||||||
|
'acronym' => 'CC BY-ND 4.0',
|
||||||
|
'spdx_id' => 'CC-BY-ND-4.0',
|
||||||
|
'kind' => LicenseKind::License,
|
||||||
|
'description' => 'Redistribution allowed, even commercially, with attribution, but the work '
|
||||||
|
.'must remain unmodified: no derivative works.',
|
||||||
|
'uri' => 'https://creativecommons.org/licenses/by-nd/4.0/',
|
||||||
|
'file' => 'CC-BY-ND-4.0.txt',
|
||||||
|
'for_record' => false,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => 70,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
8 => [
|
||||||
|
'name' => 'Attribution-NonCommercial-NoDerivatives 4.0 International',
|
||||||
|
'acronym' => 'CC BY-NC-ND 4.0',
|
||||||
|
'spdx_id' => 'CC-BY-NC-ND-4.0',
|
||||||
|
'kind' => LicenseKind::License,
|
||||||
|
'description' => 'The most restrictive CC license: the work can be shared with attribution, '
|
||||||
|
.'but cannot be modified nor used commercially.',
|
||||||
|
'uri' => 'https://creativecommons.org/licenses/by-nc-nd/4.0/',
|
||||||
|
'file' => 'CC-BY-NC-ND-4.0.txt',
|
||||||
|
'for_record' => false,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => 80,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statement di RightsStatements.org: etichettano lo stato dei diritti dei
|
||||||
|
* media chiusi o incerti, non concedono permessi (nessun testo legale,
|
||||||
|
* nessun id SPDX).
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function rightsStatements(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
9 => [
|
||||||
|
'name' => 'In Copyright',
|
||||||
|
'acronym' => 'InC',
|
||||||
|
'spdx_id' => null,
|
||||||
|
'kind' => LicenseKind::RightsStatement,
|
||||||
|
'description' => 'The item is protected by copyright and all rights are reserved. Any reuse '
|
||||||
|
.'beyond legal exceptions requires permission from the rights holder.',
|
||||||
|
'uri' => 'https://rightsstatements.org/vocab/InC/1.0/',
|
||||||
|
'file' => null,
|
||||||
|
'for_record' => false,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => 90,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
10 => [
|
||||||
|
'name' => 'In Copyright - Educational Use Permitted',
|
||||||
|
'acronym' => 'InC-EDU',
|
||||||
|
'spdx_id' => null,
|
||||||
|
'kind' => LicenseKind::RightsStatement,
|
||||||
|
'description' => 'The item is protected by copyright, but the rights holder allows reuse for '
|
||||||
|
.'educational purposes without further permission.',
|
||||||
|
'uri' => 'https://rightsstatements.org/vocab/InC-EDU/1.0/',
|
||||||
|
'file' => null,
|
||||||
|
'for_record' => false,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => 100,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
11 => [
|
||||||
|
'name' => 'Copyright Not Evaluated',
|
||||||
|
'acronym' => 'CNE',
|
||||||
|
'spdx_id' => null,
|
||||||
|
'kind' => LicenseKind::RightsStatement,
|
||||||
|
'description' => 'The copyright status of the item has not been evaluated yet. The item '
|
||||||
|
.'should be treated as potentially protected: reuse may require permission.',
|
||||||
|
'uri' => 'https://rightsstatements.org/vocab/CNE/1.0/',
|
||||||
|
'file' => null,
|
||||||
|
'for_record' => false,
|
||||||
|
'for_media' => true,
|
||||||
|
'sort_order' => 110,
|
||||||
|
'active' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/database/seeders/SystemUserSeeder.php
Normal file
41
backend/database/seeders/SystemUserSeeder.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea l'utente di sistema: riceve i contenuti riassegnati dagli utenti
|
||||||
|
* cancellati (flusso GDPR / oblio) e non è eliminabile né accessibile
|
||||||
|
* (password casuale, nessun login previsto). Idempotente.
|
||||||
|
*
|
||||||
|
* Dipende da UserRoleSeeder (serve il ruolo Admin).
|
||||||
|
*/
|
||||||
|
class SystemUserSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public const EMAIL = 'system@dyncoll.local';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$adminRoleId = UserRole::where('name', UserRole::ADMIN)->value('id');
|
||||||
|
|
||||||
|
User::updateOrCreate(
|
||||||
|
['email' => self::EMAIL],
|
||||||
|
[
|
||||||
|
'name' => 'Dyncoll System',
|
||||||
|
'password' => Hash::make(Str::random(60)),
|
||||||
|
'role_id' => $adminRoleId,
|
||||||
|
'is_system' => true,
|
||||||
|
'must_change_password' => false,
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'two_factor_setup_completed_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->command?->info('✅ System user ready: '.self::EMAIL);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/database/seeders/UserPositionSeeder.php
Normal file
29
backend/database/seeders/UserPositionSeeder.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserPosition;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class UserPositionSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$positions = [
|
||||||
|
UserPosition::PROFESSOR,
|
||||||
|
UserPosition::RESEARCHER,
|
||||||
|
UserPosition::PHD,
|
||||||
|
UserPosition::STUDENT,
|
||||||
|
UserPosition::ADMINISTRATIVE,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($positions as $position) {
|
||||||
|
UserPosition::updateOrCreate(
|
||||||
|
['value' => $position]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/database/seeders/UserRoleSeeder.php
Normal file
42
backend/database/seeders/UserRoleSeeder.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Lists\UserRole;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class UserRoleSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ruoli applicativi di base. Idempotente.
|
||||||
|
* (description in inglese: lingua ufficiale dell'applicazione.)
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
UserRole::updateOrCreate(
|
||||||
|
['name' => UserRole::ADMIN],
|
||||||
|
['description' => 'Full access to all features.'
|
||||||
|
.' Manages users, configurations and global data supervision.'
|
||||||
|
.' Can create, read, update and delete any record.']
|
||||||
|
);
|
||||||
|
|
||||||
|
UserRole::updateOrCreate(
|
||||||
|
['name' => UserRole::SUPERVISOR],
|
||||||
|
['description' => 'Limited access to certain features.'
|
||||||
|
.' Can manage users but not administrators.'
|
||||||
|
.' Can create, read, update and delete both own and other users\' records.']
|
||||||
|
);
|
||||||
|
|
||||||
|
UserRole::updateOrCreate(
|
||||||
|
['name' => UserRole::USER],
|
||||||
|
['description' => 'Day-to-day operational use. Can access all records'
|
||||||
|
.' and create new ones, but can only update or delete their own.']
|
||||||
|
);
|
||||||
|
|
||||||
|
UserRole::updateOrCreate(
|
||||||
|
['name' => UserRole::GUEST],
|
||||||
|
['description' => 'Very limited access. Can only view information'
|
||||||
|
.' not publicly available, with no editing capabilities.']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,19 +18,25 @@
|
|||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<!-- IMPORTANTE: la sicurezza del DB NON dipende da questo file. Nel container
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
le env (DB_HOST=db, DB_DATABASE=dyncoll, APP_ENV=local, ...) sono REALI e
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
finiscono in $_SERVER, che l'env() di Laravel legge PRIMA: il force qui sotto
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
NON le sovrascrive. L'override autoritativo arriva dalle -e di `make test`
|
||||||
<env name="CACHE_STORE" value="array"/>
|
(DB_HOST=db-test, DB_DATABASE=dyncoll_test). Questi force servono solo come
|
||||||
<env name="DB_CONNECTION" value="sqlite"/>
|
rete per i driver non-DB se qualcuno lancia `php artisan test` a mano; il
|
||||||
<env name="DB_DATABASE" value=":memory:"/>
|
redirect verso db-test resta garantito dal guard in Tests\TestCase. -->
|
||||||
<env name="DB_URL" value=""/>
|
<env name="APP_ENV" value="testing" force="true"/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="CACHE_STORE" value="array" force="true"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="DB_CONNECTION" value="mysql" force="true"/>
|
||||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
<env name="DB_DATABASE" value="dyncoll_test" force="true"/>
|
||||||
|
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||||
|
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||||
|
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||||
|
<env name="PULSE_ENABLED" value="false" force="true"/>
|
||||||
|
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||||
|
<env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user