Compare commits
4 Commits
611c554490
...
032f6a08df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
032f6a08df | ||
|
|
fe73662903 | ||
|
|
cc4e174880 | ||
|
|
34a8ebc6fb |
21
.env.example
21
.env.example
@@ -30,21 +30,18 @@ LOG_LEVEL=debug # warning/error in produzione
|
||||
# Usati sia da Laravel sia dal container mysql (mappati nel compose).
|
||||
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
|
||||
|
||||
21
Makefile
21
Makefile
@@ -12,7 +12,7 @@ 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 permissions
|
||||
|
||||
help: ## Mostra questo aiuto
|
||||
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}'
|
||||
@@ -42,6 +42,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,7 +72,10 @@ 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
|
||||
## --- Manutenzione ---
|
||||
permissions: ## Permessi storage scrivibili da php-fpm (www-data) — richiede sudo, una tantum
|
||||
sudo chgrp -R www-data backend/storage backend/bootstrap/cache
|
||||
|
||||
43
backend/app/Actions/Fortify/CreateNewUser.php
Normal file
43
backend/app/Actions/Fortify/CreateNewUser.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class),
|
||||
],
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
19
backend/app/Actions/Fortify/PasswordValidationRules.php
Normal file
19
backend/app/Actions/Fortify/PasswordValidationRules.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
}
|
||||
32
backend/app/Actions/Fortify/ResetUserPassword.php
Normal file
32
backend/app/Actions/Fortify/ResetUserPassword.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
35
backend/app/Actions/Fortify/UpdateUserPassword.php
Normal file
35
backend/app/Actions/Fortify/UpdateUserPassword.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||
|
||||
class UpdateUserPassword implements UpdatesUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and update the user's password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'current_password' => ['required', 'string', 'current_password:web'],
|
||||
'password' => $this->passwordRules(),
|
||||
], [
|
||||
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||
])->validateWithBag('updatePassword');
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($input['password']),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
61
backend/app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
61
backend/app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
{
|
||||
/**
|
||||
* Validate and update the given user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique('users')->ignore($user->id),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if ($input['email'] !== $user->email &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
$this->updateVerifiedUser($user, $input);
|
||||
} else {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given verified user's profile information.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
protected function updateVerifiedUser(User $user, array $input): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'email_verified_at' => null,
|
||||
])->save();
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
}
|
||||
82
backend/app/Actions/PurgeUserAction.php
Normal file
82
backend/app/Actions/PurgeUserAction.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Exceptions\CannotDeleteSystemUserException;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Cancellazione definitiva di un utente conforme al GDPR (diritto all'oblio).
|
||||
*
|
||||
* Non rimuove la riga (va preservata l'integrità referenziale verso i contenuti
|
||||
* e la traccia di audit): i contenuti posseduti vengono riassegnati all'utente
|
||||
* di sistema e i dati personali anonimizzati in-place. Il record resta
|
||||
* soft-deleted ma, marcato `anonymized_at`, esce dal cestino e dalle liste.
|
||||
*
|
||||
* Idempotente: un utente già anonimizzato viene ignorato.
|
||||
*/
|
||||
class PurgeUserAction
|
||||
{
|
||||
/**
|
||||
* Modelli posseduti (con colonna `user_id`) da riassegnare all'utente di
|
||||
* sistema prima dell'anonimizzazione. Aggiungere qui i modelli di contenuto
|
||||
* man mano che vengono creati (devono usare SoftDeletes).
|
||||
*
|
||||
* @var list<class-string>
|
||||
*/
|
||||
private const OWNED_MODELS = [];
|
||||
|
||||
public function execute(User $user): void
|
||||
{
|
||||
if ($user->is_system) {
|
||||
throw CannotDeleteSystemUserException::make();
|
||||
}
|
||||
|
||||
if ($user->anonymized_at !== null) {
|
||||
return; // già anonimizzato: idempotente
|
||||
}
|
||||
|
||||
$system = User::where('is_system', true)->firstOrFail();
|
||||
|
||||
DB::transaction(function () use ($user, $system) {
|
||||
$this->reassignContent($user, $system);
|
||||
$this->anonymize($user);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Riassegna all'utente di sistema tutti i contenuti posseduti, inclusi
|
||||
* quelli a loro volta nel cestino.
|
||||
*/
|
||||
private function reassignContent(User $user, User $system): void
|
||||
{
|
||||
foreach (self::OWNED_MODELS as $model) {
|
||||
$model::withTrashed()
|
||||
->where('user_id', $user->id)
|
||||
->update(['user_id' => $system->id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Azzera i dati personali mantenendo la riga per l'integrità referenziale.
|
||||
*/
|
||||
private function anonymize(User $user): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'name' => 'Deleted user',
|
||||
'email' => "deleted-{$user->id}@anonymized.invalid",
|
||||
'password' => Hash::make(Str::random(60)),
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
'two_factor_setup_completed_at' => null,
|
||||
'email_verified_at' => null,
|
||||
'remember_token' => null,
|
||||
'must_change_password' => false,
|
||||
'anonymized_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
54
backend/app/Console/Commands/PurgeAnonymizableUsers.php
Normal file
54
backend/app/Console/Commands/PurgeAnonymizableUsers.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\PurgeUserAction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Anonimizza (GDPR) gli utenti soft-deleted oltre la retention, riassegnandone
|
||||
* i contenuti all'utente di sistema. Idempotente, pensato per esecuzione
|
||||
* schedulata giornaliera (vedi routes/console.php).
|
||||
*/
|
||||
class PurgeAnonymizableUsers extends Command
|
||||
{
|
||||
protected $signature = 'users:purge
|
||||
{--days=30 : Days kept in the trash before anonymization}
|
||||
{--dry-run : Show the affected users without anonymizing}';
|
||||
|
||||
protected $description = 'Anonymize users deleted beyond the retention window (GDPR right to be forgotten)';
|
||||
|
||||
public function handle(PurgeUserAction $purge): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$users = User::onlyTrashed()
|
||||
->where('is_system', false)
|
||||
->whereNull('anonymized_at')
|
||||
->where('deleted_at', '<=', now()->subDays($days))
|
||||
->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->info('No users to anonymize.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($users as $user) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(' [dry-run] #%d %s', $user->id, $user->email));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$purge->execute($user);
|
||||
$this->line(sprintf(' ✓ anonymized user #%d', $user->id));
|
||||
}
|
||||
|
||||
$this->info(($dryRun ? '[dry-run] ' : '').'Processed users: '.$users->count());
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
91
backend/app/Console/Commands/V1ImportCommand.php
Normal file
91
backend/app/Console/Commands/V1ImportCommand.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Etl\Contracts\Importer;
|
||||
use App\Etl\Importers\InstitutionImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* ETL idempotente dal DB legacy (v1) allo schema v2.
|
||||
*
|
||||
* Orchestra gli importer in ORDINE DI DIPENDENZA (institutions prima delle entità
|
||||
* che le referenziano, es. artifacts). Ogni importer legge dalla connessione
|
||||
* `legacy` e scrive su quella di default. Serve sia per i refresh di sviluppo
|
||||
* sia per la migrazione di cutover.
|
||||
*/
|
||||
class V1ImportCommand extends Command
|
||||
{
|
||||
protected $signature = 'v1:import
|
||||
{--only=* : Limit to specific importers (e.g. --only=institutions)}
|
||||
{--dry-run : Read and count without writing}';
|
||||
|
||||
protected $description = 'Import data from the legacy (v1) database into the v2 schema (idempotent)';
|
||||
|
||||
/**
|
||||
* Importer in ordine di dipendenza.
|
||||
*
|
||||
* @var list<class-string<Importer>>
|
||||
*/
|
||||
private array $importers = [
|
||||
InstitutionImporter::class,
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$only = array_map('strtolower', (array) $this->option('only'));
|
||||
|
||||
try {
|
||||
DB::connection('legacy')->getPdo();
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Legacy connection unavailable: '.$e->getMessage());
|
||||
$this->line('Set DB_LEGACY_* in the environment (see config/database.php → connections.legacy).');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('DRY-RUN: no data will be written.');
|
||||
}
|
||||
|
||||
$hadError = false;
|
||||
|
||||
foreach ($this->importers as $class) {
|
||||
/** @var Importer $importer */
|
||||
$importer = app($class);
|
||||
|
||||
if ($only !== [] && ! in_array($importer->key(), $only, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->info("→ {$importer->label()}");
|
||||
|
||||
try {
|
||||
$summary = $importer->import($dryRun);
|
||||
} catch (Throwable $e) {
|
||||
$this->error(" {$importer->key()} failed: ".$e->getMessage());
|
||||
$hadError = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($summary->warnings as $warning) {
|
||||
$this->warn(' ! '.$warning);
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
' %screated %d, updated %d, skipped %d (total %d)',
|
||||
$dryRun ? '[dry-run] ' : '',
|
||||
$summary->created,
|
||||
$summary->updated,
|
||||
$summary->skipped,
|
||||
$summary->total(),
|
||||
));
|
||||
}
|
||||
|
||||
return $hadError ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
33
backend/app/Enums/InstitutionLinkType.php
Normal file
33
backend/app/Enums/InstitutionLinkType.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Tipologia di risorsa web collegata a un'istituzione.
|
||||
*
|
||||
* Vocabolario stabile, controllato dallo sviluppatore: backed enum (non MySQL
|
||||
* ENUM né lookup table). Gli artifact useranno un enum separato (ArtifactLinkType)
|
||||
* con un proprio vocabolario.
|
||||
*/
|
||||
enum InstitutionLinkType: string
|
||||
{
|
||||
case Official = 'official';
|
||||
case Ticketing = 'ticketing';
|
||||
case Social = 'social';
|
||||
case Catalogue = 'catalogue';
|
||||
case Other = 'other';
|
||||
|
||||
/**
|
||||
* Etichetta leggibile per la UI (inglese: lingua ufficiale dell'applicazione).
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Official => 'Official website',
|
||||
self::Ticketing => 'Online ticketing',
|
||||
self::Social => 'Social media',
|
||||
self::Catalogue => 'Online catalogue',
|
||||
self::Other => 'Other resource',
|
||||
};
|
||||
}
|
||||
}
|
||||
31
backend/app/Etl/Contracts/Importer.php
Normal file
31
backend/app/Etl/Contracts/Importer.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Etl\Contracts;
|
||||
|
||||
use App\Etl\ImportSummary;
|
||||
|
||||
/**
|
||||
* Un importer ETL trasforma una porzione del DB legacy (v1) nello schema v2.
|
||||
*
|
||||
* Contratto comune a tutti gli importer (institutions, poi artifacts, ...): il
|
||||
* comando `v1:import` li orchestra in ordine di dipendenza. Ogni import deve
|
||||
* essere IDEMPOTENTE (upsert su `legacy_id`) e leggere SOLO dalla connessione
|
||||
* `legacy`, scrivere SOLO sulla connessione di default (v2).
|
||||
*/
|
||||
interface Importer
|
||||
{
|
||||
/**
|
||||
* Chiave breve per il filtro `--only` (es. "institutions").
|
||||
*/
|
||||
public function key(): string;
|
||||
|
||||
/**
|
||||
* Etichetta leggibile per l'output del comando.
|
||||
*/
|
||||
public function label(): string;
|
||||
|
||||
/**
|
||||
* Esegue l'import. In dry-run non scrive nulla, ma calcola comunque i conteggi.
|
||||
*/
|
||||
public function import(bool $dryRun): ImportSummary;
|
||||
}
|
||||
28
backend/app/Etl/ImportSummary.php
Normal file
28
backend/app/Etl/ImportSummary.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Etl;
|
||||
|
||||
/**
|
||||
* Esito di un import: conteggi e avvisi non bloccanti (da stampare nel comando).
|
||||
*/
|
||||
class ImportSummary
|
||||
{
|
||||
public int $created = 0;
|
||||
|
||||
public int $updated = 0;
|
||||
|
||||
public int $skipped = 0;
|
||||
|
||||
/** @var list<string> */
|
||||
public array $warnings = [];
|
||||
|
||||
public function warn(string $message): void
|
||||
{
|
||||
$this->warnings[] = $message;
|
||||
}
|
||||
|
||||
public function total(): int
|
||||
{
|
||||
return $this->created + $this->updated + $this->skipped;
|
||||
}
|
||||
}
|
||||
160
backend/app/Etl/Importers/InstitutionImporter.php
Normal file
160
backend/app/Etl/Importers/InstitutionImporter.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Etl\Importers;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use App\Etl\Contracts\Importer;
|
||||
use App\Etl\ImportSummary;
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Importa le istituzioni da v1 (`institution`) nello schema v2.
|
||||
*
|
||||
* - Upsert su `legacy_id` → idempotente (re-run aggiorna, non duplica).
|
||||
* - `category` (v1) → `category_id` (v2): mappa 1:1 perché il lookup è seedato con
|
||||
* gli stessi id legacy. Categoria assente nel lookup (es. 1 "uncategorized") → skip + warning.
|
||||
* - `uuid` NON copiato dal legacy: lo rigenera il model (orderedUuid) alla creazione.
|
||||
* - `url` (campo singolo v1) → riga `institution_links` con type=official, solo se valorizzato.
|
||||
* - Pulizia dati: trim su name/abbreviation/address/city/logo.
|
||||
*
|
||||
* NB l'auditing è già off in console (audit.console=false), quindi l'import non genera audit.
|
||||
* NB la migrazione dei FILE logo (da v1 a storage/app/public) è fuori scope di questa fetta:
|
||||
* qui si copia solo il valore stringa della colonna.
|
||||
*/
|
||||
class InstitutionImporter implements Importer
|
||||
{
|
||||
private const SOURCE = 'legacy';
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'institutions';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Institutions';
|
||||
}
|
||||
|
||||
public function import(bool $dryRun): ImportSummary
|
||||
{
|
||||
$summary = new ImportSummary;
|
||||
|
||||
/** @var list<int> $validCategoryIds */
|
||||
$validCategoryIds = InstitutionCategory::query()->pluck('id')->map(intval(...))->all();
|
||||
|
||||
if ($validCategoryIds === []) {
|
||||
$summary->warn('Lookup institution_categories vuoto: esegui prima `db:seed`. Import saltato.');
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
$rows = DB::connection(self::SOURCE)
|
||||
->table('institution')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
DB::transaction(function () use ($rows, $dryRun, $summary, $validCategoryIds): void {
|
||||
foreach ($rows as $row) {
|
||||
$this->importRow($row, $dryRun, $validCategoryIds, $summary);
|
||||
}
|
||||
});
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Importa (upsert) una singola istituzione legacy. Categoria assente nel
|
||||
* lookup v2 → skip con warning.
|
||||
*
|
||||
* @param list<int> $validCategoryIds
|
||||
*/
|
||||
private function importRow(object $row, bool $dryRun, array $validCategoryIds, ImportSummary $summary): void
|
||||
{
|
||||
$categoryId = (int) $row->category;
|
||||
|
||||
if (! in_array($categoryId, $validCategoryIds, true)) {
|
||||
$summary->warn(sprintf(
|
||||
"Institution legacy #%d ('%s'): category %d assente nel lookup v2 → saltata.",
|
||||
$row->id, trim((string) $row->name), $categoryId,
|
||||
));
|
||||
$summary->skipped++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
Institution::query()->where('legacy_id', (int) $row->id)->exists()
|
||||
? $summary->updated++
|
||||
: $summary->created++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$institution = Institution::updateOrCreate(
|
||||
['legacy_id' => (int) $row->id],
|
||||
$this->mapAttributes($row, $categoryId),
|
||||
);
|
||||
|
||||
$institution->wasRecentlyCreated ? $summary->created++ : $summary->updated++;
|
||||
|
||||
$this->syncOfficialLink($institution, (string) ($row->url ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappa una riga legacy sugli attributi del model v2 (con pulizia dei dati).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapAttributes(object $row, int $categoryId): array
|
||||
{
|
||||
return [
|
||||
'category_id' => $categoryId,
|
||||
'name' => trim((string) $row->name),
|
||||
'abbreviation' => trim((string) $row->abbreviation),
|
||||
'address' => trim((string) $row->address),
|
||||
'city' => trim((string) $row->city),
|
||||
'lat' => $row->lat,
|
||||
'lon' => $row->lon,
|
||||
'logo' => trim((string) $row->logo),
|
||||
'color' => $this->cleanColor($row->color),
|
||||
'is_storage_place' => (bool) $row->is_storage_place,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Colore valido o fallback al default v2.
|
||||
*/
|
||||
private function cleanColor(mixed $color): string
|
||||
{
|
||||
$color = trim((string) ($color ?? ''));
|
||||
|
||||
return $color !== '' ? $color : '#c5cae9';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincronizza il link "official" dall'unico `url` legacy. Idempotente
|
||||
* (upsert su institution_id + type). URL vuoto/blank → nessun link.
|
||||
*/
|
||||
private function syncOfficialLink(Institution $institution, string $url): void
|
||||
{
|
||||
$url = trim($url);
|
||||
|
||||
if ($url === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
InstitutionLink::updateOrCreate(
|
||||
[
|
||||
'institution_id' => $institution->id,
|
||||
'type' => InstitutionLinkType::Official,
|
||||
],
|
||||
[
|
||||
'url' => $url,
|
||||
'sort_order' => 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
17
backend/app/Exceptions/CannotDeleteSystemUserException.php
Normal file
17
backend/app/Exceptions/CannotDeleteSystemUserException.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Sollevata quando si tenta di anonimizzare/eliminare l'utente di sistema,
|
||||
* che deve restare sempre presente come destinatario dei contenuti riassegnati.
|
||||
*/
|
||||
class CannotDeleteSystemUserException extends RuntimeException
|
||||
{
|
||||
public static function make(): self
|
||||
{
|
||||
return new self('The system user cannot be deleted.');
|
||||
}
|
||||
}
|
||||
72
backend/app/Http/Controllers/AuthController.php
Normal file
72
backend/app/Http/Controllers/AuthController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Utente autenticato corrente (include l'accessor setup_status).
|
||||
* Risposta al primo livello, come da convenzione Sanctum SPA.
|
||||
*/
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json($request->user());
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout: chiude la sessione stateful e ripulisce i cookie.
|
||||
* Sovrascrive la rotta omonima di Fortify (registrata dopo → ha precedenza).
|
||||
*/
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$this->tearDownSession($request);
|
||||
|
||||
return $this->forgetAuthCookies($this->messageResponse('auth.logout_success'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellazione self-service dell'account: soft-delete + logout.
|
||||
* L'anonimizzazione definitiva (GDPR) la fa l'Admin o la retention.
|
||||
*/
|
||||
public function destroyAccount(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_if($user->is_system, 403);
|
||||
|
||||
$user->delete();
|
||||
$this->tearDownSession($request);
|
||||
|
||||
return $this->forgetAuthCookies($this->messageResponse('account.deleted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalida la sessione corrente e rigenera il token CSRF.
|
||||
*/
|
||||
private function tearDownSession(Request $request): void
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca per la cancellazione i cookie di sessione e XSRF lato client.
|
||||
*/
|
||||
private function forgetAuthCookies(JsonResponse $response): JsonResponse
|
||||
{
|
||||
return $response
|
||||
->withCookie(Cookie::forget(config('session.cookie')))
|
||||
->withCookie(Cookie::forget('XSRF-TOKEN'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreInstitutionCategoryRequest;
|
||||
use App\Http\Requests\UpdateInstitutionCategoryRequest;
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* CRUD del lookup categorie di istituzione (scrittura riservata agli Admin).
|
||||
*
|
||||
* NB gli id 2/3/4/6 sono allineati al legacy e usati dall'ETL: cancellarne uno
|
||||
* in uso è bloccato (409). Nessun soft delete su questo lookup.
|
||||
*/
|
||||
class InstitutionCategoryController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Elenco delle categorie.
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return $this->collectionResponse(InstitutionCategory::orderBy('value')->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una categoria.
|
||||
*/
|
||||
public function store(StoreInstitutionCategoryRequest $request): JsonResponse
|
||||
{
|
||||
$category = InstitutionCategory::create($request->validated());
|
||||
|
||||
return $this->createdResponse($category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dettaglio di una categoria.
|
||||
*/
|
||||
public function show(InstitutionCategory $institutionCategory): JsonResponse
|
||||
{
|
||||
return $this->okResponse($institutionCategory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiorna una categoria.
|
||||
*/
|
||||
public function update(
|
||||
UpdateInstitutionCategoryRequest $request,
|
||||
InstitutionCategory $institutionCategory
|
||||
): JsonResponse {
|
||||
$institutionCategory->update($request->validated());
|
||||
|
||||
return $this->updatedResponse($institutionCategory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina una categoria (se non assegnata ad alcuna istituzione).
|
||||
*/
|
||||
public function destroy(InstitutionCategory $institutionCategory): JsonResponse
|
||||
{
|
||||
if ($institutionCategory->isInUse()) {
|
||||
return $this->conflictResponse('Cannot delete: category assigned to at least one institution.');
|
||||
}
|
||||
|
||||
$institutionCategory->delete();
|
||||
|
||||
return $this->deletedResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indica se la categoria è in uso (bloccata per l'eliminazione).
|
||||
*/
|
||||
public function usage(InstitutionCategory $institutionCategory): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'in_use' => $institutionCategory->isInUse(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
172
backend/app/Http/Controllers/InstitutionController.php
Normal file
172
backend/app/Http/Controllers/InstitutionController.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\IndexInstitutionRequest;
|
||||
use App\Http\Requests\StoreInstitutionRequest;
|
||||
use App\Http\Requests\UpdateInstitutionRequest;
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\Institution;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* CRUD delle istituzioni (scrittura riservata agli Admin via tier di rotta).
|
||||
*
|
||||
* Identificate verso l'esterno dallo `uuid` (route key), non dall'id interno.
|
||||
* Soft delete: destroy = cestina, restore = ripristina, forceDestroy = elimina
|
||||
* definitivamente (e rimuove il file logo + i link via cascade FK).
|
||||
*/
|
||||
class InstitutionController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/** Sottocartella del disco `public` dove vivono i loghi. */
|
||||
private const LOGO_DIR = 'institution_logo';
|
||||
|
||||
/**
|
||||
* Elenco paginato, con filtri opzionali (ricerca, categoria, cestino).
|
||||
*/
|
||||
public function index(IndexInstitutionRequest $request): JsonResponse
|
||||
{
|
||||
$query = Institution::query()
|
||||
->with('category')
|
||||
->orderBy('name');
|
||||
|
||||
$this->applyTrashed($query, $request->input('trashed'));
|
||||
$this->applySearch($query, $request->input('search'));
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', $request->integer('category_id'));
|
||||
}
|
||||
|
||||
$institutions = $query->paginate($request->integer('per_page') ?: 20);
|
||||
|
||||
return $this->paginatedCollectionResponse($institutions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un'istituzione (con upload del logo).
|
||||
*/
|
||||
public function store(StoreInstitutionRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['logo'] = $this->storeLogo($request->file('logo'));
|
||||
|
||||
$institution = Institution::create($data);
|
||||
|
||||
return $this->createdResponse($institution->load('category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dettaglio (con categoria e link).
|
||||
*/
|
||||
public function show(Institution $institution): JsonResponse
|
||||
{
|
||||
return $this->okResponse($institution->load(['category', 'links']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiorna un'istituzione. Logo opzionale: se presente sostituisce il vecchio.
|
||||
*/
|
||||
public function update(UpdateInstitutionRequest $request, Institution $institution): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$this->deleteLogo($institution->logo);
|
||||
$data['logo'] = $this->storeLogo($request->file('logo'));
|
||||
} else {
|
||||
unset($data['logo']);
|
||||
}
|
||||
|
||||
$institution->update($data);
|
||||
|
||||
return $this->updatedResponse($institution->load('category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cestina (soft delete).
|
||||
*/
|
||||
public function destroy(Institution $institution): JsonResponse
|
||||
{
|
||||
$institution->delete();
|
||||
|
||||
return $this->deletedResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ripristina un'istituzione cestinata.
|
||||
*/
|
||||
public function restore(Institution $institution): JsonResponse
|
||||
{
|
||||
$institution->restore();
|
||||
|
||||
return $this->restoredResponse($institution->load('category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina definitivamente (rimuove il file logo; i link cadono in cascata DB).
|
||||
*/
|
||||
public function forceDestroy(Institution $institution): JsonResponse
|
||||
{
|
||||
$this->deleteLogo($institution->logo);
|
||||
$institution->forceDelete();
|
||||
|
||||
return $this->deletedResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva il file logo sul disco `public` e ne restituisce il path relativo.
|
||||
*/
|
||||
private function storeLogo(UploadedFile $file): string
|
||||
{
|
||||
return $file->store(self::LOGO_DIR, 'public');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rimuove un file logo dal disco `public`, se presente.
|
||||
*/
|
||||
private function deleteLogo(?string $path): void
|
||||
{
|
||||
if (filled($path)) {
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Include i soft-deleted: `with` (tutti) o `only` (solo cestinati).
|
||||
*
|
||||
* @param Builder<Institution> $query
|
||||
*/
|
||||
private function applyTrashed(Builder $query, ?string $trashed): void
|
||||
{
|
||||
match ($trashed) {
|
||||
'with' => $query->withTrashed(),
|
||||
'only' => $query->onlyTrashed(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ricerca parziale su nome, città e sigla.
|
||||
*
|
||||
* @param Builder<Institution> $query
|
||||
*/
|
||||
private function applySearch(Builder $query, ?string $search): void
|
||||
{
|
||||
if (blank($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$term = '%'.$search.'%';
|
||||
|
||||
$query->where(function (Builder $q) use ($term): void {
|
||||
$q->where('name', 'like', $term)
|
||||
->orWhere('city', 'like', $term)
|
||||
->orWhere('abbreviation', 'like', $term);
|
||||
});
|
||||
}
|
||||
}
|
||||
69
backend/app/Http/Controllers/InstitutionLinkController.php
Normal file
69
backend/app/Http/Controllers/InstitutionLinkController.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreInstitutionLinkRequest;
|
||||
use App\Http\Requests\UpdateInstitutionLinkRequest;
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* Risorse web di un'istituzione (sito ufficiale, ticketing, social, ...).
|
||||
*
|
||||
* Risorsa annidata e scoped sotto `institutions/{institution}`: il link deve
|
||||
* appartenere all'istituzione del path. Nessun soft delete (solo Institution lo ha).
|
||||
*/
|
||||
class InstitutionLinkController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Elenco dei link di un'istituzione, ordinati.
|
||||
*/
|
||||
public function index(Institution $institution): JsonResponse
|
||||
{
|
||||
$links = $institution->links()->orderBy('sort_order')->get();
|
||||
|
||||
return $this->collectionResponse($links);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiunge un link all'istituzione.
|
||||
*/
|
||||
public function store(StoreInstitutionLinkRequest $request, Institution $institution): JsonResponse
|
||||
{
|
||||
$link = $institution->links()->create($request->validated());
|
||||
|
||||
return $this->createdResponse($link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dettaglio di un link.
|
||||
*/
|
||||
public function show(Institution $institution, InstitutionLink $link): JsonResponse
|
||||
{
|
||||
return $this->okResponse($link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiorna un link.
|
||||
*/
|
||||
public function update(UpdateInstitutionLinkRequest $request, Institution $institution, InstitutionLink $link): JsonResponse
|
||||
{
|
||||
$link->update($request->validated());
|
||||
|
||||
return $this->updatedResponse($link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un link.
|
||||
*/
|
||||
public function destroy(Institution $institution, InstitutionLink $link): JsonResponse
|
||||
{
|
||||
$link->delete();
|
||||
|
||||
return $this->deletedResponse();
|
||||
}
|
||||
}
|
||||
85
backend/app/Http/Controllers/UserController.php
Normal file
85
backend/app/Http/Controllers/UserController.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\IndexUserRequest;
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* Gestione utenti riservata agli amministratori (sola lettura per ora:
|
||||
* la creazione/invito e la modifica arriveranno con la gestione account).
|
||||
*
|
||||
* Ogni utente è serializzato col ruolo correlato e l'accessor `setup_status`
|
||||
* (password_required / 2fa_setup_required / complete), utile alla dashboard admin.
|
||||
*/
|
||||
class UserController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Elenco paginato degli utenti, con filtri opzionali (ricerca, ruolo, cestino).
|
||||
*/
|
||||
public function index(IndexUserRequest $request): JsonResponse
|
||||
{
|
||||
$query = User::query()
|
||||
->with('role')
|
||||
->orderBy('name');
|
||||
|
||||
$this->applyTrashed($query, $request->input('trashed'));
|
||||
$this->applySearch($query, $request->input('search'));
|
||||
|
||||
if ($request->filled('role_id')) {
|
||||
$query->where('role_id', $request->integer('role_id'));
|
||||
}
|
||||
|
||||
$users = $query->paginate($request->integer('per_page') ?: 20);
|
||||
|
||||
return $this->paginatedCollectionResponse($users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dettaglio di un singolo utente (anche cestinato/anonimizzato).
|
||||
*/
|
||||
public function show(User $user): JsonResponse
|
||||
{
|
||||
$user->load('role');
|
||||
|
||||
return $this->okResponse($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Include i soft-deleted nell'elenco: `with` (tutti) o `only` (solo cestinati).
|
||||
*
|
||||
* @param Builder<User> $query
|
||||
*/
|
||||
private function applyTrashed(Builder $query, ?string $trashed): void
|
||||
{
|
||||
match ($trashed) {
|
||||
'with' => $query->withTrashed(),
|
||||
'only' => $query->onlyTrashed(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtra per nome o email (ricerca parziale, case-insensitive).
|
||||
*
|
||||
* @param Builder<User> $query
|
||||
*/
|
||||
private function applySearch(Builder $query, ?string $search): void
|
||||
{
|
||||
if (blank($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$term = '%'.$search.'%';
|
||||
|
||||
$query->where(function (Builder $q) use ($term): void {
|
||||
$q->where('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term);
|
||||
});
|
||||
}
|
||||
}
|
||||
28
backend/app/Http/Controllers/UserPositionController.php
Normal file
28
backend/app/Http/Controllers/UserPositionController.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\Lists\UserPosition;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class UserPositionController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Elenco delle position.
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return $this->collectionResponse(UserPosition::all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Dettaglio di una position.
|
||||
*/
|
||||
public function show(UserPosition $userPosition): JsonResponse
|
||||
{
|
||||
return $this->okResponse($userPosition);
|
||||
}
|
||||
}
|
||||
38
backend/app/Http/Controllers/UserRoleController.php
Normal file
38
backend/app/Http/Controllers/UserRoleController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Traits\ApiResponse;
|
||||
use App\Models\Lists\UserRole;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class UserRoleController extends Controller
|
||||
{
|
||||
use ApiResponse;
|
||||
|
||||
/**
|
||||
* Elenco dei ruoli.
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return $this->collectionResponse(UserRole::all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Dettaglio di un ruolo.
|
||||
*/
|
||||
public function show(UserRole $userRole): JsonResponse
|
||||
{
|
||||
return $this->okResponse($userRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Numero di utenti con questo ruolo (statistica per Admin).
|
||||
*/
|
||||
public function usage(UserRole $userRole): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'user_count' => $userRole->users()->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
backend/app/Http/Middleware/EnsureSetupComplete.php
Normal file
40
backend/app/Http/Middleware/EnsureSetupComplete.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureSetupComplete
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): mixed
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$setupStatus = null;
|
||||
|
||||
if ($user->must_change_password) {
|
||||
$setupStatus = 'password_required';
|
||||
} elseif (is_null($user->two_factor_setup_completed_at)) {
|
||||
$setupStatus = '2fa_setup_required';
|
||||
}
|
||||
|
||||
if ($setupStatus !== null) {
|
||||
return response()->json([
|
||||
'setup_status' => $setupStatus,
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
22
backend/app/Http/Middleware/ForceJsonResponse.php
Normal file
22
backend/app/Http/Middleware/ForceJsonResponse.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ForceJsonResponse
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$request->headers->set('Accept', 'application/json');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
25
backend/app/Http/Middleware/IsAdmin.php
Normal file
25
backend/app/Http/Middleware/IsAdmin.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class IsAdmin
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! Auth::check() || Auth::user()->role?->name !== 'Admin') {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
31
backend/app/Http/Requests/IndexInstitutionRequest.php
Normal file
31
backend/app/Http/Requests/IndexInstitutionRequest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class IndexInstitutionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione è gestita dal middleware di rotta (tier.app).
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'category_id' => ['nullable', 'integer', 'exists:institution_categories,id'],
|
||||
'trashed' => ['nullable', Rule::in(['with', 'only'])],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
backend/app/Http/Requests/IndexUserRequest.php
Normal file
31
backend/app/Http/Requests/IndexUserRequest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class IndexUserRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (auth + admin + setup) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'role_id' => ['nullable', 'integer', 'exists:user_roles,id'],
|
||||
'trashed' => ['nullable', Rule::in(['with', 'only'])],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInstitutionCategoryRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'value' => ['required', 'string', 'max:25', 'unique:institution_categories,value'],
|
||||
];
|
||||
}
|
||||
}
|
||||
37
backend/app/Http/Requests/StoreInstitutionLinkRequest.php
Normal file
37
backend/app/Http/Requests/StoreInstitutionLinkRequest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreInstitutionLinkRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->mergeIfMissing(['sort_order' => 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'url' => ['required', 'url', 'max:2000'],
|
||||
'type' => ['required', Rule::enum(InstitutionLinkType::class)],
|
||||
'label' => ['nullable', 'string', 'max:100'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
48
backend/app/Http/Requests/StoreInstitutionRequest.php
Normal file
48
backend/app/Http/Requests/StoreInstitutionRequest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreInstitutionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default per i campi con valore di colonna (color, is_storage_place):
|
||||
* così non si inseriscono mai NULL su colonne NOT NULL.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->mergeIfMissing([
|
||||
'color' => '#c5cae9',
|
||||
'is_storage_place' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => ['required', 'integer', 'exists:institution_categories,id'],
|
||||
'name' => ['required', 'string', 'max:255', 'unique:institutions,name'],
|
||||
'abbreviation' => ['required', 'string', 'max:5', 'unique:institutions,abbreviation'],
|
||||
'address' => ['required', 'string', 'max:255'],
|
||||
'city' => ['required', 'string', 'max:100'],
|
||||
'lat' => ['required', 'numeric', 'between:-90,90'],
|
||||
'lon' => ['required', 'numeric', 'between:-180,180'],
|
||||
'logo' => ['required', 'image', 'max:5120'],
|
||||
'color' => ['required', 'string', 'max:50'],
|
||||
'is_storage_place' => ['required', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateInstitutionCategoryRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$id = $this->route('institution_category')->id;
|
||||
|
||||
return [
|
||||
'value' => ['required', 'string', 'max:25', Rule::unique('institution_categories', 'value')->ignore($id)],
|
||||
];
|
||||
}
|
||||
}
|
||||
37
backend/app/Http/Requests/UpdateInstitutionLinkRequest.php
Normal file
37
backend/app/Http/Requests/UpdateInstitutionLinkRequest.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateInstitutionLinkRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->mergeIfMissing(['sort_order' => 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'url' => ['required', 'url', 'max:2000'],
|
||||
'type' => ['required', Rule::enum(InstitutionLinkType::class)],
|
||||
'label' => ['nullable', 'string', 'max:100'],
|
||||
'sort_order' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
40
backend/app/Http/Requests/UpdateInstitutionRequest.php
Normal file
40
backend/app/Http/Requests/UpdateInstitutionRequest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateInstitutionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* L'autorizzazione (solo Admin) è gestita dal middleware di rotta.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$id = $this->route('institution')->id;
|
||||
|
||||
return [
|
||||
'category_id' => ['required', 'integer', 'exists:institution_categories,id'],
|
||||
'name' => ['required', 'string', 'max:255', Rule::unique('institutions', 'name')->ignore($id)],
|
||||
'abbreviation' => ['required', 'string', 'max:5', Rule::unique('institutions', 'abbreviation')->ignore($id)],
|
||||
'address' => ['required', 'string', 'max:255'],
|
||||
'city' => ['required', 'string', 'max:100'],
|
||||
'lat' => ['required', 'numeric', 'between:-90,90'],
|
||||
'lon' => ['required', 'numeric', 'between:-180,180'],
|
||||
// Opzionale in update: se assente si mantiene il logo esistente.
|
||||
'logo' => ['nullable', 'image', 'max:5120'],
|
||||
'color' => ['required', 'string', 'max:50'],
|
||||
'is_storage_place' => ['required', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
130
backend/app/Http/Traits/ApiResponse.php
Normal file
130
backend/app/Http/Traits/ApiResponse.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Traits;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Risposte JSON standardizzate per i controller API.
|
||||
*
|
||||
* I messaggi sono presi da config/messages.php. Passando un $prefix si possono
|
||||
* usare messaggi specifici per risorsa (es. "user_roles.created"); senza prefix
|
||||
* si usano le chiavi generiche di primo livello.
|
||||
*/
|
||||
trait ApiResponse
|
||||
{
|
||||
/**
|
||||
* 200 — Lista paginata: { message, data, meta }.
|
||||
*/
|
||||
protected function paginatedCollectionResponse(LengthAwarePaginator $paginator, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.index" : 'messages.index'),
|
||||
'data' => $paginator->items(),
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 200 — Collezione di risorse.
|
||||
*/
|
||||
protected function collectionResponse(mixed $collection, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.index" : 'messages.index'),
|
||||
'data' => $collection,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 200 — Singola risorsa.
|
||||
*/
|
||||
protected function okResponse(mixed $data, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.show" : 'messages.show'),
|
||||
'data' => $data,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 201 — Risorsa creata.
|
||||
*/
|
||||
protected function createdResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.created" : 'messages.created'),
|
||||
'data' => $model,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 200 — Risorsa aggiornata.
|
||||
*/
|
||||
protected function updatedResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.updated" : 'messages.updated'),
|
||||
'data' => $model,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 200 — Risorsa eliminata.
|
||||
*/
|
||||
protected function deletedResponse(?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.deleted" : 'messages.deleted'),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 200 — Risorsa ripristinata (soft delete restore).
|
||||
*/
|
||||
protected function restoredResponse(mixed $model, ?string $prefix = null): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config($prefix ? "messages.{$prefix}.restored" : 'messages.restored'),
|
||||
'data' => $model,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 409 — Conflitto: operazione bloccata (es. risorsa in uso).
|
||||
*/
|
||||
protected function conflictResponse(string $message): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => $message,
|
||||
'in_use' => true,
|
||||
], 409);
|
||||
}
|
||||
|
||||
/**
|
||||
* Risposta con solo messaggio — chiave config es. "auth.login_success".
|
||||
*/
|
||||
protected function messageResponse(string $configKey, int $status = 200): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config("messages.{$configKey}"),
|
||||
], $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Risposta con messaggio e dati — chiave config es. "auth.login_success".
|
||||
*/
|
||||
protected function dataResponse(mixed $data, string $configKey, int $status = 200): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => config("messages.{$configKey}"),
|
||||
'data' => $data,
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
90
backend/app/Models/Institution.php
Normal file
90
backend/app/Models/Institution.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
46
backend/app/Models/InstitutionLink.php
Normal file
46
backend/app/Models/InstitutionLink.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class InstitutionLink extends Model implements Auditable
|
||||
{
|
||||
use AuditableTrait;
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'institution_id',
|
||||
'url',
|
||||
'type',
|
||||
'label',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'institution_id' => 'integer',
|
||||
'type' => InstitutionLinkType::class,
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Istituzione a cui appartiene il link.
|
||||
*
|
||||
* @return BelongsTo<Institution, $this>
|
||||
*/
|
||||
public function institution(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Institution::class);
|
||||
}
|
||||
}
|
||||
56
backend/app/Models/Lists/InstitutionCategory.php
Normal file
56
backend/app/Models/Lists/InstitutionCategory.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Lists;
|
||||
|
||||
use App\Models\Institution;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
/**
|
||||
* Lookup delle categorie di istituzione.
|
||||
*
|
||||
* Tabella controllata: gli id sono fissi e coincidono con quelli del DB legacy
|
||||
* (v1) così l'ETL mappa institutions.category_id 1:1 senza tradurre. La categoria
|
||||
* legacy 1 ("uncategorized", mai usata) NON viene riportata.
|
||||
*/
|
||||
class InstitutionCategory extends Model implements Auditable
|
||||
{
|
||||
use AuditableTrait;
|
||||
use HasFactory;
|
||||
|
||||
// Id allineati al legacy (vedi seeder). Nessun id 1 e nessun id 5.
|
||||
public const LIBRARY = 'library';
|
||||
|
||||
public const MUSEUM = 'museum';
|
||||
|
||||
public const PUBLIC_ADMINISTRATION = 'public administration';
|
||||
|
||||
public const RESEARCH_INSTITUTE = 'research institute';
|
||||
|
||||
protected $table = 'institution_categories';
|
||||
|
||||
protected $fillable = [
|
||||
'value',
|
||||
];
|
||||
|
||||
/**
|
||||
* Istituzioni che appartengono a questa categoria.
|
||||
*
|
||||
* @return HasMany<Institution, $this>
|
||||
*/
|
||||
public function institutions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Institution::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* La categoria è assegnata ad almeno un'istituzione.
|
||||
*/
|
||||
public function isInUse(): bool
|
||||
{
|
||||
return $this->institutions()->exists();
|
||||
}
|
||||
}
|
||||
28
backend/app/Models/Lists/UserPosition.php
Normal file
28
backend/app/Models/Lists/UserPosition.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Lists;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class UserPosition extends Model implements Auditable
|
||||
{
|
||||
use AuditableTrait;
|
||||
use HasFactory;
|
||||
|
||||
public const PROFESSOR = 'Professor';
|
||||
|
||||
public const RESEARCHER = 'Researcher';
|
||||
|
||||
public const PHD = 'PhD';
|
||||
|
||||
public const STUDENT = 'Student';
|
||||
|
||||
public const ADMINISTRATIVE = 'Administrative personnel';
|
||||
|
||||
protected $table = 'user_positions';
|
||||
|
||||
protected $fillable = ['value'];
|
||||
}
|
||||
41
backend/app/Models/Lists/UserRole.php
Normal file
41
backend/app/Models/Lists/UserRole.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Lists;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use OwenIt\Auditing\Auditable as AuditableTrait;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
|
||||
class UserRole extends Model implements Auditable
|
||||
{
|
||||
use AuditableTrait;
|
||||
use HasFactory;
|
||||
|
||||
public const ADMIN = 'Admin';
|
||||
|
||||
public const SUPERVISOR = 'Supervisor';
|
||||
|
||||
public const USER = 'User';
|
||||
|
||||
public const GUEST = 'Guest';
|
||||
|
||||
protected $table = 'user_roles';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* Utenti che hanno questo ruolo.
|
||||
*
|
||||
* @return HasMany<User, $this>
|
||||
*/
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class, 'role_id');
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,59 @@
|
||||
|
||||
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\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 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 +66,43 @@ 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',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruolo applicativo dell'utente.
|
||||
*
|
||||
* @return BelongsTo<UserRole, $this>
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserRole::class, 'role_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
}
|
||||
|
||||
54
backend/app/Notifications/ResetPasswordNotification.php
Normal file
54
backend/app/Notifications/ResetPasswordNotification.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ResetPasswordNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(public readonly string $token)
|
||||
{
|
||||
// Il token viene passato al costruttore e reso disponibile come proprietà pubblica
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->line('The introduction to the notification.')
|
||||
->action('Notification Action', url('/'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
53
backend/app/Notifications/WelcomeNotification.php
Normal file
53
backend/app/Notifications/WelcomeNotification.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class WelcomeNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->line('The introduction to the notification.')
|
||||
->action('Notification Action', url('/'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
56
backend/app/Providers/FortifyServiceProvider.php
Normal file
56
backend/app/Providers/FortifyServiceProvider.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
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::createUsersUsing(CreateNewUser::class);
|
||||
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
RateLimiter::for('passkeys', function (Request $request) {
|
||||
$credentialId = $request->input('credential.id');
|
||||
|
||||
return Limit::perMinute(10)->by(
|
||||
($credentialId ?: $request->session()->getId()).'|'.$request->ip()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,51 @@
|
||||
<?php
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\FortifyServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
FortifyServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -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
204
backend/config/audit.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
use OwenIt\Auditing\Models\Audit;
|
||||
use OwenIt\Auditing\Resolvers\IpAddressResolver;
|
||||
use OwenIt\Auditing\Resolvers\UrlResolver;
|
||||
use OwenIt\Auditing\Resolvers\UserAgentResolver;
|
||||
use OwenIt\Auditing\Resolvers\UserResolver;
|
||||
|
||||
return [
|
||||
|
||||
'enabled' => env('AUDITING_ENABLED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Implementation
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define which Audit model implementation should be used.
|
||||
|
|
||||
*/
|
||||
|
||||
'implementation' => Audit::class,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Morph prefix & Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define the morph prefix and authentication guards for the User resolver.
|
||||
|
|
||||
*/
|
||||
|
||||
'user' => [
|
||||
'morph_prefix' => 'user',
|
||||
'guards' => [
|
||||
'web',
|
||||
'api',
|
||||
],
|
||||
'resolver' => UserResolver::class,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Resolvers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define the IP Address, User Agent and URL resolver implementations.
|
||||
|
|
||||
*/
|
||||
'resolvers' => [
|
||||
'ip_address' => IpAddressResolver::class,
|
||||
'user_agent' => UserAgentResolver::class,
|
||||
'url' => UrlResolver::class,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Events
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The Eloquent events that trigger an Audit.
|
||||
|
|
||||
*/
|
||||
|
||||
'events' => [
|
||||
'created',
|
||||
'updated',
|
||||
'deleted',
|
||||
'restored',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Strict Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Enable the strict mode when auditing?
|
||||
|
|
||||
*/
|
||||
|
||||
'strict' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global exclude
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Have something you always want to exclude by default? - add it here.
|
||||
| Note that this is overwritten (not merged) with local exclude
|
||||
|
|
||||
*/
|
||||
|
||||
'exclude' => [],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Empty Values
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Should Audit records be stored when the recorded old_values & new_values
|
||||
| are both empty?
|
||||
|
|
||||
| Some events may be empty on purpose. Use allowed_empty_values to exclude
|
||||
| those from the empty values check. For example when auditing
|
||||
| model retrieved events which will never have new and old values.
|
||||
|
|
||||
|
|
||||
*/
|
||||
|
||||
'empty_values' => true,
|
||||
'allowed_empty_values' => [
|
||||
'retrieved',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Allowed Array Values
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Should the array values be audited?
|
||||
|
|
||||
| By default, array values are not allowed. This is to prevent performance
|
||||
| issues when storing large amounts of data. You can override this by
|
||||
| setting allow_array_values to true.
|
||||
*/
|
||||
'allowed_array_values' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Timestamps
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Should the created_at, updated_at and deleted_at timestamps be audited?
|
||||
|
|
||||
*/
|
||||
|
||||
'timestamps' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Threshold
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Specify a threshold for the amount of Audit records a model can have.
|
||||
| Zero means no limit.
|
||||
|
|
||||
*/
|
||||
|
||||
'threshold' => 0,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The default audit driver used to keep track of changes.
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => 'database',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Driver Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Available audit drivers and respective configurations.
|
||||
|
|
||||
*/
|
||||
|
||||
'drivers' => [
|
||||
'database' => [
|
||||
'table' => 'audits',
|
||||
'connection' => null,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Queue Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Available audit queue configurations.
|
||||
|
|
||||
*/
|
||||
|
||||
'queue' => [
|
||||
'enable' => false,
|
||||
'connection' => 'sync',
|
||||
'queue' => 'default',
|
||||
'delay' => 0,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Console
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Whether console events should be audited (eg. php artisan db:seed).
|
||||
|
|
||||
*/
|
||||
|
||||
'console' => false,
|
||||
];
|
||||
45
backend/config/cors.php
Normal file
45
backend/config/cors.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your settings for cross-origin resource sharing
|
||||
| or "CORS". This determines what cross-origin operations may execute
|
||||
| in web browsers. You are free to adjust these settings as needed.
|
||||
|
|
||||
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
|
|
||||
*/
|
||||
|
||||
// Le rotte Fortify (login/logout/2FA/reset) sono sotto prefisso `api/`,
|
||||
// quindi `api/*` le copre tutte; `sanctum/csrf-cookie` rilascia il cookie XSRF.
|
||||
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
// Origini autorizzate per le richieste cross-origin con credenziali (cookie).
|
||||
// Con `supports_credentials => true` NON è ammesso il wildcard `*`: serve la
|
||||
// lista esatta delle origini. In dev l'unica che porta il cookie di sessione è
|
||||
// APP_URL, perché SESSION_DOMAIN è scopato a `.dyncoll-dev.local`.
|
||||
// FRONTEND_URL (se valorizzato) fa da override e accetta più origini separate
|
||||
// da virgola; altrimenti si usa APP_URL.
|
||||
'allowed_origins' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) env('FRONTEND_URL', env('APP_URL', 'https://dyncoll-dev.local')))
|
||||
))),
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
'allowed_headers' => ['*'],
|
||||
|
||||
'exposed_headers' => [],
|
||||
|
||||
'max_age' => 0,
|
||||
|
||||
'supports_credentials' => true,
|
||||
|
||||
];
|
||||
@@ -17,7 +17,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
'default' => env('DB_CONNECTION', 'mysql'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -84,6 +84,31 @@ return [
|
||||
]) : [],
|
||||
],
|
||||
|
||||
/*
|
||||
* Sorgente legacy (dyncoll v1) in sola lettura per l'ETL `php artisan v1:import`.
|
||||
* Le DB_LEGACY_* NON stanno in .env.example: si impostano nell'ambiente che esegue
|
||||
* l'import (dev: dump di produzione restorato in locale; cutover: DB v1 reale).
|
||||
* `strict=false` per tollerare il sql_mode del DB v1.
|
||||
*/
|
||||
'legacy' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_LEGACY_URL'),
|
||||
'host' => env('DB_LEGACY_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_LEGACY_PORT', '3306'),
|
||||
'database' => env('DB_LEGACY_DATABASE', 'dyncoll'),
|
||||
'username' => env('DB_LEGACY_USERNAME', 'root'),
|
||||
'password' => env('DB_LEGACY_PASSWORD', ''),
|
||||
'charset' => env('DB_LEGACY_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_LEGACY_COLLATION', 'utf8mb4_0900_ai_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => false,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('DB_LEGACY_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
|
||||
160
backend/config/fortify.php
Normal file
160
backend/config/fortify.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Fortify will use while
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Password Broker
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which password broker Fortify can use when a user
|
||||
| is resetting their password. This configured value should match one
|
||||
| of your password brokers setup in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => 'users',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Username / Email
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines which model attribute should be considered as your
|
||||
| application's "username" field. Typically, this might be the email
|
||||
| address of the users but you are free to change this value here.
|
||||
|
|
||||
| Out of the box, Fortify expects forgot password and reset password
|
||||
| requests to have a field named 'email'. If the application uses
|
||||
| another name for the field you may define it below as needed.
|
||||
|
|
||||
*/
|
||||
|
||||
'username' => 'email',
|
||||
|
||||
'email' => 'email',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Lowercase Usernames
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines whether usernames should be lowercased before saving
|
||||
| them in the database, as some database system string fields are case
|
||||
| sensitive. You may disable this for your application if necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'lowercase_usernames' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Home Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the path where users will get redirected during
|
||||
| authentication or password reset when the operations are successful
|
||||
| and the user is authenticated. You are free to change this value.
|
||||
|
|
||||
*/
|
||||
|
||||
'home' => '/home',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Prefix / Subdomain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which prefix Fortify will assign to all the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| subdomain under which all of the Fortify routes will be available.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => 'api',
|
||||
|
||||
'domain' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which middleware Fortify will assign to the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| these middleware but typically this provided default is preferred.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Fortify will throttle logins to five requests per minute for
|
||||
| every email and IP address combination. However, if you would like to
|
||||
| specify a custom rate limiter to call then you may specify it here.
|
||||
|
|
||||
*/
|
||||
|
||||
'limiters' => [
|
||||
'login' => 'login',
|
||||
'two-factor' => 'two-factor',
|
||||
'passkeys' => 'passkeys',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register View Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify if the routes returning views should be disabled as
|
||||
| you may not need them when building your own application. This may be
|
||||
| especially true if you're writing a custom single-page application.
|
||||
|
|
||||
*/
|
||||
|
||||
'views' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Features
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some of the Fortify features are optional. You may disable the features
|
||||
| by removing them from this array. You're free to only remove some of
|
||||
| these features or you can even remove all of these if you need to.
|
||||
|
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
Features::registration(),
|
||||
Features::resetPasswords(),
|
||||
Features::emailVerification(),
|
||||
Features::updateProfileInformation(),
|
||||
Features::updatePasswords(),
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
// 'window' => 0,
|
||||
]),
|
||||
],
|
||||
|
||||
];
|
||||
37
backend/config/messages.php
Normal file
37
backend/config/messages.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Messaggi CRUD generici
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Usati dal trait App\Http\Traits\ApiResponse quando non viene passato un
|
||||
| prefisso specifico per risorsa. Per messaggi dedicati a una risorsa basta
|
||||
| aggiungere un sotto-array (es. 'user_roles' => ['created' => '...']).
|
||||
|
|
||||
*/
|
||||
|
||||
'index' => 'List retrieved successfully.',
|
||||
'show' => 'Resource retrieved successfully.',
|
||||
'created' => 'Resource created successfully.',
|
||||
'updated' => 'Resource updated successfully.',
|
||||
'deleted' => 'Resource deleted successfully.',
|
||||
'restored' => 'Resource restored successfully.',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Autenticazione / account
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'auth' => [
|
||||
'logout_success' => 'Logged out successfully.',
|
||||
],
|
||||
|
||||
'account' => [
|
||||
'deleted' => 'Account deleted successfully.',
|
||||
],
|
||||
|
||||
];
|
||||
102
backend/config/sanctum.php
Normal file
102
backend/config/sanctum.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => explode(
|
||||
',',
|
||||
env(
|
||||
'SANCTUM_STATEFUL_DOMAINS',
|
||||
sprintf(
|
||||
'%s%s',
|
||||
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||
Sanctum::currentApplicationUrlWithPort(),
|
||||
// Sanctum::currentRequestHost(),
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
'sanctum' => [
|
||||
'driver' => 'sanctum',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => AuthenticateSession::class,
|
||||
'encrypt_cookies' => EncryptCookies::class,
|
||||
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
34
backend/database/factories/InstitutionFactory.php
Normal file
34
backend/database/factories/InstitutionFactory.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Institution;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Institution>
|
||||
*/
|
||||
class InstitutionFactory extends Factory
|
||||
{
|
||||
protected $model = Institution::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => InstitutionCategory::factory(),
|
||||
'name' => fake()->unique()->company(),
|
||||
'abbreviation' => strtoupper(fake()->unique()->lexify('?????')),
|
||||
'address' => fake()->streetAddress(),
|
||||
'city' => fake()->city(),
|
||||
'lat' => fake()->latitude(),
|
||||
'lon' => fake()->longitude(),
|
||||
'logo' => 'default.jpg',
|
||||
'color' => '#c5cae9',
|
||||
'is_storage_place' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
30
backend/database/factories/InstitutionLinkFactory.php
Normal file
30
backend/database/factories/InstitutionLinkFactory.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<InstitutionLink>
|
||||
*/
|
||||
class InstitutionLinkFactory extends Factory
|
||||
{
|
||||
protected $model = InstitutionLink::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'institution_id' => Institution::factory(),
|
||||
'url' => fake()->url(),
|
||||
'type' => InstitutionLinkType::Official,
|
||||
'label' => null,
|
||||
'sort_order' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories\Lists;
|
||||
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<InstitutionCategory>
|
||||
*/
|
||||
class InstitutionCategoryFactory extends Factory
|
||||
{
|
||||
protected $model = InstitutionCategory::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'value' => fake()->unique()->word(),
|
||||
];
|
||||
}
|
||||
}
|
||||
26
backend/database/factories/Lists/UserPositionFactory.php
Normal file
26
backend/database/factories/Lists/UserPositionFactory.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories\Lists;
|
||||
|
||||
use App\Models\Lists\UserPosition;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<UserPosition>
|
||||
*/
|
||||
class UserPositionFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected $model = UserPosition::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'value' => fake()->unique()->word(),
|
||||
];
|
||||
}
|
||||
}
|
||||
25
backend/database/factories/Lists/UserRoleFactory.php
Normal file
25
backend/database/factories/Lists/UserRoleFactory.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories\Lists;
|
||||
|
||||
use App\Models\Lists\UserRole;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<UserRole>
|
||||
*/
|
||||
class UserRoleFactory extends Factory
|
||||
{
|
||||
protected $model = UserRole::class;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => ucfirst($this->faker->unique()->word()),
|
||||
'description' => $this->faker->sentence(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_roles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('description')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_roles');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('role_id')->nullable()->after('id');
|
||||
$table->foreign('role_id')->references('id')->on('user_roles')->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropForeign(['role_id']);
|
||||
$table->dropColumn('role_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('must_change_password')->default(true)->after('password');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('must_change_password');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Utente di sistema: riceve i contenuti riassegnati e non è eliminabile.
|
||||
$table->boolean('is_system')->default(false)->after('role_id');
|
||||
|
||||
// Traccia l'avvenuta anonimizzazione (GDPR). Il record resta ai fini
|
||||
// di integrità referenziale ma esce dal cestino e dalle liste utenti.
|
||||
$table->timestamp('anonymized_at')->nullable()->after('deleted_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn(['is_system', 'anonymized_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->text('two_factor_secret')
|
||||
->after('password')
|
||||
->nullable();
|
||||
|
||||
$table->text('two_factor_recovery_codes')
|
||||
->after('two_factor_secret')
|
||||
->nullable();
|
||||
|
||||
$table->timestamp('two_factor_confirmed_at')
|
||||
->after('two_factor_recovery_codes')
|
||||
->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'two_factor_secret',
|
||||
'two_factor_recovery_codes',
|
||||
'two_factor_confirmed_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$connection = config('audit.drivers.database.connection', config('database.default'));
|
||||
$table = config('audit.drivers.database.table', 'audits');
|
||||
|
||||
Schema::connection($connection)->create($table, function (Blueprint $table) {
|
||||
|
||||
$morphPrefix = config('audit.user.morph_prefix', 'user');
|
||||
|
||||
$table->bigIncrements('id');
|
||||
$table->string($morphPrefix.'_type')->nullable();
|
||||
$table->unsignedBigInteger($morphPrefix.'_id')->nullable();
|
||||
$table->string('event');
|
||||
$table->morphs('auditable');
|
||||
$table->text('old_values')->nullable();
|
||||
$table->text('new_values')->nullable();
|
||||
$table->text('url')->nullable();
|
||||
$table->ipAddress('ip_address')->nullable();
|
||||
$table->string('user_agent', 1023)->nullable();
|
||||
$table->string('tags')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index([$morphPrefix.'_id', $morphPrefix.'_type']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$connection = config('audit.drivers.database.connection', config('database.default'));
|
||||
$table = config('audit.drivers.database.table', 'audits');
|
||||
|
||||
Schema::connection($connection)->drop($table);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->timestamp('two_factor_setup_completed_at')
|
||||
->nullable()
|
||||
->after('two_factor_confirmed_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('two_factor_setup_completed_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('institution_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('value', 25)->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('institution_categories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('institutions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('category_id')
|
||||
->constrained('institution_categories')
|
||||
->cascadeOnUpdate()
|
||||
->restrictOnDelete();
|
||||
$table->string('name')->unique();
|
||||
$table->string('abbreviation', 5)->unique();
|
||||
$table->string('address');
|
||||
$table->string('city', 100)->index();
|
||||
$table->decimal('lat', 10, 6);
|
||||
$table->decimal('lon', 10, 6);
|
||||
$table->string('logo');
|
||||
$table->string('color', 50)->default('#c5cae9');
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->boolean('is_storage_place')->default(true);
|
||||
// Id del record nel DB legacy (v1): chiave di upsert per l'ETL e per
|
||||
// risolvere le FK delle tabelle dipendenti (es. artifact.owner).
|
||||
$table->unsignedBigInteger('legacy_id')->nullable()->unique();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('institutions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('institution_links', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('institution_id')
|
||||
->constrained()
|
||||
->cascadeOnDelete();
|
||||
$table->string('url', 2000);
|
||||
// Vincolato a App\Enums\InstitutionLinkType (cast + validazione lato app).
|
||||
$table->string('type', 30);
|
||||
$table->string('label', 100)->nullable();
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['institution_id', 'sort_order']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('institution_links');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_positions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('value', 25)->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_positions');
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
@@ -11,15 +10,16 @@ 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
33
backend/database/seeders/InstitutionCategorySeeder.php
Normal file
33
backend/database/seeders/InstitutionCategorySeeder.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class InstitutionCategorySeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Categorie di istituzione. Idempotente.
|
||||
*
|
||||
* Gli id sono FISSI e coincidono con il DB legacy (v1) così l'ETL mappa
|
||||
* institutions.category_id 1:1 senza tradurre. La categoria 1 ("uncategorized",
|
||||
* mai usata) e la 5 (inesistente nel legacy) sono volutamente assenti.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$categories = [
|
||||
2 => InstitutionCategory::LIBRARY,
|
||||
3 => InstitutionCategory::MUSEUM,
|
||||
4 => InstitutionCategory::PUBLIC_ADMINISTRATION,
|
||||
6 => InstitutionCategory::RESEARCH_INSTITUTE,
|
||||
];
|
||||
|
||||
foreach ($categories as $id => $value) {
|
||||
InstitutionCategory::updateOrCreate(
|
||||
['id' => $id],
|
||||
['value' => $value],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
41
backend/database/seeders/SystemUserSeeder.php
Normal file
41
backend/database/seeders/SystemUserSeeder.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Lists\UserRole;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Crea l'utente di sistema: riceve i contenuti riassegnati dagli utenti
|
||||
* cancellati (flusso GDPR / oblio) e non è eliminabile né accessibile
|
||||
* (password casuale, nessun login previsto). Idempotente.
|
||||
*
|
||||
* Dipende da UserRoleSeeder (serve il ruolo Admin).
|
||||
*/
|
||||
class SystemUserSeeder extends Seeder
|
||||
{
|
||||
public const EMAIL = 'system@dyncoll.local';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$adminRoleId = UserRole::where('name', UserRole::ADMIN)->value('id');
|
||||
|
||||
User::updateOrCreate(
|
||||
['email' => self::EMAIL],
|
||||
[
|
||||
'name' => 'Dyncoll System',
|
||||
'password' => Hash::make(Str::random(60)),
|
||||
'role_id' => $adminRoleId,
|
||||
'is_system' => true,
|
||||
'must_change_password' => false,
|
||||
'email_verified_at' => now(),
|
||||
'two_factor_setup_completed_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
$this->command?->info('✅ System user ready: '.self::EMAIL);
|
||||
}
|
||||
}
|
||||
29
backend/database/seeders/UserPositionSeeder.php
Normal file
29
backend/database/seeders/UserPositionSeeder.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Lists\UserPosition;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class UserPositionSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$positions = [
|
||||
UserPosition::PROFESSOR,
|
||||
UserPosition::RESEARCHER,
|
||||
UserPosition::PHD,
|
||||
UserPosition::STUDENT,
|
||||
UserPosition::ADMINISTRATIVE,
|
||||
];
|
||||
|
||||
foreach ($positions as $position) {
|
||||
UserPosition::updateOrCreate(
|
||||
['value' => $position]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
42
backend/database/seeders/UserRoleSeeder.php
Normal file
42
backend/database/seeders/UserRoleSeeder.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Lists\UserRole;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class UserRoleSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Ruoli applicativi di base. Idempotente.
|
||||
* (description in inglese: lingua ufficiale dell'applicazione.)
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
UserRole::updateOrCreate(
|
||||
['name' => UserRole::ADMIN],
|
||||
['description' => 'Full access to all features.'
|
||||
.' Manages users, configurations and global data supervision.'
|
||||
.' Can create, read, update and delete any record.']
|
||||
);
|
||||
|
||||
UserRole::updateOrCreate(
|
||||
['name' => UserRole::SUPERVISOR],
|
||||
['description' => 'Limited access to certain features.'
|
||||
.' Can manage users but not administrators.'
|
||||
.' Can create, read, update and delete both own and other users\' records.']
|
||||
);
|
||||
|
||||
UserRole::updateOrCreate(
|
||||
['name' => UserRole::USER],
|
||||
['description' => 'Day-to-day operational use. Can access all records'
|
||||
.' and create new ones, but can only update or delete their own.']
|
||||
);
|
||||
|
||||
UserRole::updateOrCreate(
|
||||
['name' => UserRole::GUEST],
|
||||
['description' => 'Very limited access. Can only view information'
|
||||
.' not publicly available, with no editing capabilities.']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,19 +18,25 @@
|
||||
</include>
|
||||
</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>
|
||||
|
||||
36
backend/routes/api.php
Normal file
36
backend/routes/api.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AuthController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// File "core": rotte di sessione e pubbliche trasversali. Le rotte per modello
|
||||
// stanno in routes/api/*.php, caricate automaticamente dal then: in bootstrap/app.php
|
||||
// (prefisso `api` + tier riusabili `tier.app` / `tier.admin`).
|
||||
|
||||
$AUTH = 'auth:sanctum';
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rotte pubbliche (nessun auth), protette dal throttling.
|
||||
// Limiter `public` definito in App\Providers\AppServiceProvider::boot()
|
||||
// (60 req/min per IP) — modificare lì il limite.
|
||||
// -----------------------------------------------------------------------
|
||||
Route::middleware('throttle:public')->group(function () {
|
||||
// Solo endpoint GET (index/show) leggibili da chiunque.
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tier "transitional": autenticato, MA setup eventualmente incompleto
|
||||
// (password forzata da cambiare e/o 2FA non ancora configurata).
|
||||
//
|
||||
// ⚠️ NON aggiungere `setup.complete` qui: queste rotte devono restare
|
||||
// raggiungibili PROPRIO durante il setup, altrimenti deadlock. In particolare
|
||||
// il frontend legge GET /user per scoprire `setup_status` e decidere cosa
|
||||
// mostrare. Le azioni di setup vero (cambio password, abilitazione 2FA) e la
|
||||
// verifica email le registra Fortify, già auth-only e fuori da `setup.complete`.
|
||||
// -----------------------------------------------------------------------
|
||||
Route::middleware($AUTH)->group(function () {
|
||||
Route::get('/user', [AuthController::class, 'me']);
|
||||
// Sovrascrive la rotta logout di Fortify (registrata dopo → precedenza).
|
||||
Route::post('/logout', [AuthController::class, 'logout']);
|
||||
Route::delete('/account', [AuthController::class, 'destroyAccount']);
|
||||
});
|
||||
23
backend/routes/api/institution-categories.php
Normal file
23
backend/routes/api/institution-categories.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InstitutionCategoryController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// rotte pubbliche con throttling
|
||||
Route::middleware('throttle:public')->group(function () {
|
||||
Route::apiResource('institution-categories', InstitutionCategoryController::class)
|
||||
->only(['index', 'show']);
|
||||
});
|
||||
|
||||
// Prefisso `api` e tier applicati dal loader in bootstrap/app.php.
|
||||
|
||||
// Lettura: utente pienamente operativo.
|
||||
Route::middleware('tier.app')->group(function () {
|
||||
Route::get('institution-categories/{institution_category}/usage', [InstitutionCategoryController::class, 'usage']);
|
||||
});
|
||||
|
||||
// Scrittura: solo Admin.
|
||||
Route::middleware('tier.admin')->group(function () {
|
||||
Route::apiResource('institution-categories', InstitutionCategoryController::class)
|
||||
->only(['store', 'update', 'destroy']);
|
||||
});
|
||||
34
backend/routes/api/institutions.php
Normal file
34
backend/routes/api/institutions.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InstitutionController;
|
||||
use App\Http\Controllers\InstitutionLinkController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Prefisso `api` e tier (`tier.app` / `tier.admin`) applicati dal loader in
|
||||
// bootstrap/app.php. Le istituzioni si risolvono per `uuid` (route key del model).
|
||||
|
||||
// rotte pubbliche con throttling
|
||||
Route::middleware('throttle:public')->group(function () {
|
||||
Route::apiResource('institutions', InstitutionController::class)->only(['index', 'show']);
|
||||
Route::apiResource('institutions.links', InstitutionLinkController::class)
|
||||
->only(['index', 'show'])
|
||||
->scoped();
|
||||
});
|
||||
|
||||
// Lettura: utente pienamente operativo.
|
||||
Route::middleware('tier.app')->group(function () {
|
||||
//
|
||||
});
|
||||
|
||||
// Scrittura + soft delete: solo Admin.
|
||||
Route::middleware('tier.admin')->group(function () {
|
||||
Route::apiResource('institutions', InstitutionController::class)->only(['store', 'update', 'destroy']);
|
||||
|
||||
// Soft delete: ripristino e cancellazione definitiva (binding sui cestinati).
|
||||
Route::post('institutions/{institution}/restore', [InstitutionController::class, 'restore'])->withTrashed();
|
||||
Route::delete('institutions/{institution}/force', [InstitutionController::class, 'forceDestroy'])->withTrashed();
|
||||
|
||||
Route::apiResource('institutions.links', InstitutionLinkController::class)
|
||||
->only(['store', 'update', 'destroy'])
|
||||
->scoped();
|
||||
});
|
||||
12
backend/routes/api/user-positions.php
Normal file
12
backend/routes/api/user-positions.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UserPositionController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Prefisso `api` e stack `api` sono applicati dal loader in bootstrap/app.php.
|
||||
// I tier `tier.app` / `tier.admin` sono definiti lì come middleware group.
|
||||
|
||||
// Lookup fissa, non gestibile da interfaccia: solo lettura.
|
||||
Route::middleware('throttle:public')->group(function () {
|
||||
Route::apiResource('user-positions', UserPositionController::class)->only(['index', 'show']);
|
||||
});
|
||||
17
backend/routes/api/user-roles.php
Normal file
17
backend/routes/api/user-roles.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UserRoleController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Prefisso `api` e stack `api` sono applicati dal loader in bootstrap/app.php.
|
||||
// I tier `tier.app` / `tier.admin` sono definiti lì come middleware group.
|
||||
|
||||
// Lookup fissa, non gestibile da interfaccia: solo lettura.
|
||||
Route::middleware('tier.app')->group(function () {
|
||||
Route::apiResource('user-roles', UserRoleController::class)->only(['index', 'show']);
|
||||
});
|
||||
|
||||
// Statistiche: solo Admin.
|
||||
Route::middleware('tier.admin')->group(function () {
|
||||
Route::get('user-roles/{user_role}/usage', [UserRoleController::class, 'usage']);
|
||||
});
|
||||
11
backend/routes/api/users.php
Normal file
11
backend/routes/api/users.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UserController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Gestione utenti (sola lettura): area admin.
|
||||
Route::middleware('tier.admin')->group(function () {
|
||||
Route::get('users', [UserController::class, 'index']);
|
||||
// withTrashed: l'admin può ispezionare anche utenti cestinati/anonimizzati.
|
||||
Route::get('users/{user}', [UserController::class, 'show'])->withTrashed();
|
||||
});
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
// Anonimizzazione GDPR degli utenti oltre la retention (richiede lo scheduler attivo).
|
||||
Schedule::command('users:purge')->daily();
|
||||
|
||||
74
backend/tests/Concerns/CreatesLegacyDatabase.php
Normal file
74
backend/tests/Concerns/CreatesLegacyDatabase.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Concerns;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Costruisce una connessione `legacy` SQLite in-memory che replica le tabelle v1
|
||||
* lette dall'ETL (`institution`), così gli importer si testano senza un MySQL v1.
|
||||
*/
|
||||
trait CreatesLegacyDatabase
|
||||
{
|
||||
/** Riconfigura la connessione `legacy` su SQLite :memory: e ne crea lo schema v1. */
|
||||
protected function fakeLegacyConnection(): void
|
||||
{
|
||||
config()->set('database.connections.legacy', [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => false,
|
||||
]);
|
||||
DB::purge('legacy');
|
||||
|
||||
$schema = Schema::connection('legacy');
|
||||
$schema->dropIfExists('institution');
|
||||
|
||||
$schema->create('institution', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('id')->primary();
|
||||
$table->unsignedBigInteger('category');
|
||||
$table->string('name', 255);
|
||||
$table->string('abbreviation', 5);
|
||||
$table->string('address', 255);
|
||||
$table->decimal('lat', 10, 6);
|
||||
$table->decimal('lon', 10, 6);
|
||||
$table->string('url', 2000)->nullable();
|
||||
$table->string('logo', 255);
|
||||
$table->string('uuid', 36);
|
||||
$table->string('color', 50)->nullable();
|
||||
$table->string('city', 100);
|
||||
$table->boolean('is_storage_place');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserisce una riga `institution` legacy e ne restituisce l'id.
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
protected function insertLegacyInstitution(array $overrides = []): int
|
||||
{
|
||||
$row = array_merge([
|
||||
'id' => fake()->unique()->numberBetween(1, 1_000_000),
|
||||
'category' => 3,
|
||||
'name' => 'Some Museum',
|
||||
'abbreviation' => 'SM',
|
||||
'address' => 'Main Street 1',
|
||||
'lat' => 55.704660,
|
||||
'lon' => 13.191007,
|
||||
'url' => 'https://example.org/',
|
||||
'logo' => 'default.jpg',
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'color' => '#abcdef',
|
||||
'city' => 'Lund',
|
||||
'is_storage_place' => 1,
|
||||
], $overrides);
|
||||
|
||||
DB::connection('legacy')->table('institution')->insert($row);
|
||||
|
||||
return (int) $row['id'];
|
||||
}
|
||||
}
|
||||
66
backend/tests/Concerns/CreatesUsers.php
Normal file
66
backend/tests/Concerns/CreatesUsers.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Concerns;
|
||||
|
||||
use App\Models\Lists\UserRole;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Helper per costruire utenti nei vari stati del setup, senza ripetere ovunque
|
||||
* lo stesso array di attributi. La factory di default crea un utente verificato
|
||||
* ma con must_change_password=true (default di colonna) → setup incompleto: qui
|
||||
* forniamo scorciatoie per gli stati che servono ai test (operativo, admin,
|
||||
* di sistema, setup incompleto, non verificato).
|
||||
*/
|
||||
trait CreatesUsers
|
||||
{
|
||||
/** Ruolo applicativo, riusato se già presente (idempotente in-test). */
|
||||
protected function role(string $name): UserRole
|
||||
{
|
||||
return UserRole::firstOrCreate(['name' => $name], ['description' => $name.' role']);
|
||||
}
|
||||
|
||||
/** Utente pienamente operativo: verificato, setup completo, ruolo User. */
|
||||
protected function operationalUser(array $overrides = []): User
|
||||
{
|
||||
return User::factory()->create(array_merge([
|
||||
'role_id' => $this->role(UserRole::USER)->id,
|
||||
'email_verified_at' => now(),
|
||||
'must_change_password' => false,
|
||||
'two_factor_setup_completed_at' => now(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/** Operativo con ruolo Admin → supera tier.admin / IsAdmin. */
|
||||
protected function adminUser(array $overrides = []): User
|
||||
{
|
||||
return $this->operationalUser(array_merge([
|
||||
'role_id' => $this->role(UserRole::ADMIN)->id,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/** Utente di sistema (destinatario dei contenuti riassegnati, non eliminabile). */
|
||||
protected function systemUser(array $overrides = []): User
|
||||
{
|
||||
return $this->operationalUser(array_merge([
|
||||
'is_system' => true,
|
||||
'role_id' => $this->role(UserRole::ADMIN)->id,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/** Autenticato ma setup incompleto (deve cambiare password). */
|
||||
protected function setupIncompleteUser(array $overrides = []): User
|
||||
{
|
||||
return $this->operationalUser(array_merge([
|
||||
'must_change_password' => true,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/** Setup completo ma email non verificata → blocco di `verified`. */
|
||||
protected function unverifiedUser(array $overrides = []): User
|
||||
{
|
||||
return $this->operationalUser(array_merge([
|
||||
'email_verified_at' => null,
|
||||
], $overrides));
|
||||
}
|
||||
}
|
||||
73
backend/tests/Feature/Auth/AccessTierTest.php
Normal file
73
backend/tests/Feature/Auth/AccessTierTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Verifica la scala di accesso (tier.app / tier.admin) attraverso rotte reali:
|
||||
* - tier.app → user-roles index (auth + verified + setup.complete)
|
||||
* - tier.admin→ users index (+ admin)
|
||||
* Copre i rami di EnsureSetupComplete, del middleware `verified` e di IsAdmin.
|
||||
*/
|
||||
class AccessTierTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const APP_ROUTE = '/api/user-roles';
|
||||
|
||||
private const ADMIN_ROUTE = '/api/users';
|
||||
|
||||
public function test_tier_app_allows_a_fully_operational_user(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
|
||||
$this->getJson(self::APP_ROUTE)->assertOk();
|
||||
}
|
||||
|
||||
public function test_tier_app_requires_authentication(): void
|
||||
{
|
||||
$this->getJson(self::APP_ROUTE)->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_tier_app_blocks_user_who_must_change_password(): void
|
||||
{
|
||||
$this->actingAs($this->setupIncompleteUser(), 'sanctum');
|
||||
|
||||
$this->getJson(self::APP_ROUTE)
|
||||
->assertForbidden()
|
||||
->assertJsonPath('setup_status', 'password_required');
|
||||
}
|
||||
|
||||
public function test_tier_app_blocks_user_without_two_factor_setup(): void
|
||||
{
|
||||
$user = $this->operationalUser(['two_factor_setup_completed_at' => null]);
|
||||
$this->actingAs($user, 'sanctum');
|
||||
|
||||
$this->getJson(self::APP_ROUTE)
|
||||
->assertForbidden()
|
||||
->assertJsonPath('setup_status', '2fa_setup_required');
|
||||
}
|
||||
|
||||
public function test_tier_app_blocks_unverified_user(): void
|
||||
{
|
||||
$this->actingAs($this->unverifiedUser(), 'sanctum');
|
||||
|
||||
$this->getJson(self::APP_ROUTE)->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_tier_admin_allows_admin(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
|
||||
$this->getJson(self::ADMIN_ROUTE)->assertOk();
|
||||
}
|
||||
|
||||
public function test_tier_admin_forbids_non_admin(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
|
||||
$this->getJson(self::ADMIN_ROUTE)->assertForbidden();
|
||||
}
|
||||
}
|
||||
86
backend/tests/Feature/Auth/AuthControllerTest.php
Normal file
86
backend/tests/Feature/Auth/AuthControllerTest.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_me_returns_authenticated_user_with_setup_status(): void
|
||||
{
|
||||
$user = $this->operationalUser();
|
||||
$this->actingAs($user, 'sanctum');
|
||||
|
||||
$response = $this->getJson('/api/user');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('id', $user->id)
|
||||
->assertJsonPath('email', $user->email)
|
||||
->assertJsonPath('setup_status', 'complete');
|
||||
|
||||
// Gli attributi sensibili non devono mai essere serializzati.
|
||||
$this->assertArrayNotHasKey('password', $response->json());
|
||||
$this->assertArrayNotHasKey('two_factor_secret', $response->json());
|
||||
$this->assertArrayNotHasKey('two_factor_recovery_codes', $response->json());
|
||||
}
|
||||
|
||||
public function test_me_is_reachable_during_setup(): void
|
||||
{
|
||||
// La rotta /user è nel tier "transitional": deve restare accessibile anche
|
||||
// a setup incompleto, così il frontend può leggere setup_status.
|
||||
$user = $this->setupIncompleteUser();
|
||||
$this->actingAs($user, 'sanctum');
|
||||
|
||||
$this->getJson('/api/user')
|
||||
->assertOk()
|
||||
->assertJsonPath('setup_status', 'password_required');
|
||||
}
|
||||
|
||||
public function test_me_requires_authentication(): void
|
||||
{
|
||||
$this->getJson('/api/user')->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_logout_succeeds_and_forgets_cookies(): void
|
||||
{
|
||||
$user = $this->operationalUser();
|
||||
$this->actingAs($user, 'sanctum');
|
||||
|
||||
$response = $this->postJson('/api/logout');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('message', config('messages.auth.logout_success'))
|
||||
->assertCookieExpired('XSRF-TOKEN');
|
||||
}
|
||||
|
||||
public function test_destroy_account_soft_deletes_the_user(): void
|
||||
{
|
||||
$user = $this->operationalUser();
|
||||
$this->actingAs($user, 'sanctum');
|
||||
|
||||
$this->deleteJson('/api/account')
|
||||
->assertOk()
|
||||
->assertJsonPath('message', config('messages.account.deleted'));
|
||||
|
||||
$this->assertSoftDeleted('users', ['id' => $user->id]);
|
||||
}
|
||||
|
||||
public function test_destroy_account_is_forbidden_for_the_system_user(): void
|
||||
{
|
||||
$user = $this->systemUser();
|
||||
$this->actingAs($user, 'sanctum');
|
||||
|
||||
$this->deleteJson('/api/account')->assertForbidden();
|
||||
|
||||
$this->assertDatabaseHas('users', ['id' => $user->id, 'deleted_at' => null]);
|
||||
}
|
||||
|
||||
public function test_session_endpoints_require_authentication(): void
|
||||
{
|
||||
$this->postJson('/api/logout')->assertUnauthorized();
|
||||
$this->deleteJson('/api/account')->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
24
backend/tests/Feature/EnvironmentSafetyTest.php
Normal file
24
backend/tests/Feature/EnvironmentSafetyTest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Guardia di sicurezza: garantisce che la suite giri sul DB di test dedicato
|
||||
* (servizio db-test), non sul database di sviluppo `dyncoll`. È volutamente
|
||||
* read-only (non usa RefreshDatabase): qui assertiamo che l'host e il nome DB
|
||||
* siano quelli di test. La protezione "dura" — che aborta PRIMA di un eventuale
|
||||
* migrate:fresh — sta nel guard beforeRefreshingDatabase() di Tests\TestCase.
|
||||
*/
|
||||
class EnvironmentSafetyTest extends TestCase
|
||||
{
|
||||
public function test_suite_runs_against_the_dedicated_test_database(): void
|
||||
{
|
||||
$this->assertSame('testing', app()->environment());
|
||||
$this->assertSame('mysql', config('database.default'));
|
||||
$this->assertSame('db-test', config('database.connections.mysql.host'));
|
||||
$this->assertSame('dyncoll_test', DB::connection()->getDatabaseName());
|
||||
}
|
||||
}
|
||||
168
backend/tests/Feature/Etl/InstitutionImporterTest.php
Normal file
168
backend/tests/Feature/Etl/InstitutionImporterTest.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Etl;
|
||||
|
||||
use App\Etl\Importers\InstitutionImporter;
|
||||
use App\Models\Institution;
|
||||
use Database\Seeders\InstitutionCategorySeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\Concerns\CreatesLegacyDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionImporterTest extends TestCase
|
||||
{
|
||||
use CreatesLegacyDatabase;
|
||||
use RefreshDatabase;
|
||||
|
||||
/** Categorie v2 con gli id legacy fissi (2,3,4,6). */
|
||||
private function seedV2Categories(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
}
|
||||
|
||||
public function test_it_imports_an_institution_and_creates_the_official_link(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution([
|
||||
'id' => 1,
|
||||
'category' => 3,
|
||||
'name' => 'Blekinge Museum',
|
||||
'abbreviation' => 'BLM',
|
||||
'city' => 'Karlskrona',
|
||||
'url' => 'https://blekingemuseum.se/',
|
||||
]);
|
||||
|
||||
$summary = (new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertSame(1, $summary->created);
|
||||
$this->assertDatabaseHas('institutions', [
|
||||
'legacy_id' => 1,
|
||||
'category_id' => 3,
|
||||
'name' => 'Blekinge Museum',
|
||||
'city' => 'Karlskrona',
|
||||
]);
|
||||
|
||||
$institution = Institution::where('legacy_id', 1)->firstOrFail();
|
||||
$this->assertNotEmpty($institution->uuid);
|
||||
$this->assertDatabaseHas('institution_links', [
|
||||
'institution_id' => $institution->id,
|
||||
'type' => 'official',
|
||||
'url' => 'https://blekingemuseum.se/',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_creates_no_link_when_the_url_is_blank(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 21, 'url' => ' ']);
|
||||
|
||||
(new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertDatabaseCount('institution_links', 0);
|
||||
}
|
||||
|
||||
public function test_it_trims_textual_fields(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 20, 'name' => 'Lund University Library ']);
|
||||
|
||||
(new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertDatabaseHas('institutions', ['legacy_id' => 20, 'name' => 'Lund University Library']);
|
||||
}
|
||||
|
||||
public function test_it_falls_back_to_the_default_color_when_legacy_color_is_blank(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1, 'color' => null]);
|
||||
|
||||
(new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertDatabaseHas('institutions', ['legacy_id' => 1, 'color' => '#c5cae9']);
|
||||
}
|
||||
|
||||
public function test_it_skips_institutions_with_an_unknown_category(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 99, 'category' => 999]);
|
||||
|
||||
$summary = (new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertSame(1, $summary->skipped);
|
||||
$this->assertNotEmpty($summary->warnings);
|
||||
$this->assertDatabaseMissing('institutions', ['legacy_id' => 99]);
|
||||
}
|
||||
|
||||
public function test_it_regenerates_the_uuid_instead_of_copying_the_legacy_one(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$legacyUuid = '00000000-0000-0000-0000-000000000000';
|
||||
$this->insertLegacyInstitution(['id' => 1, 'uuid' => $legacyUuid]);
|
||||
|
||||
(new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertNotSame($legacyUuid, Institution::where('legacy_id', 1)->value('uuid'));
|
||||
}
|
||||
|
||||
public function test_it_is_idempotent_and_keeps_the_uuid_stable(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$first = (new InstitutionImporter)->import(false);
|
||||
$uuid = Institution::where('legacy_id', 1)->value('uuid');
|
||||
$second = (new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertSame(1, $first->created);
|
||||
$this->assertSame(0, $second->created);
|
||||
$this->assertSame(1, $second->updated);
|
||||
$this->assertDatabaseCount('institutions', 1);
|
||||
$this->assertSame($uuid, Institution::where('legacy_id', 1)->value('uuid'));
|
||||
}
|
||||
|
||||
public function test_dry_run_writes_nothing(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$summary = (new InstitutionImporter)->import(true);
|
||||
|
||||
$this->assertSame(1, $summary->created);
|
||||
$this->assertDatabaseCount('institutions', 0);
|
||||
}
|
||||
|
||||
public function test_dry_run_counts_an_existing_institution_as_updated(): void
|
||||
{
|
||||
$this->seedV2Categories();
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
(new InstitutionImporter)->import(false);
|
||||
$summary = (new InstitutionImporter)->import(true);
|
||||
|
||||
$this->assertSame(0, $summary->created);
|
||||
$this->assertSame(1, $summary->updated);
|
||||
$this->assertDatabaseCount('institutions', 1);
|
||||
}
|
||||
|
||||
public function test_it_warns_and_skips_when_the_v2_lookup_is_empty(): void
|
||||
{
|
||||
// Nessun seed delle categorie v2.
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$summary = (new InstitutionImporter)->import(false);
|
||||
|
||||
$this->assertNotEmpty($summary->warnings);
|
||||
$this->assertSame(0, $summary->total());
|
||||
$this->assertDatabaseCount('institutions', 0);
|
||||
}
|
||||
}
|
||||
94
backend/tests/Feature/Etl/V1ImportCommandTest.php
Normal file
94
backend/tests/Feature/Etl/V1ImportCommandTest.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Etl;
|
||||
|
||||
use Database\Seeders\InstitutionCategorySeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\CreatesLegacyDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class V1ImportCommandTest extends TestCase
|
||||
{
|
||||
use CreatesLegacyDatabase;
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_imports_and_reports_counts(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$this->artisan('v1:import')
|
||||
->expectsOutputToContain('created 1, updated 0, skipped 0')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseCount('institutions', 1);
|
||||
}
|
||||
|
||||
public function test_dry_run_writes_nothing(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$this->artisan('v1:import --dry-run')
|
||||
->expectsOutputToContain('DRY-RUN')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseCount('institutions', 0);
|
||||
}
|
||||
|
||||
public function test_only_filter_skips_unmatched_importers(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 1]);
|
||||
|
||||
$this->artisan('v1:import --only=nonexistent')->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseCount('institutions', 0);
|
||||
}
|
||||
|
||||
public function test_it_prints_importer_warnings(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
$this->fakeLegacyConnection();
|
||||
$this->insertLegacyInstitution(['id' => 99, 'category' => 999]);
|
||||
|
||||
$this->artisan('v1:import')
|
||||
->expectsOutputToContain('category 999')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_it_fails_when_an_importer_throws(): void
|
||||
{
|
||||
// Connessione raggiungibile (getPdo ok) ma senza la tabella `institution`:
|
||||
// l'import lancia, il comando intercetta e fallisce.
|
||||
config()->set('database.connections.legacy', [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
]);
|
||||
DB::purge('legacy');
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
|
||||
$this->artisan('v1:import')
|
||||
->expectsOutputToContain('failed')
|
||||
->assertFailed();
|
||||
}
|
||||
|
||||
public function test_it_fails_when_the_legacy_connection_is_unavailable(): void
|
||||
{
|
||||
config()->set('database.connections.legacy', [
|
||||
'driver' => 'sqlite',
|
||||
'database' => '/nonexistent/path/legacy.sqlite',
|
||||
'prefix' => '',
|
||||
]);
|
||||
DB::purge('legacy');
|
||||
|
||||
$this->artisan('v1:import')
|
||||
->expectsOutputToContain('Legacy connection unavailable')
|
||||
->assertFailed();
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
110
backend/tests/Feature/InstitutionCategoryControllerTest.php
Normal file
110
backend/tests/Feature/InstitutionCategoryControllerTest.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Institution;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionCategoryControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// --- Lettura (tier.app) --------------------------------------------------
|
||||
|
||||
public function test_index_lists_categories(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
InstitutionCategory::factory()->count(2)->create();
|
||||
|
||||
$this->getJson('/api/institution-categories')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['message', 'data' => [['id', 'value']]])
|
||||
->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
public function test_show_returns_a_single_category(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
|
||||
$this->getJson("/api/institution-categories/{$category->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $category->id);
|
||||
}
|
||||
|
||||
public function test_usage_reflects_whether_the_category_is_used(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$free = InstitutionCategory::factory()->create();
|
||||
$used = InstitutionCategory::factory()->create();
|
||||
Institution::factory()->create(['category_id' => $used->id]);
|
||||
|
||||
$this->getJson("/api/institution-categories/{$free->id}/usage")->assertJsonPath('in_use', false);
|
||||
$this->getJson("/api/institution-categories/{$used->id}/usage")->assertJsonPath('in_use', true);
|
||||
}
|
||||
|
||||
// --- Scrittura (tier.admin) ---------------------------------------------
|
||||
|
||||
public function test_admin_can_create_a_category(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
|
||||
$this->postJson('/api/institution-categories', ['value' => 'gallery'])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.value', 'gallery');
|
||||
|
||||
$this->assertDatabaseHas('institution_categories', ['value' => 'gallery']);
|
||||
}
|
||||
|
||||
public function test_create_rejects_a_duplicate_value(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
InstitutionCategory::factory()->create(['value' => 'gallery']);
|
||||
|
||||
$this->postJson('/api/institution-categories', ['value' => 'gallery'])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors('value');
|
||||
}
|
||||
|
||||
public function test_admin_can_update_a_category(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
|
||||
$this->putJson("/api/institution-categories/{$category->id}", ['value' => 'archive'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.value', 'archive');
|
||||
}
|
||||
|
||||
public function test_cannot_delete_a_category_in_use(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
Institution::factory()->create(['category_id' => $category->id]);
|
||||
|
||||
$this->deleteJson("/api/institution-categories/{$category->id}")
|
||||
->assertStatus(409)
|
||||
->assertJsonPath('in_use', true);
|
||||
|
||||
$this->assertDatabaseHas('institution_categories', ['id' => $category->id]);
|
||||
}
|
||||
|
||||
public function test_admin_can_delete_a_free_category(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
|
||||
$this->deleteJson("/api/institution-categories/{$category->id}")->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('institution_categories', ['id' => $category->id]);
|
||||
}
|
||||
|
||||
public function test_a_non_admin_cannot_write(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
|
||||
$this->postJson('/api/institution-categories', ['value' => 'x'])->assertForbidden();
|
||||
}
|
||||
}
|
||||
40
backend/tests/Feature/InstitutionCategorySeederTest.php
Normal file
40
backend/tests/Feature/InstitutionCategorySeederTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Database\Seeders\InstitutionCategorySeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionCategorySeederTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_seeds_the_categories_with_fixed_legacy_ids(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
|
||||
$this->assertSame(4, InstitutionCategory::count());
|
||||
$this->assertSame('library', InstitutionCategory::find(2)?->value);
|
||||
$this->assertSame('museum', InstitutionCategory::find(3)?->value);
|
||||
$this->assertSame('public administration', InstitutionCategory::find(4)?->value);
|
||||
$this->assertSame('research institute', InstitutionCategory::find(6)?->value);
|
||||
}
|
||||
|
||||
public function test_it_excludes_uncategorized_and_the_gap_ids(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
|
||||
$this->assertNull(InstitutionCategory::find(1));
|
||||
$this->assertNull(InstitutionCategory::find(5));
|
||||
}
|
||||
|
||||
public function test_it_is_idempotent(): void
|
||||
{
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
$this->seed(InstitutionCategorySeeder::class);
|
||||
|
||||
$this->assertSame(4, InstitutionCategory::count());
|
||||
}
|
||||
}
|
||||
251
backend/tests/Feature/InstitutionControllerTest.php
Normal file
251
backend/tests/Feature/InstitutionControllerTest.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const BASE_ROUTE = '/api/institutions';
|
||||
|
||||
private const TEST_MUSEUM = 'Test Museum';
|
||||
|
||||
private const JSON_HEADERS = ['Accept' => 'application/json'];
|
||||
|
||||
private const OLD_LOGO_PATH = 'institution_logo/old.jpg';
|
||||
|
||||
private const GONE_LOGO_PATH = 'institution_logo/gone.jpg';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'category_id' => InstitutionCategory::factory()->create()->id,
|
||||
'name' => self::TEST_MUSEUM,
|
||||
'abbreviation' => 'TM',
|
||||
'address' => 'Some Road 1',
|
||||
'city' => 'Lund',
|
||||
'lat' => 55.7,
|
||||
'lon' => 13.19,
|
||||
'color' => '#ffffff',
|
||||
'is_storage_place' => true,
|
||||
'logo' => UploadedFile::fake()->image('logo.png'),
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
// --- Lettura (tier.app) --------------------------------------------------
|
||||
|
||||
public function test_index_lists_institutions_for_an_operational_user(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
Institution::factory()->count(2)->create();
|
||||
|
||||
$this->getJson(self::BASE_ROUTE)
|
||||
->assertOk()
|
||||
->assertJsonStructure(['message', 'data', 'meta' => ['current_page', 'total']])
|
||||
->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
public function test_index_filters_by_category_and_search(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
Institution::factory()->create(['category_id' => $category->id, 'name' => 'Blekinge Museum']);
|
||||
Institution::factory()->create(['name' => 'Other Place']);
|
||||
|
||||
$this->getJson(self::BASE_ROUTE."?category_id={$category->id}")
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.name', 'Blekinge Museum');
|
||||
|
||||
$this->getJson(self::BASE_ROUTE.'?search=Blekinge')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data');
|
||||
}
|
||||
|
||||
public function test_index_can_list_only_trashed(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$trashed = Institution::factory()->create();
|
||||
Institution::factory()->create();
|
||||
$trashed->delete();
|
||||
|
||||
$this->getJson(self::BASE_ROUTE)->assertJsonCount(1, 'data');
|
||||
$this->getJson(self::BASE_ROUTE.'?trashed=only')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $trashed->id);
|
||||
$this->getJson(self::BASE_ROUTE.'?trashed=with')->assertJsonCount(2, 'data');
|
||||
}
|
||||
|
||||
public function test_show_returns_institution_with_category_and_links(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->getJson(self::BASE_ROUTE."/{$institution->uuid}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.uuid', $institution->uuid)
|
||||
->assertJsonStructure(['data' => ['category', 'links']]);
|
||||
}
|
||||
|
||||
public function test_show_resolves_by_uuid_not_id(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
// L'id interno non è una route key valida.
|
||||
$this->getJson(self::BASE_ROUTE."/{$institution->id}")->assertNotFound();
|
||||
}
|
||||
|
||||
// --- Scrittura (tier.admin) ---------------------------------------------
|
||||
|
||||
public function test_admin_can_create_an_institution_with_a_logo(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
|
||||
$this->post(self::BASE_ROUTE, $this->validPayload(), self::JSON_HEADERS)
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.name', self::TEST_MUSEUM)
|
||||
->assertJsonStructure(['data' => ['uuid', 'category']]);
|
||||
|
||||
$institution = Institution::firstWhere('name', self::TEST_MUSEUM);
|
||||
$this->assertNotNull($institution);
|
||||
$this->assertNotEmpty($institution->uuid);
|
||||
$this->assertTrue(Storage::disk('public')->exists($institution->logo));
|
||||
}
|
||||
|
||||
public function test_create_applies_default_color_and_storage_flag(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
|
||||
$payload = $this->validPayload();
|
||||
unset($payload['color'], $payload['is_storage_place']);
|
||||
|
||||
$this->post(self::BASE_ROUTE, $payload, self::JSON_HEADERS)->assertCreated();
|
||||
|
||||
$this->assertDatabaseHas('institutions', [
|
||||
'name' => self::TEST_MUSEUM,
|
||||
'color' => '#c5cae9',
|
||||
'is_storage_place' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_validates_required_fields(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
|
||||
$this->postJson(self::BASE_ROUTE, [])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['category_id', 'name', 'abbreviation', 'lat', 'lon', 'logo']);
|
||||
}
|
||||
|
||||
public function test_admin_can_update_an_institution_keeping_the_logo(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create(['logo' => 'institution_logo/keep.jpg', 'name' => 'Old']);
|
||||
|
||||
$payload = $this->validPayload(['name' => 'New Name', 'category_id' => $institution->category_id]);
|
||||
unset($payload['logo']);
|
||||
|
||||
$this->put(self::BASE_ROUTE."/{$institution->uuid}", $payload, self::JSON_HEADERS)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.name', 'New Name');
|
||||
|
||||
$this->assertSame('institution_logo/keep.jpg', $institution->fresh()->logo);
|
||||
}
|
||||
|
||||
public function test_update_replaces_the_logo_when_a_new_one_is_uploaded(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
Storage::disk('public')->put(self::OLD_LOGO_PATH, 'x');
|
||||
$institution = Institution::factory()->create(['logo' => self::OLD_LOGO_PATH]);
|
||||
|
||||
$payload = $this->validPayload(['category_id' => $institution->category_id]);
|
||||
|
||||
$this->put(self::BASE_ROUTE."/{$institution->uuid}", $payload, self::JSON_HEADERS)->assertOk();
|
||||
|
||||
$this->assertFalse(Storage::disk('public')->exists(self::OLD_LOGO_PATH));
|
||||
$this->assertTrue(Storage::disk('public')->exists($institution->fresh()->logo));
|
||||
}
|
||||
|
||||
public function test_admin_can_soft_delete_an_institution(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->deleteJson(self::BASE_ROUTE."/{$institution->uuid}")->assertOk();
|
||||
|
||||
$this->assertSoftDeleted($institution);
|
||||
}
|
||||
|
||||
public function test_admin_can_restore_a_trashed_institution(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$institution->delete();
|
||||
|
||||
$this->postJson(self::BASE_ROUTE."/{$institution->uuid}/restore")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.uuid', $institution->uuid);
|
||||
|
||||
$this->assertNotSoftDeleted($institution);
|
||||
}
|
||||
|
||||
public function test_admin_can_force_delete_an_institution_and_its_logo_and_links(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
Storage::disk('public')->put(self::GONE_LOGO_PATH, 'x');
|
||||
$institution = Institution::factory()->create(['logo' => self::GONE_LOGO_PATH]);
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
$institution->delete();
|
||||
|
||||
$this->deleteJson(self::BASE_ROUTE."/{$institution->uuid}/force")->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('institutions', ['id' => $institution->id]);
|
||||
$this->assertDatabaseMissing('institution_links', ['id' => $link->id]);
|
||||
Storage::assertMissing(self::GONE_LOGO_PATH);
|
||||
}
|
||||
|
||||
// --- Autorizzazione ------------------------------------------------------
|
||||
|
||||
public function test_a_non_admin_cannot_write(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->postJson(self::BASE_ROUTE, [])->assertForbidden();
|
||||
$this->deleteJson(self::BASE_ROUTE."/{$institution->uuid}")->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_guests_can_read_institutions(): void
|
||||
{
|
||||
Institution::factory()->create();
|
||||
|
||||
$this->getJson(self::BASE_ROUTE)->assertOk();
|
||||
}
|
||||
|
||||
public function test_guests_cannot_write_institutions(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->postJson(self::BASE_ROUTE, [])->assertUnauthorized();
|
||||
$this->deleteJson(self::BASE_ROUTE."/{$institution->uuid}")->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
157
backend/tests/Feature/InstitutionLinkControllerTest.php
Normal file
157
backend/tests/Feature/InstitutionLinkControllerTest.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionLinkControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $overrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function validPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'url' => 'https://example.org',
|
||||
'type' => InstitutionLinkType::Official->value,
|
||||
'label' => 'Official site',
|
||||
'sort_order' => 1,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
// --- Lettura (tier.app) --------------------------------------------------
|
||||
|
||||
public function test_index_lists_links_of_an_institution_ordered(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$second = InstitutionLink::factory()->create(['institution_id' => $institution->id, 'sort_order' => 2]);
|
||||
$first = InstitutionLink::factory()->create(['institution_id' => $institution->id, 'sort_order' => 1]);
|
||||
|
||||
$this->getJson("/api/institutions/{$institution->uuid}/links")
|
||||
->assertOk()
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.id', $first->id)
|
||||
->assertJsonPath('data.1.id', $second->id);
|
||||
}
|
||||
|
||||
public function test_show_returns_a_single_link(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->getJson("/api/institutions/{$institution->uuid}/links/{$link->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $link->id);
|
||||
}
|
||||
|
||||
public function test_show_returns_not_found_when_the_link_belongs_to_another_institution(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$otherInstitution = Institution::factory()->create();
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $otherInstitution->id]);
|
||||
|
||||
$this->getJson("/api/institutions/{$institution->uuid}/links/{$link->id}")->assertNotFound();
|
||||
}
|
||||
|
||||
// --- Scrittura (tier.admin) ---------------------------------------------
|
||||
|
||||
public function test_admin_can_add_a_link_to_an_institution(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->postJson("/api/institutions/{$institution->uuid}/links", $this->validPayload())
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.url', 'https://example.org')
|
||||
->assertJsonPath('data.institution_id', $institution->id);
|
||||
|
||||
$this->assertDatabaseHas('institution_links', [
|
||||
'institution_id' => $institution->id,
|
||||
'url' => 'https://example.org',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_defaults_sort_order_to_zero_when_missing(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$payload = $this->validPayload();
|
||||
unset($payload['sort_order']);
|
||||
|
||||
$this->postJson("/api/institutions/{$institution->uuid}/links", $payload)
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.sort_order', 0);
|
||||
}
|
||||
|
||||
public function test_store_validates_required_fields(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->postJson("/api/institutions/{$institution->uuid}/links", [])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['url', 'type']);
|
||||
}
|
||||
|
||||
public function test_admin_can_update_a_link(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->putJson(
|
||||
"/api/institutions/{$institution->uuid}/links/{$link->id}",
|
||||
$this->validPayload(['url' => 'https://updated.example.org'])
|
||||
)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.url', 'https://updated.example.org');
|
||||
|
||||
$this->assertSame('https://updated.example.org', $link->fresh()->url);
|
||||
}
|
||||
|
||||
public function test_admin_can_delete_a_link(): void
|
||||
{
|
||||
$this->actingAs($this->adminUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->deleteJson("/api/institutions/{$institution->uuid}/links/{$link->id}")->assertOk();
|
||||
|
||||
$this->assertDatabaseMissing('institution_links', ['id' => $link->id]);
|
||||
}
|
||||
|
||||
// --- Autorizzazione ------------------------------------------------------
|
||||
|
||||
public function test_a_non_admin_cannot_write(): void
|
||||
{
|
||||
$this->actingAs($this->operationalUser(), 'sanctum');
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->postJson("/api/institutions/{$institution->uuid}/links", $this->validPayload())->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_guests_can_read_links(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->getJson("/api/institutions/{$institution->uuid}/links")->assertOk();
|
||||
}
|
||||
|
||||
public function test_guests_cannot_write_links(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->postJson("/api/institutions/{$institution->uuid}/links", $this->validPayload())->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
30
backend/tests/Feature/Models/InstitutionLinkTest.php
Normal file
30
backend/tests/Feature/Models/InstitutionLinkTest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Models;
|
||||
|
||||
use App\Enums\InstitutionLinkType;
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionLinkTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_type_is_cast_to_enum(): void
|
||||
{
|
||||
$link = InstitutionLink::factory()->create(['type' => InstitutionLinkType::Ticketing]);
|
||||
|
||||
$this->assertInstanceOf(InstitutionLinkType::class, $link->fresh()->type);
|
||||
$this->assertSame(InstitutionLinkType::Ticketing, $link->fresh()->type);
|
||||
}
|
||||
|
||||
public function test_it_belongs_to_an_institution(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
$link = InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->assertTrue($link->institution->is($institution));
|
||||
}
|
||||
}
|
||||
99
backend/tests/Feature/Models/InstitutionTest.php
Normal file
99
backend/tests/Feature/Models/InstitutionTest.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Models;
|
||||
|
||||
use App\Models\Institution;
|
||||
use App\Models\InstitutionLink;
|
||||
use App\Models\Lists\InstitutionCategory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InstitutionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_uuid_is_generated_on_create(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$this->assertNotEmpty($institution->uuid);
|
||||
$this->assertSame(36, strlen((string) $institution->uuid));
|
||||
}
|
||||
|
||||
public function test_two_institutions_get_distinct_uuids(): void
|
||||
{
|
||||
$a = Institution::factory()->create();
|
||||
$b = Institution::factory()->create();
|
||||
|
||||
$this->assertNotSame($a->uuid, $b->uuid);
|
||||
}
|
||||
|
||||
public function test_a_provided_uuid_is_not_overwritten(): void
|
||||
{
|
||||
$uuid = (string) Str::uuid();
|
||||
|
||||
$institution = Institution::factory()->make();
|
||||
$institution->uuid = $uuid;
|
||||
$institution->save();
|
||||
|
||||
$this->assertSame($uuid, $institution->fresh()->uuid);
|
||||
}
|
||||
|
||||
public function test_route_key_is_the_uuid(): void
|
||||
{
|
||||
$this->assertSame('uuid', (new Institution)->getRouteKeyName());
|
||||
}
|
||||
|
||||
public function test_attributes_are_cast(): void
|
||||
{
|
||||
$institution = Institution::factory()->create([
|
||||
'is_storage_place' => 1,
|
||||
'lat' => '55.700000',
|
||||
'lon' => '13.190000',
|
||||
'legacy_id' => '42',
|
||||
]);
|
||||
|
||||
$fresh = $institution->fresh();
|
||||
|
||||
$this->assertIsBool($fresh->is_storage_place);
|
||||
$this->assertIsFloat($fresh->lat);
|
||||
$this->assertIsFloat($fresh->lon);
|
||||
$this->assertSame(42, $fresh->legacy_id);
|
||||
}
|
||||
|
||||
public function test_it_belongs_to_a_category(): void
|
||||
{
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
$institution = Institution::factory()->create(['category_id' => $category->id]);
|
||||
|
||||
$this->assertTrue($institution->category->is($category));
|
||||
}
|
||||
|
||||
public function test_a_category_has_many_institutions(): void
|
||||
{
|
||||
$category = InstitutionCategory::factory()->create();
|
||||
Institution::factory()->count(2)->create(['category_id' => $category->id]);
|
||||
|
||||
$this->assertCount(2, $category->institutions);
|
||||
}
|
||||
|
||||
public function test_it_has_many_links(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
InstitutionLink::factory()->create(['institution_id' => $institution->id]);
|
||||
|
||||
$this->assertCount(1, $institution->refresh()->links);
|
||||
}
|
||||
|
||||
public function test_it_is_soft_deleted(): void
|
||||
{
|
||||
$institution = Institution::factory()->create();
|
||||
|
||||
$institution->delete();
|
||||
|
||||
$this->assertSoftDeleted($institution);
|
||||
$this->assertSame(0, Institution::count());
|
||||
$this->assertSame(1, Institution::withTrashed()->count());
|
||||
}
|
||||
}
|
||||
77
backend/tests/Feature/PurgeAnonymizableUsersCommandTest.php
Normal file
77
backend/tests/Feature/PurgeAnonymizableUsersCommandTest.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PurgeAnonymizableUsersCommandTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** Crea un utente cestinato con deleted_at retrodatato di $days giorni. */
|
||||
private function trashedDaysAgo(int $days): User
|
||||
{
|
||||
$user = $this->operationalUser();
|
||||
$user->delete();
|
||||
User::withTrashed()->whereKey($user->id)->update(['deleted_at' => now()->subDays($days)]);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
public function test_it_anonymizes_users_past_the_retention_window(): void
|
||||
{
|
||||
$this->systemUser();
|
||||
$old = $this->trashedDaysAgo(40);
|
||||
|
||||
$this->artisan('users:purge', ['--days' => 30])->assertSuccessful();
|
||||
|
||||
$old->refresh();
|
||||
$this->assertNotNull($old->anonymized_at);
|
||||
$this->assertSame('Deleted user', $old->name);
|
||||
}
|
||||
|
||||
public function test_it_skips_users_still_within_retention(): void
|
||||
{
|
||||
$this->systemUser();
|
||||
$recent = $this->trashedDaysAgo(5);
|
||||
|
||||
$this->artisan('users:purge', ['--days' => 30])->assertSuccessful();
|
||||
|
||||
$recent->refresh();
|
||||
$this->assertNull($recent->anonymized_at);
|
||||
}
|
||||
|
||||
public function test_dry_run_does_not_modify_anyone(): void
|
||||
{
|
||||
$this->systemUser();
|
||||
$old = $this->trashedDaysAgo(40);
|
||||
|
||||
$this->artisan('users:purge', ['--days' => 30, '--dry-run' => true])->assertSuccessful();
|
||||
|
||||
$old->refresh();
|
||||
$this->assertNull($old->anonymized_at);
|
||||
}
|
||||
|
||||
public function test_it_never_touches_the_system_user(): void
|
||||
{
|
||||
$system = $this->systemUser();
|
||||
// Anche se (per assurdo) cestinato e vecchio, l'utente di sistema è escluso.
|
||||
$system->delete();
|
||||
User::withTrashed()->whereKey($system->id)->update(['deleted_at' => now()->subDays(99)]);
|
||||
|
||||
$this->artisan('users:purge', ['--days' => 30])->assertSuccessful();
|
||||
|
||||
$this->assertNull($system->fresh()->anonymized_at);
|
||||
}
|
||||
|
||||
public function test_it_reports_when_there_is_nothing_to_do(): void
|
||||
{
|
||||
$this->systemUser();
|
||||
|
||||
$this->artisan('users:purge', ['--days' => 30])
|
||||
->expectsOutputToContain('No users to anonymize.')
|
||||
->assertSuccessful();
|
||||
}
|
||||
}
|
||||
55
backend/tests/Feature/PurgeUserActionTest.php
Normal file
55
backend/tests/Feature/PurgeUserActionTest.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Actions\PurgeUserAction;
|
||||
use App\Exceptions\CannotDeleteSystemUserException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PurgeUserActionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function purge(): PurgeUserAction
|
||||
{
|
||||
return app(PurgeUserAction::class);
|
||||
}
|
||||
|
||||
public function test_it_anonymizes_personal_data_in_place(): void
|
||||
{
|
||||
$this->systemUser();
|
||||
$user = $this->operationalUser(['name' => 'Mario Rossi']);
|
||||
|
||||
$this->purge()->execute($user);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame('Deleted user', $user->name);
|
||||
$this->assertSame("deleted-{$user->id}@anonymized.invalid", $user->email);
|
||||
$this->assertNotNull($user->anonymized_at);
|
||||
$this->assertNull($user->email_verified_at);
|
||||
$this->assertNull($user->two_factor_setup_completed_at);
|
||||
$this->assertFalse($user->must_change_password);
|
||||
}
|
||||
|
||||
public function test_it_is_idempotent_on_already_anonymized_users(): void
|
||||
{
|
||||
$this->systemUser();
|
||||
$user = $this->operationalUser(['name' => 'Keep Me', 'anonymized_at' => now()]);
|
||||
|
||||
$this->purge()->execute($user);
|
||||
|
||||
$user->refresh();
|
||||
// Già anonimizzato → uscita anticipata: nessuna riscrittura del nome.
|
||||
$this->assertSame('Keep Me', $user->name);
|
||||
}
|
||||
|
||||
public function test_it_refuses_to_anonymize_the_system_user(): void
|
||||
{
|
||||
$system = $this->systemUser();
|
||||
|
||||
$this->expectException(CannotDeleteSystemUserException::class);
|
||||
|
||||
$this->purge()->execute($system);
|
||||
}
|
||||
}
|
||||
73
backend/tests/Feature/SeederTest.php
Normal file
73
backend/tests/Feature/SeederTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Lists\UserRole;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Database\Seeders\SystemUserSeeder;
|
||||
use Database\Seeders\UserRoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SeederTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_role_seeder_creates_the_base_roles(): void
|
||||
{
|
||||
$this->seed(UserRoleSeeder::class);
|
||||
|
||||
$this->assertSame(4, UserRole::count());
|
||||
foreach ([UserRole::ADMIN, UserRole::SUPERVISOR, UserRole::USER, UserRole::GUEST] as $name) {
|
||||
$this->assertDatabaseHas('user_roles', ['name' => $name]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_role_descriptions_are_in_english(): void
|
||||
{
|
||||
$this->seed(UserRoleSeeder::class);
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'Full access',
|
||||
(string) UserRole::where('name', UserRole::ADMIN)->value('description')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_user_role_seeder_is_idempotent(): void
|
||||
{
|
||||
$this->seed(UserRoleSeeder::class);
|
||||
$this->seed(UserRoleSeeder::class);
|
||||
|
||||
$this->assertSame(4, UserRole::count());
|
||||
}
|
||||
|
||||
public function test_system_user_seeder_creates_a_locked_admin_account(): void
|
||||
{
|
||||
$this->seed(UserRoleSeeder::class);
|
||||
$this->seed(SystemUserSeeder::class);
|
||||
|
||||
$system = User::where('email', SystemUserSeeder::EMAIL)->firstOrFail();
|
||||
$this->assertTrue($system->is_system);
|
||||
$this->assertFalse($system->must_change_password);
|
||||
$this->assertSame(UserRole::ADMIN, $system->role->name);
|
||||
$this->assertSame('complete', $system->setup_status);
|
||||
}
|
||||
|
||||
public function test_system_user_seeder_is_idempotent(): void
|
||||
{
|
||||
$this->seed(UserRoleSeeder::class);
|
||||
$this->seed(SystemUserSeeder::class);
|
||||
$this->seed(SystemUserSeeder::class);
|
||||
|
||||
$this->assertSame(1, User::where('email', SystemUserSeeder::EMAIL)->count());
|
||||
}
|
||||
|
||||
public function test_database_seeder_runs_the_full_chain(): void
|
||||
{
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
$this->assertSame(4, UserRole::count());
|
||||
$this->assertDatabaseHas('users', ['email' => SystemUserSeeder::EMAIL, 'is_system' => true]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user