Compare commits

..

8 Commits

Author SHA1 Message Date
Giuseppe Naponiello
271acacb1d license: built full stack, test and documentation
Some checks failed
docs / build (push) Has been cancelled
docs / deploy (push) Has been cancelled
2026-07-03 12:47:47 +02:00
Giuseppe Naponiello
f8206b5840 user disabled_at field addedù 2026-06-24 17:02:11 +02:00
Giuseppe Naponiello
96b0dc76aa user affiliation schema completed 2026-06-24 16:30:57 +02:00
Giuseppe Naponiello
6a07c3e922 user affiliation schema completed 2026-06-24 16:30:21 +02:00
Giuseppe Naponiello
032f6a08df users and institutions route, tests fixed 2026-06-23 12:29:02 +02:00
Giuseppe Naponiello
fe73662903 feature test su institution 2026-06-22 22:14:15 +02:00
Giuseppe Naponiello
cc4e174880 auth tests 2026-06-22 14:31:44 +02:00
Giuseppe Naponiello
34a8ebc6fb setup and render template file, create new logo in different shapes 2026-06-17 22:28:31 +02:00
180 changed files with 12767 additions and 227 deletions

View File

@@ -30,21 +30,18 @@ LOG_LEVEL=debug # warning/error in produzione
# Usati sia da Laravel sia dal container mysql (mappati nel compose).
DB_CONNECTION=mysql
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_USERNAME=dyncoll
DB_PASSWORD= # segreto: compilare
DB_ROOT_PASSWORD= # segreto: solo per il container mysql (root)
# --- Database legacy (v1) — sorgente per l'ETL `php artisan v1:import` --------
# Connessione in SOLA LETTURA verso il MySQL di dyncoll.v1.
# Attiva solo durante l'import; richiede rete docker condivisa o tunnel.
DB_LEGACY_CONNECTION=mysql
DB_LEGACY_HOST=dyncoll_v1_db # container/host del DB v1
DB_LEGACY_PORT=3306
DB_LEGACY_DATABASE=lund
DB_LEGACY_USERNAME=readonly
DB_LEGACY_PASSWORD= # segreto: compilare
# NB: le variabili DB_LEGACY_* (sorgente in SOLA LETTURA per l'ETL
# `php artisan v1:import`) NON stanno qui di proposito: servono solo durante la
# migrazione una-tantum da dyncoll.v1, non nella configurazione standard. Al
# momento del cutover aggiungerle a mano al .env con i valori corretti
# dell'ambiente (host/porta/credenziali possono differire dal locale).
# --- Redis (cache, sessioni, code, Horizon) ----------------------------------
REDIS_HOST=redis
@@ -62,6 +59,10 @@ FILESYSTEM_DISK=local
# --- Autenticazione (Sanctum / Fortify) --------------------------------------
SANCTUM_STATEFUL_DOMAINS=dyncoll-dev.local,localhost,localhost:8080
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) -------------------------------------------------------------
# DEV: Mailpit (servizio nel docker-compose.override.yml). UI: http://localhost:8025

54
.github/workflows/docs.yml vendored Normal file
View 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

View File

@@ -25,7 +25,7 @@ nazionale svedese.
| 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Ä) |
| 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`) |
## 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):
```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 import # ETL da v1 (quando implementato)
make artisan c="test --coverage-clover=coverage.xml" # test backend
make be-update # composer update (host)
make fe-install # npm install host (package-lock + type-check IDE)
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 help # elenco completo
```

121
LICENSES/CC0-1.0.txt Normal file
View 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.

View File

@@ -12,13 +12,17 @@ DC := docker compose
EXEC := $(DC) exec -u $(UID):$(GID) backend
.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
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}'
## --- 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
down: ## Ferma lo stack
$(DC) down
@@ -42,6 +46,20 @@ import: ## ETL da v1 (container)
$(EXEC) php artisan v1:import
tinker: ## REPL artisan (container)
$(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 ---
# 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
fe-build: ## build di produzione su host (smoke test locale)
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 ---
permissions: ## Permessi storage scrivibili da php-fpm (www-data) — richiede sudo, una tantum

View File

@@ -15,7 +15,7 @@ SPDX-License-Identifier = "EUPL-1.2"
# Documentazione utente (sito MkDocs): CC-BY-4.0.
[[annotations]]
path = "documentation/**"
path = ["docs/**", "mkdocs.yml"]
precedence = "override"
SPDX-FileCopyrightText = "2026 Giuseppe Naponiello"
SPDX-License-Identifier = "CC-BY-4.0"
@@ -27,3 +27,16 @@ path = "frontend/public/vendor/3dhop/**"
precedence = "override"
SPDX-FileCopyrightText = "2014-2020 Visual Computing Lab, ISTI - CNR"
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"

View 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;
}
}

View 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']),
]);
}
}

View 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'];
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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 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',
};
}
}

View 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',
};
}
}

View 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;
}

View 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;
}
}

View 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,
],
);
}
}

View 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.');
}
}

View 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'));
}
}

View File

@@ -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(),
]);
}
}

View 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);
});
}
}

View 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();
}
}

View 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(),
]);
}
}

View 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,
};
}
}

View 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);
});
}
}

View 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);
}
}

View 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(),
]);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View File

@@ -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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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();
}
}

View File

@@ -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)],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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();
}
}

View 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;
}
}

View 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'];
}

View 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');
}
}

View File

@@ -2,20 +2,60 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use App\Models\Lists\UserRole;
use App\Notifications\ResetPasswordNotification;
use Illuminate\Contracts\Auth\MustVerifyEmail;
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\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use OwenIt\Auditing\Contracts\Auditable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
class User extends Authenticatable implements Auditable, MustVerifyEmail
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/** @use HasFactory<\Database\Factories\UserFactory> */
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.
@@ -27,6 +67,54 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'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';
}
}

View 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();
}
}

View 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 [
//
];
}
}

View 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 [
//
];
}
}

View File

@@ -2,6 +2,9 @@
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +22,8 @@ class AppServiceProvider extends ServiceProvider
*/
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()));
}
}

View 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()
);
});
}
}

View File

@@ -1,18 +1,51 @@
<?php
use App\Http\Middleware\EnsureSetupComplete;
use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\IsAdmin;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
api: __DIR__.'/../routes/api.php', // NOSONAR
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
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 {
//
// 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 {
$exceptions->shouldRenderJsonWhen(

View File

@@ -1,7 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
return [
AppServiceProvider::class,
FortifyServiceProvider::class,
];

View File

@@ -3,7 +3,7 @@
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"keywords": ["laravel", "framework", "v.13.15.0"],
"license": "MIT",
"require": {
"php": "^8.3",

204
backend/config/audit.php Normal file
View 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
View 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,
];

View File

@@ -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' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),

160
backend/config/fortify.php Normal file
View 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,
]),
],
];

View 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
View 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,
],
];

View 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,
];
}
}

View 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,
];
}
}

View File

@@ -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(),
];
}
}

View 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]);
}
}

View 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(),
];
}
}

View 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(),
];
}
}

View 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,
];
}
}

View File

@@ -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();
});
}
};

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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']);
});
}
};

View File

@@ -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',
]);
});
}
};

View File

@@ -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);
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
}
};

View File

@@ -2,7 +2,6 @@
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
@@ -11,15 +10,17 @@ class DatabaseSeeder extends Seeder
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
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
$this->call([
UserRoleSeeder::class,
SystemUserSeeder::class,
InstitutionCategorySeeder::class,
UserPositionSeeder::class,
LicenseSeeder::class,
]);
}
}

View 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],
);
}
}
}

View 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,
],
];
}
}

View 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 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);
}
}

View 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]
);
}
}
}

View 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.']
);
}
}

View File

@@ -18,19 +18,25 @@
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<!-- IMPORTANTE: la sicurezza del DB NON dipende da questo file. Nel container
le env (DB_HOST=db, DB_DATABASE=dyncoll, APP_ENV=local, ...) sono REALI e
finiscono in $_SERVER, che l'env() di Laravel legge PRIMA: il force qui sotto
NON le sovrascrive. L'override autoritativo arriva dalle -e di `make test`
(DB_HOST=db-test, DB_DATABASE=dyncoll_test). Questi force servono solo come
rete per i driver non-DB se qualcuno lancia `php artisan test` a mano; il
redirect verso db-test resta garantito dal guard in Tests\TestCase. -->
<env name="APP_ENV" value="testing" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
<env name="CACHE_STORE" value="array" force="true"/>
<env name="DB_CONNECTION" value="mysql" force="true"/>
<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>
</phpunit>

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