diff --git a/.docker/entry-point.sh b/.docker/entry-point.sh index 723edf7ef..d7beba4d3 100755 --- a/.docker/entry-point.sh +++ b/.docker/entry-point.sh @@ -52,7 +52,7 @@ function handleStartup() { fi done fi - yarn production + npm run production } checkDatabase diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..a186cd207 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[compose.yaml] +indent_size = 4 diff --git a/.env.example b/.env.example index 631200130..ea7d75c3e 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,8 @@ QUEUE_CONNECTION=database # Set this to true for production envs SESSION_SECURE_COOKIE=false +# PHP_CLI_SERVER_WORKERS=4 + MAIL_MAILER=log MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 @@ -54,5 +56,6 @@ MAIL_TO_ADMIN_NAME='Admin Name' MAIL_TO_DEVELOPER_TEAM=arc@neontribe.co.uk MAIL_TO_DEVELOPER_NAME='User Support' +CIPHERSWEET_KEY="" PASSWORD_CLIENT=1 PASSWORD_CLIENT_SECRET=secret diff --git a/.gitignore b/.gitignore index 73b853378..1c12eabf5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,41 +1,67 @@ +# Cache files +*.swp +.DS_STORE +.php-cs-fixer.cache +.php_cs.cache .phpstorm.meta.php .phpunit.result.cache -.php_cs.cache +/.phpunit.cache +/_ide_helper_models.php +Thumbs.db _ide_helper.php + +# Logs +*.log + +# IDEs +/.fleet +/.idea +/.nova +/.vscode +/.zed + +# Dependencies /node_modules +/vendor + +# Build +/public/build +/public/css +/public/fonts /public/hot +/public/images/ +/public/js +/public/store/css/datepicker.css +build +npm-debug.log + +# Testing & Dusk +arc_test_file_* +coverage +tests/Browser/console/ +tests/Browser/screenshots/ + +# Storage +app/local /public/storage /storage -/vendor -/.idea + +# Homestead /.vagrant Homestead.json Homestead.yaml -npm-debug.log + +# Configs .env -.env* -.prettierignore -.vscode -.DS_STORE +.env.backup .env.dusk.local -tests/Browser/console/ -tests/Browser/screenshots/ +.env.local +.env.production +.phpactor.json +.prettierignore +/auth.json +passport.install -# webpack results -/public/css -/public/fonts -/public/js -/resources/fonts/* -/_ide_helper_models.php -/yarn-error.log -/public/store/css/datepicker.css -/public/images/ +# Docker .docker-installed -passport.install -coverage -.php-cs-fixer.cache -*.swp -app/local -.env.local -arc_test_file_* -build + diff --git a/.nvmrc b/.nvmrc index 2dbbe00e6..deed13c01 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.11.1 +lts/jod diff --git a/app/Bundle.php b/app/Bundle.php index 2ce19b1d2..5da794ebd 100644 --- a/app/Bundle.php +++ b/app/Bundle.php @@ -2,15 +2,16 @@ namespace App; -use Auth; -use DB; -use Exception; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -use Illuminate\Database\Eloquent\Model; -use Log; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use RuntimeException; use Throwable; /** @@ -19,15 +20,10 @@ * @property Carer $collectingCarer * @property Centre $disbursingCentre * @property User $disbursingUser - * @property Carbon $disbursed_at + * @property Carbon|null $disbursed_at */ class Bundle extends Model { - /** - * The attributes that are mass assignable. - * - * @var array - */ protected $fillable = [ 'entitlement', 'registration_id', @@ -37,224 +33,131 @@ class Bundle extends Model 'disbursing_user_id', ]; - protected $rules = [ - ]; - - /** - * The attributes that should be cast to native types. - * - * @var array - */ protected $casts = [ 'created_at' => 'datetime', - 'updated_at' => 'datetime', - 'disbursed_at' => 'datetime', // When it was handed out. + 'updated_at' => 'datetime', + 'disbursed_at' => 'datetime', ]; /** - * The attributes that should be hidden for arrays. - * - * @var array + * Add vouchers to this bundle by code, skipping any already attached. */ - protected $hidden = [ - ]; + public function addVouchers(array $voucherCodes): array + { + $currentCodes = $this->vouchers->pluck('code')->all(); - /** - * The attributes to append to the model's array form. - * - * @var array - */ - protected $appends = [ - ]; + // Only attempt codes not already on this bundle. + $newCodes = array_values(array_diff($voucherCodes, $currentCodes)); + $vouchers = Voucher::whereIn('code', $newCodes)->get(); + + return $this->alterVouchers($vouchers, $newCodes, $this); + } /** - * Adds voucher codes to a bundle - * @param $voucherCodes - * @return array + * Validate and reassign a collection of vouchers to the given bundle (or null to unbundle). */ - public function addVouchers($voucherCodes) + public function alterVouchers(Collection $vouchers, array $codes = [], ?self $bundle = null): array { - $self = $this; $errors = []; - // Get current Codes for vouchers on the bundle (if any) - $currentCodes = $this->vouchers - ->pluck('code') - ->toArray(); - - // Calculate vouchers to add, so we don't try to add already bundled vouchers. - $addBundleCodes = array_diff($voucherCodes, $currentCodes); - - // Find vouchers models that match codes to add. - $addVouchers = Voucher::whereIn('code', $addBundleCodes)->get(); - - // Add the voucher models to a specific bundle (this one) - $addErrors = $this->alterVouchers($addVouchers, $addBundleCodes, $self); - - // if it threw any errors, merge those with the array. - if (!empty($addErrors)) { - $errors = array_merge_recursive($addErrors, $errors); + // Detect codes that have no corresponding DB row. + $missingCodes = array_values(array_diff($codes, $vouchers->pluck('code')->all())); + if ($missingCodes !== []) { + $errors['codes'] = $missingCodes; } - // an empty errors array means all good. - return $errors; - } + foreach ($vouchers as $voucher) { + match (true) { + // Already disbursed — cannot be reassigned. + $voucher->bundle?->disbursed_at !== null => $errors['disbursed'][] = $voucher->code, + // Belongs to a *different* bundle — must be manually removed first. + $voucher->bundle !== null && $bundle !== null => $errors['bundled'][] = $voucher, - /** - * Refactored function that works out if we broke anything then adds vouchers. - * - * @param Collection $vouchers - * @param array $codes - * @param Bundle|null $bundle - * @return array - */ - public function alterVouchers(Collection $vouchers, array $codes = [], Bundle $bundle = null) - { - $errors = []; - - // codes may reference vouchers we can't find in the database - // TODO: move this check further out? + // State machine forbids collecting (expired, void, recorded, payment_pending, paid). + !$voucher->transitionAllowed('collect') => $errors['used'][] = $voucher->code, - $missingCodes = array_diff($codes, $vouchers->pluck("code")->toArray()); - if ($missingCodes) { - $errors["codes"] = $missingCodes; + // All clear — reassign. + default => $voucher->bundle()->associate($bundle)->save(), + }; } - // Try to run the vouchers we know are in the DB - $vouchers->each( - // Passing a pointer to $errors using "&", so we can change it, inside the loop. - // Otherwise the variable is immutable. - function (Voucher $voucher) use ($bundle, &$errors) { - // Ensure this voucher's bundle can be reassigned. - if ($voucher->bundle && $voucher->bundle->disbursed_at !== null) { - // This voucher has already been given out. - $errors["disbursed"][] = $voucher->code; - } else if ($voucher->bundle && $bundle !== null) { - // Vouchers should not jump from another bundle without being manually removed first. - $errors["bundled"][] = $voucher; - } - else if (!$voucher->transitionAllowed('collect')){ - // Vouchers cannot be bundled if they are expired, void, recorded, payment_pending or paid - $errors["used"][] = $voucher->code; - } - else { - // Change its bundle - $voucher->bundle()->associate($bundle)->save(); - } - } - ); - return $errors; } /** - * Syncs an array of voucher codes with vouchers(); - * - * @param array $voucherCodes array of cleaned Voucher codes - * @return array $errors Errors + * Sync this bundle's vouchers to exactly the supplied set of codes. + * Runs inside a transaction; rolls back and returns a 'transaction' error key on failure. */ - public function syncVouchers(array $voucherCodes) + public function syncVouchers(array $voucherCodes): array { $errors = []; - // If we get an unhandled exception, we should halt and rollback. try { - DB::transaction(function () use ($voucherCodes, $errors) { - - $currentCodes = $this->vouchers - ->pluck('code') - ->toArray(); - - // Calculate vouchers to remove. - $unBundleCodes = array_diff($currentCodes, $voucherCodes); - - // Find the vouchers to remove. - $removeVouchers = $this->vouchers()->whereIn('code', $unBundleCodes)->get(); - - // Sync them to a null bundle - $removeErrors = $this->alterVouchers($removeVouchers, $unBundleCodes, null); - - if (!empty($removeErrors)) { - $errors = array_merge_recursive($removeErrors, $errors); + DB::transaction(function () use ($voucherCodes, &$errors): void { + $currentCodes = $this->vouchers->pluck('code')->all(); + + // Codes to remove from the bundle. + $removeCodes = array_values(array_diff($currentCodes, $voucherCodes)); + $removeVouchers = $this->vouchers()->whereIn('code', $removeCodes)->get(); + + $errors = array_merge_recursive( + $this->alterVouchers($removeVouchers, $removeCodes, null), + $errors, + ); + + // Codes to add. + $errors = array_merge_recursive( + $this->addVouchers($voucherCodes), + $errors, + ); + + if ($errors !== []) { + throw new RuntimeException('Errors during voucher sync transaction.'); } - - // use addVouchers to Add any vouchers. - $errors = array_merge_recursive($this->addVouchers($voucherCodes), $errors); - - // Whoops! errors happened. - if (!empty($errors)) { - throw new Exception("Errors during transaction"); - }; }); } catch (Throwable $e) { - // Log it - Log::error('Bad transaction for ' . __CLASS__ . '@' . __METHOD__ . ' by service user ' . Auth::id()); + Log::error(sprintf( + 'Bad transaction for %s@%s by service user %s', + self::class, + __FUNCTION__, + Auth::id() ?? 'unauthenticated', + )); Log::error($e->getTraceAsString()); - // Add an error notification for the caller to deal with - $errors["transaction"] = true; + + $errors['transaction'] = true; } + return $errors; } - /** - * Get the Registration this bundle is for - * - * @return BelongsTo - */ - public function registration() + public function registration(): BelongsTo { return $this->belongsTo(Registration::class); } - /** - * The vouchers in this Bundle - * - * @return HasMany - */ - public function vouchers() + public function vouchers(): HasMany { return $this->hasMany(Voucher::class); } - /** - * Return the Carer it was disbursed to - * - * @return BelongsTo - */ - public function collectingCarer() + + public function collectingCarer(): BelongsTo { return $this->belongsTo(Carer::class); } - /** - * Return the Centre it was disbursed to - * - * @return BelongsTo - */ - public function disbursingCentre() + public function disbursingCentre(): BelongsTo { return $this->belongsTo(Centre::class); } - /** - * Return the CentreUser it was disbursed by - * - * @return BelongsTo - */ - public function disbursingUser() + public function disbursingUser(): BelongsTo { return $this->belongsTo(CentreUser::class); } - /** - * Scope to pull only disbursed bundles - * - * @param Builder $query - * @return Builder - */ - public function scopeDisbursed($query) + public function scopeDisbursed(Builder $query): Builder { - return $query->where('disbursed_at', '!=', null); + return $query->whereNotNull('disbursed_at'); } } - diff --git a/app/Carer.php b/app/Carer.php index d34c99a0c..4d5e01ed8 100644 --- a/app/Carer.php +++ b/app/Carer.php @@ -2,18 +2,21 @@ namespace App; +use App\Support\LazySecureModel; use App\Traits\Aliasable; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Spatie\LaravelCipherSweet\Contracts\CipherSweetEncrypted; +use ParagonIE\CipherSweet\EncryptedRow; +use ParagonIE\CipherSweet\BlindIndex; + /** - * @mixin Eloquent * @property string $name * @property string $ethnicity * @property string $language * @property Family $family */ -class Carer extends Model +class Carer extends LazySecureModel implements CipherSweetEncrypted { use Aliasable; use SoftDeletes; @@ -30,23 +33,28 @@ class Carer extends Model */ protected $fillable = [ 'name', - 'ethnicity', - 'language', + 'ethnicity', + 'language', + 'emailsecret', + 'telnosecret', + 'family_id', ]; - /** - * The attributes that should be hidden for arrays. - * - * @var array - */ - protected $hidden = []; + public static function configureCipherSweet(EncryptedRow $encryptedRow): void + { + $encryptedRow + ->addOptionalTextField('emailsecret') + ->addBlindIndex('emailsecret', new BlindIndex('emailsecret_index')) + ->addOptionalTextField('telnosecret') + ->addBlindIndex('telnosecret', new BlindIndex('telnosecret_index')); + } /** * Get the Family this Carer picks up for. * * @return BelongsTo */ - public function family() : BelongsTo + public function family(): BelongsTo { return $this->belongsTo(Family::class); } @@ -56,8 +64,8 @@ public function family() : BelongsTo */ public function delete() { - $this->name = 'Deleted'; - $this->save(); - return parent::delete(); + $this->name = 'Deleted'; + $this->save(); + return parent::delete(); } } diff --git a/app/Centre.php b/app/Centre.php index 89816dadb..9afaa9023 100644 --- a/app/Centre.php +++ b/app/Centre.php @@ -2,21 +2,33 @@ namespace App; +use App\Observers\CentreObserver; use Eloquent; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; - +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\belongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Support\Facades\DB; +use RuntimeException; +use Throwable; /** * @mixin Eloquent * @property string $name * @property string $prefix * @property string $print_pref + * @property boolean $can_collect * @property Sponsor $sponsor * @property Registration[] $registrations * @property CentreUser[] $centreUsers * @property Centre[] $neighbours * @property Family[] $families */ + +#[ObservedBy(CentreObserver::class)] class Centre extends Model { /** @@ -25,7 +37,11 @@ class Centre extends Model * @var array */ protected $fillable = [ - 'name', 'prefix', 'print_pref', 'sponsor_id' + 'name', + 'prefix', + 'print_pref', + 'sponsor_id', + 'can_collect', ]; /** @@ -36,7 +52,16 @@ class Centre extends Model protected $hidden = [ ]; - public function nextCentreSequence() + /** + * Casts + * + * @var array + */ + protected $casts = [ + 'can_collect' => 'boolean', + ]; + + public function nextCentreSequence(): int { // Get the last family $last_family = $this->families()->orderByDesc('centre_sequence')->first(); @@ -46,56 +71,98 @@ public function nextCentreSequence() // Override it if the family has a sequence. if ($last_family && $last_family->centre_sequence) { - $sequence = $last_family->centre_sequence +1; + $sequence = $last_family->centre_sequence + 1; } return $sequence; } + /** + * All internal markets for this centre. + * Use the open() scope to restrict to those with at least one trader. + */ + public function markets(): HasMany + { + return $this->hasMany(Market::class); + } + /** * Get the Registrations for this Centre - * - * @return \Illuminate\Database\Eloquent\Relations\HasMany */ - public function registrations() + public function registrations(): HasMany { - return $this->hasMany('App\Registration'); + return $this->hasMany(Registration::class); } /** * Get the CentreUsers who belong to this Centre - * - * @return \Illuminate\Database\Eloquent\Relations\belongsToMany */ - public function centreUsers() + public function centreUsers(): BelongsToMany { - return $this->belongsToMany('App\CentreUser'); + return $this->belongsToMany(CentreUser::class); } /** * Get the Sponsor for this Centre - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ - public function sponsor() + public function sponsor(): BelongsTo { - return $this->belongsTo('App\Sponsor'); + return $this->belongsTo(Sponsor::class); } /** * Gets all the siblings under the same parent (including this one). * Self join; possible a better way to do this. - * - * @return \Illuminate\Database\Eloquent\Relations\HasMany */ - public function neighbours() + public function neighbours(): HasMany { - return $this->hasMany('App\Centre', 'sponsor_id', 'sponsor_id'); + return $this->hasMany(related: __CLASS__, foreignKey: 'sponsor_id', localKey: 'sponsor_id'); } - public function families() + public function families(): HasMany { - return $this->hasMany('App\Family', 'initial_centre_id'); + return $this->hasMany(Family::class, 'initial_centre_id'); } + /** + * Relationship for addressing vouchers that are assigned to this centre via deliveries. + */ + public function availableVouchers(): HasManyThrough + { + return $this->hasManyThrough(Voucher::class, Delivery::class) + ->where('vouchers.currentstate', 'dispatched') + ->whereNull('vouchers.bundle_id'); + } + + /** + * How many vouchers do we have access to? + */ + public function getPoolSize(): int + { + return $this->availableVouchers()->count(); + } + + /** + * @throws Throwable + */ + public function claimFromPool(int $quantity): Collection + { + return DB::transaction(function () use ($quantity) { + $vouchers = $this->availableVouchers() + ->orderBy('deliveries.dispatched_at') + ->orderBy('vouchers.id') + ->select('vouchers.*') + ->limit($quantity) + ->lockForUpdate() + ->get(); + + if ($vouchers->count() < $quantity) { + throw new RuntimeException( + "Pool has {$vouchers->count()} vouchers available, {$quantity} requested." + ); + } + + return $vouchers; + }); + } } diff --git a/app/CentreUser.php b/app/CentreUser.php index 7db1786cd..1759b1f19 100644 --- a/app/CentreUser.php +++ b/app/CentreUser.php @@ -2,15 +2,18 @@ namespace App; +use App\Notifications\StorePasswordResetNotification; +use App\Traits\Retirable; use Eloquent; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\belongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Notifications\Notifiable; use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; -use App\Notifications\StorePasswordResetNotification; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; /** * @mixin Eloquent @@ -27,6 +30,7 @@ class CentreUser extends Authenticatable { use Notifiable; use SoftDeletes; + use Retirable; protected string $guard = 'store'; @@ -36,7 +40,11 @@ class CentreUser extends Authenticatable * @var array */ protected $fillable = [ - 'name', 'email', 'password', 'role', 'downloader', + 'name', + 'email', + 'password', + 'role', + 'downloader', ]; /** @@ -45,7 +53,7 @@ class CentreUser extends Authenticatable * @var array */ protected $appends = [ - 'homeCentre' + 'homeCentre', ]; /** @@ -54,7 +62,8 @@ class CentreUser extends Authenticatable * @var array */ protected $hidden = [ - 'password', 'remember_token', + 'password', + 'remember_token', ]; /** @@ -67,6 +76,15 @@ class CentreUser extends Authenticatable 'deleted_at' => 'datetime', ]; + protected function retirableFields(): array + { + return [ + 'name' => '[User Retired]', + 'email' => 'retired_' . Str::uuid() . '@retired.invalid', + 'password' => Hash::make(Str::uuid()), + 'remember_token' => null, + ]; + } /** * Get the Notes that belong to this CentreUser */ @@ -152,7 +170,7 @@ public function isRelevantCentre(Centre $centre): bool /** * Send the password reset notification. * - * @param string $token + * @param string $token */ public function sendPasswordResetNotification($token): void { diff --git a/app/Console/Commands/ArchiveVouchers.php b/app/Console/Commands/ArchiveVouchers.php index 52c65d41a..476bfe401 100644 --- a/app/Console/Commands/ArchiveVouchers.php +++ b/app/Console/Commands/ArchiveVouchers.php @@ -13,7 +13,7 @@ class ArchiveVouchers extends Command { protected array $tableData = []; - protected string $date = "2023-09-01"; + protected string $date = "2024-09-01"; protected $signature = 'arc:archiveVouchers'; protected $description = 'archive vouchers and their histories'; @@ -120,8 +120,10 @@ private function populateTemporaryTable(): void private function getSelectionCriteria(): Builder { // Define and return the query for selecting vouchers to archive - return DB::table('vouchers')->whereIn('currentstate', - ['reimbursed', 'retired', 'voided', 'expired'])->where('updated_at', '<', $this->date); + return DB::table('vouchers')->whereIn( + 'currentstate', + ['reimbursed', 'retired', 'voided', 'expired'] + )->where('updated_at', '<', $this->date); } private function dropIndexes($tableName): void @@ -149,8 +151,10 @@ private function makeIndexes($tableName): void $keysFound = Arr::pluck(Schema::getForeignKeys($tableName), 'name'); foreach ($foreignKeys as $fk) { if (!array_key_exists($fk['name'], $keysFound)) { - $table->foreign($fk['columns'], - $fk['name'])->references($fk['foreign_columns'])->on($fk['foreign_table']); + $table->foreign( + $fk['columns'], + $fk['name'] + )->references($fk['foreign_columns'])->on($fk['foreign_table']); } } diff --git a/app/Console/Commands/MoveFamilyRegistrationCentre.php b/app/Console/Commands/MoveFamilyRegistrationCentre.php new file mode 100644 index 000000000..6e05dccd5 --- /dev/null +++ b/app/Console/Commands/MoveFamilyRegistrationCentre.php @@ -0,0 +1,260 @@ +argument('family_id'); + $centreId = $this->argument('centre_id'); + $csvPath = $this->option('csv'); + $dryRun = (bool)$this->option('dry-run'); + $force = (bool)$this->option('force'); + + if ( + ($csvPath && ($centreId || $familyId)) || + (!$csvPath && !($centreId && $familyId)) + ) { + $this->error('Provide either a family_id and centre_id OR --csv=path'); + return self::FAILURE; + } + + $workList = collect(); + + if ($familyId && $centreId) { + $workList->push(["familyId" => (int)$familyId, "centreId" => (int)$centreId]); + } + + if ($csvPath) { + if (!Storage::exists($csvPath)) { + $this->error("CSV file not found: $csvPath"); + return self::FAILURE; + } + + $workList = $workList->merge( + $this->extractFromCsv($csvPath) + ); + } + + // can filter work on arrays? + $workList = $workList->filter()->unique( + function ($item) { + return $item['familyId'] . '-' . $item['centreId']; + } + )->values(); + + $this->info("Processing {$workList->count()} families"); + + $failed = []; + + foreach ($workList as $item) { + ['familyId' => $familyId, 'centreId' => $centreId] = $item; + $this->line(''); + $this->line("==== FAMILY $familyId ===="); + + $result = $this->moveSingleFamily($familyId, $centreId, $dryRun, $force); + + if ($result !== self::SUCCESS) { + $failed[] = $familyId; + } + } + + $this->line(''); + $this->info('Batch complete.'); + + if (!empty($failed)) { + $this->warn('Failed family IDs:'); + $this->line(implode(', ', $failed)); + return self::FAILURE; + } + + $path = "movesDone.csv"; + if (count($this->summary) > 0) { + $this->writeCsv($path, $this->summary); + $this->line("Wrote changes to $path"); + } + + return self::SUCCESS; + } + + private function extractFromCsv(string $path): Collection + { + $pairs = collect(); + + $handle = Storage::readStream($path); + + if ($handle === false) { + throw new RuntimeException("Cannot open CSV: $path"); + } + + $header = fgetcsv($handle); + + if (!$header) { + fclose($handle); + throw new RuntimeException('CSV has no rows.'); + } + + $rvidIndex = array_search('RVID', $header, true); + $centreIndex = array_search('Centre', $header, true); + + if ($rvidIndex === false) { + fclose($handle); + throw new RuntimeException('CSV missing RVID header column.'); + } + + if ($centreIndex === false) { + fclose($handle); + throw new RuntimeException('CSV missing Centre header column.'); + } + + $centreMap = Centre::query()->pluck('id', 'name'); + + while (($row = fgetcsv($handle)) !== false) { + $rvid = trim((string)($row[$rvidIndex] ?? '')); + $centreName = trim((string)($row[$centreIndex] ?? '')); + + $family = Family::findByRvid($rvid); + + if (!$family) { + $this->line("Invalid RVID: $rvid"); + continue; + } + + $centreId = $centreMap[$centreName] ?? null; + + if (!$centreId) { + $this->line("Invalid Centre: $centreName"); + continue; + } + + $pairs->push([ + 'familyId' => $family->id, + 'centreId' => $centreId, + ]); + } + + fclose($handle); + + return $pairs; + } + + private function moveSingleFamily(int $familyId, int $centreId, bool $dryRun, bool $force): int + { + $centre = Centre::find($centreId); + if (!$centre) { + $this->error("Centre $centreId not found."); + return self::FAILURE; + } + + $family = Family::withPrimaryCarer()->whereKey($familyId)->lockForUpdate()->first(); + if (!$family) { + $this->error("Family $familyId not found."); + return self::FAILURE; + } + + try { + return DB::transaction(callback: function () use ($family, $centre, $dryRun, $force) { + + $oldRVID = $family->rvid; + $oldName = $family->pri_carer; + + $registrations = Registration::where('family_id', $family->id)->with('centre')->get(); + $centreNames = $registrations->pluck('centre.name')->all(); + $centreNames = implode(", ", array_unique(array_sort($centreNames))); + + $this->info("Family: $family->id ($family->rvid)"); + $this->info("Primary Carer: $family->pri_carer"); + $this->line("Registrations: {$registrations->count()}"); + $this->line("In Centres: $centreNames"); + $this->line("Move to Centre: $centre->id ($centre->name)"); + + if ($dryRun) { + $this->warn('Dry run complete — nothing deleted.'); + return self::SUCCESS; + } + + if ( + !$force && !$this->confirm( + "This will permanently move family $family->id and related data to $centre->name. Continue?" + ) + ) { + $this->warn('Skipped'); + return self::FAILURE; + } + + if ($registrations->isNotEmpty()) { + $this->moveRegistrations($registrations, $centre); + $family->lockToCentre($centre, true); + $family->save(); + } + + // refresh model. + $family->refresh(); + $newRVID = $family->rvid; + $this->summary[] = [$oldRVID, $oldName, $newRVID]; + + $this->info('Family Registration permanently moved.'); + return self::SUCCESS; + }, attempts: 3); + } catch (Throwable $e) { + report($e); + $this->error($e->getMessage()); + return self::FAILURE; + } + } + + private function moveRegistrations(Collection $registrations, $centre): void + { + $expected = $registrations->count(); + $actioned = Registration::query() + ->whereIn('id', $registrations->modelKeys()) + ->update([ + 'centre_id' => $centre->id, + ]); + + if ($actioned !== $expected) { + throw new RuntimeException( + "Registration move mismatch. Expected $expected, moved $actioned." + ); + } + } + + private function writeCsv(string $path, iterable $rows): void + { + $stream = fopen('php://temp', 'w+'); + + if ($stream === false) { + throw new RuntimeException('Cannot open temp stream.'); + } + + foreach ($rows as $row) { + fputcsv($stream, $row); + } + + rewind($stream); + + Storage::put($path, stream_get_contents($stream)); + + fclose($stream); + } +} diff --git a/app/Console/Commands/PurgeFamilyGraph.php b/app/Console/Commands/PurgeFamilyGraph.php new file mode 100644 index 000000000..669be1e92 --- /dev/null +++ b/app/Console/Commands/PurgeFamilyGraph.php @@ -0,0 +1,264 @@ +argument('family_id'); + $csvPath = $this->option('csv'); + $dryRun = (bool)$this->option('dry-run'); + $force = (bool)$this->option('force'); + + if (!$familyId && !$csvPath) { + $this->error('Provide either a family_id OR --csv=path'); + return self::FAILURE; + } + + $familyIds = collect(); + + if ($familyId) { + $familyIds->push((int)$familyId); + } + + if ($csvPath) { + if (!Storage::exists($csvPath)) { + $this->error("CSV file not found: $csvPath"); + return self::FAILURE; + } + + $familyIds = $familyIds->merge( + $this->extractFromCsv($csvPath) + ); + } + + $familyIds = $familyIds->filter()->unique()->values(); + + $this->info("Processing {$familyIds->count()} families"); + + $failed = []; + + foreach ($familyIds as $id) { + $this->line(''); + $this->line("==== FAMILY $id ===="); + + $result = $this->purgeSingleFamily($id, $dryRun, $force); + + if ($result !== self::SUCCESS) { + $failed[] = $id; + } + } + + $this->line(''); + $this->info('Batch complete.'); + + if (!empty($failed)) { + $this->warn('Failed family IDs:'); + $this->line(implode(', ', $failed)); + return self::FAILURE; + } + + return self::SUCCESS; + } + + private function extractFromCsv(string $path): Collection + { + $ids = collect(); + + $handle = Storage::readStream($path); + + if ($handle === false) { + throw new RuntimeException("Cannot open CSV: $path"); + } + + $header = fgetcsv($handle); + + if (!$header) { + fclose($handle); + throw new RuntimeException('CSV has no rows.'); + } + + $rvid = array_search('RVID', $header, true); + + if ($rvid === false) { + fclose($handle); + throw new RuntimeException('CSV missing RVID header column.'); + } + + while (($row = fgetcsv($handle)) !== false) { + $family = Family::findByRvid($row[$rvid]); + if ($family !== null) { + $ids->push($family->id); + } else { + $this->line("Invalid rvid: $row[$rvid]"); + } + } + + fclose($handle); + return $ids; + } + + private function purgeSingleFamily(int $familyId, bool $dryRun, bool $force): int + { + try { + return DB::transaction(callback: function() use ($familyId, $dryRun, $force) { + + $family = Family::withPrimaryCarer()->whereKey($familyId)->lockForUpdate()->first(); + + if (!$family) { + $this->error("Family $familyId not found."); + return self::FAILURE; + } + + $registrationIds = Registration::where('family_id', $familyId)->pluck('id'); + + $bundleIds = $registrationIds->isEmpty() + ? collect() + : Bundle::whereIn('registration_id', $registrationIds)->pluck('id'); + + $voucherCount = $bundleIds->isEmpty() + ? 0 + : Voucher::whereIn('bundle_id', $bundleIds)->count(); + + $childrenCount = Child::where('family_id', $familyId)->count(); + $carersCount = Carer::where('family_id', $familyId)->withTrashed()->count(); + + $this->info("Family: $familyId"); + $this->info("Primary Carer: $family->pri_carer"); + $this->line("Registrations: {$registrationIds->count()}"); + $this->line("Bundles: {$bundleIds->count()}"); + $this->line("Vouchers to detach: $voucherCount"); + $this->line("Children: $childrenCount"); + $this->line("Carers (including trashed): $carersCount"); + + if ($dryRun) { + $this->warn('Dry run complete — nothing deleted.'); + return self::SUCCESS; + } + + if ( + !$force && !$this->confirm( + "This will permanently purge family $familyId and related data. Continue?" + ) + ) { + $this->warn('Skipped'); + return self::FAILURE; + } + + if ($bundleIds->isNotEmpty()) { + $this->detachVouchersFromBundles($bundleIds); + $this->deleteBundles($bundleIds); + } + + if ($registrationIds->isNotEmpty()) { + $this->deleteRegistrations($registrationIds); + } + + $deletedChildren = $this->deleteChildren($familyId); + if ($deletedChildren !== $childrenCount) { + throw new RuntimeException( + "Child delete mismatch. Expected $childrenCount, deleted $deletedChildren." + ); + } + + $deletedCarers = $this->deleteCarers($familyId); + if ($deletedCarers !== $carersCount) { + throw new RuntimeException( + "Carer delete mismatch. Expected $carersCount, deleted $deletedCarers." + ); + } + + $deletedFamily = $this->deleteFamily($family); + if ($deletedFamily !== 1) { + throw new RuntimeException("Family delete failed for family $familyId."); + } + + $this->info('Family graph permanently deleted.'); + return self::SUCCESS; + }, attempts: 3); + } catch (Throwable $e) { + report($e); + $this->error($e->getMessage()); + return self::FAILURE; + } + } + + private function detachVouchersFromBundles(Collection $bundleIds): void + { + foreach ($bundleIds->chunk(1000) as $chunk) { + Voucher::whereIn('bundle_id', $chunk->all())->update(['bundle_id' => null]); + } + } + + private function deleteBundles(Collection $bundleIds): void + { + $expected = $bundleIds->count(); + $deleted = 0; + + foreach ($bundleIds->chunk(1000) as $chunk) { + $affected = Bundle::whereIn('id', $chunk->all())->delete(); + $deleted += $affected; + } + + if ($deleted !== $expected) { + throw new RuntimeException( + "Bundle delete mismatch. Expected $expected, deleted $deleted." + ); + } + } + + private function deleteRegistrations(Collection $registrationIds): void + { + $expected = $registrationIds->count(); + $deleted = 0; + + foreach ($registrationIds->chunk(1000) as $chunk) { + $affected = Registration::whereIn('id', $chunk->all())->delete(); + $deleted += $affected; + } + + if ($deleted !== $expected) { + throw new RuntimeException( + "Registration delete mismatch. Expected $expected, deleted $deleted." + ); + } + } + + private function deleteChildren(int $familyId): int + { + return (int)Child::where('family_id', $familyId)->delete(); + } + + private function deleteCarers(int $familyId): int + { + // carers are softDelete-able + return (int)Carer::where('family_id', $familyId)->withTrashed()->forceDelete(); + } + + private function deleteFamily($family): int + { + return (int)$family->delete(); + } +} diff --git a/app/Console/Commands/SweepAndSubmitCollectingCentres.php b/app/Console/Commands/SweepAndSubmitCollectingCentres.php new file mode 100644 index 000000000..ebe10d4aa --- /dev/null +++ b/app/Console/Commands/SweepAndSubmitCollectingCentres.php @@ -0,0 +1,67 @@ +whereNotNull('centre_id'); + })->count(); + + Log::info(sprintf('[SweepAndSubmit] Found %d traders in internal markets', $count)); + + Trader::whereHas('market', static function ($q) { + return $q->whereNotNull('centre_id'); + })->chunk( + 50, + function ($traders): void { + foreach ($traders as $trader) { + $query = $trader->vouchers()->where('currentstate', 'recorded'); + $count = $query->count(); + if ($count === 0) { + Log::debug(sprintf( + '[SweepAndSubmit] Trader %d (%s): no recorded vouchers, skipping', + $trader->id, + $trader->name + )); + continue; + } + + Log::info(sprintf( + '[SweepAndSubmit] Trader %d (%s): processing %d vouchers', + $trader->id, + $trader->name, + $count + )); + + // Builder passed directly — handle() opens the lazy cursor internally. + // No invalid detection needed: the query is scoped, not user-submitted. + $processor = new TransitionProcessor( + trader: $trader, + transition: 'confirm', + sendPaymentEmail: false + ); + $response = $processor->handle($query); + + Log::info(sprintf( + '[SweepAndSubmit] Trader %d (%s): results %s', + $trader->id, + $trader->name, + json_encode($response->toArray(), JSON_THROW_ON_ERROR) + )); + } + } + ); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 9dadbf7f3..1b13c353a 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -19,6 +19,12 @@ protected function schedule(Schedule $schedule): void ->dailyAt('02:00') ->withoutOverlapping() ; + + $schedule->command('arc:sweep-and-submit') + ->tuesdays() + ->at('01:00') + ->withoutOverlapping() + ; } /** diff --git a/app/Family.php b/app/Family.php index 426682e2f..486a61a26 100644 --- a/app/Family.php +++ b/app/Family.php @@ -9,6 +9,7 @@ use App\Traits\Evaluable; use DB; use Eloquent; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -21,10 +22,10 @@ * @property string $rejoin_on * @property string $leave_amount * @property int $centre_sequence - * @property Carer[] $carers - * @property Child[] $children - * @property Note[] $notes - * @property Registration[] $registrations + * @property Collection|Carer[] $carers + * @property Collection|Child[] $children + * @property Collection|Note[] $notes + * @property Collection|Registration[] $registrations * @property Centre $initialCentre * @property string $rvid * @@ -63,14 +64,7 @@ class Family extends Model implements IEvaluee ]; /** - * The attributes that should be hidden for arrays. - * - * @var array - */ - protected $hidden = []; - - /** - * Attributes to autocalculate and add when we ask. + * Attributes to auto-calculate and add when we ask. * * @var array */ @@ -79,6 +73,41 @@ class Family extends Model implements IEvaluee 'rvid', ]; + public static function findByRvid(string $rvid): ?self + { + $rvid = strtoupper(trim($rvid)); + + if ($rvid === '') { + return null; + } + + // IMPORTANT: longest prefix first (prevents AB matching before AB1) + $centres = Centre::query() + ->select('id', 'prefix') + ->orderByRaw('LENGTH(prefix) DESC') + ->get()->all(); + + foreach ($centres as $centre) { + if (!str_starts_with($rvid, $centre->prefix)) { + continue; + } + $sequencePart = substr($rvid, strlen($centre->prefix)); + + if (!ctype_digit($sequencePart)) { + continue; + } + + $sequence = (int)$sequencePart; + + return self::query() + ->where('initial_centre_id', $centre->id) + ->where('centre_sequence', $sequence) + ->first(); + } + + return null; + } + /** * Gets the evaluator from up the chain. * @@ -95,11 +124,9 @@ public function getEvaluator(): AbstractEvaluator } /** - * Gets the due date or Null; - * - * @return mixed|null + * Gets the due date or Null */ - public function getExpectingAttribute() + public function getExpectingAttribute(): mixed { $due = null; foreach ($this->children as $child) { @@ -155,7 +182,7 @@ public function getRvidAttribute(): string */ public function carers(): HasMany { - return $this->hasMany('App\Carer'); + return $this->hasMany(Carer::class); } /** @@ -164,7 +191,7 @@ public function carers(): HasMany */ public function children(): HasMany { - return $this->hasMany('App\Child'); + return $this->hasMany(Child::class); } /** @@ -174,7 +201,7 @@ public function children(): HasMany */ public function notes(): HasMany { - return $this->hasMany('App\Note'); + return $this->hasMany(Note::class); } /** @@ -184,7 +211,7 @@ public function notes(): HasMany */ public function registrations(): HasMany { - return $this->hasMany('App\Registration'); + return $this->hasMany(Registration::class); } /** @@ -193,7 +220,7 @@ public function registrations(): HasMany */ public function initialCentre(): BelongsTo { - return $this->belongsTo('App\Centre', 'initial_centre_id'); + return $this->belongsTo(Centre::class, 'initial_centre_id'); } public function scopeWithPrimaryCarer($query) @@ -206,4 +233,13 @@ public function scopeWithPrimaryCarer($query) return $query->select('families.*')->selectSub($subQuery, 'pri_carer'); } + + /** Check status of family (active or not active) + * @return bool + */ + public function status(): bool + { + return $this->leaving_on === null + || ($this->rejoin_on > $this->leaving_on); + } } diff --git a/app/Http/Controllers/API/VoucherController.php b/app/Http/Controllers/API/VoucherController.php index 237b3da66..447e949fe 100644 --- a/app/Http/Controllers/API/VoucherController.php +++ b/app/Http/Controllers/API/VoucherController.php @@ -4,7 +4,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\ApiTransitionVoucherRequest; -use App\Services\TransitionProcessor; +use App\Services\TransitionProcessor\TransitionProcessor; use App\Trader; use App\Voucher; use Illuminate\Http\JsonResponse; @@ -12,32 +12,29 @@ class VoucherController extends Controller { /** - * Legacy transition route for older clients + * Legacy transition route for older clients. * route POST api/vouchers - * - * @param ApiTransitionVoucherRequest $request - * @return JsonResponse */ public function legacyTransition(ApiTransitionVoucherRequest $request): JsonResponse { - // get our trader $trader = Trader::findOrFail($request->input('trader_id')); - //create unique, cleaned vouchers - $voucherCodes = array_unique(Voucher::cleanCodes($request->input('vouchers'))); + $submittedCodes = array_unique(Voucher::cleanCodes($request->input('vouchers'))); + + $query = Voucher::whereIn('code', $submittedCodes); + $foundCodes = $query->pluck('code')->all(); + $invalidCodes = array_values(array_diff($submittedCodes, $foundCodes)); $processor = new TransitionProcessor($trader, $request->input('transition')); - $processor->handle($voucherCodes); + $response = $processor->handle($query); + $response->addInvalid($invalidCodes); - return response()->json($processor->constructResponseMessage()); + return response()->json($response->constructResponseMessage()); } /** * Display the specified resource. - * - * @param string $code - * @return JsonResponse */ public function show(string $code): JsonResponse { diff --git a/app/Http/Controllers/Service/Admin/CentreUsersController.php b/app/Http/Controllers/Service/Admin/CentreUsersController.php index 69175fcd8..b95f8950a 100644 --- a/app/Http/Controllers/Service/Admin/CentreUsersController.php +++ b/app/Http/Controllers/Service/Admin/CentreUsersController.php @@ -23,16 +23,13 @@ use Log; use Ramsey\Uuid\Uuid; use Throwable; -use function PHPUnit\Framework\isNan; class CentreUsersController extends Controller { /** * Display a listing of Workers. - * @param AdminIndexCentreUsersRequest $request - * @return Application|Factory|View */ - public function index(AdminIndexCentreUsersRequest $request): View|Factory|Application + public function index(AdminIndexCentreUsersRequest $request): Factory|View { // fetch query params from request $field = $request->input('orderBy'); @@ -47,7 +44,7 @@ public function index(AdminIndexCentreUsersRequest $request): View|Factory|Appli return $homeCentre?->sponsor?->name . '#' . $homeCentre?->name . '#' . $item->name; }, }; - $workers = CentreUser::withTrashed()->get()->sortBy($sorter, SORT_REGULAR, ($direction === 'desc')); + $workers = CentreUser::withTrashed()->active()->get()->sortBy($sorter, SORT_REGULAR, ($direction === 'desc')); return view('service.centreusers.index', compact('workers')); } @@ -101,12 +98,9 @@ public function edit($id): View|Factory|Application /** * Update a CentreUser from a form - * @param AdminUpdateCentreUserRequest $request - * @param $id - * @return RedirectResponse * @throws Throwable */ - public function update(AdminUpdateCentreUserRequest $request, $id) + public function update(AdminUpdateCentreUserRequest $request, $id): RedirectResponse { try { $centreUser = DB::transaction(function () use ($request, $id) { @@ -140,11 +134,8 @@ public function update(AdminUpdateCentreUserRequest $request, $id) /** * Code deduplication; - * @param Request $request - * @param CentreUser $cu - * @return array */ - private function syncCentres(Request $request, CentreUser $cu): array + private function syncCentres(Request $request, CentreUser $cu): void { // Set Home Centre $homeCentre_id = $request->input('worker_centre'); @@ -162,13 +153,11 @@ private function syncCentres(Request $request, CentreUser $cu): array } } // Sync them setting pivots. - return $cu->centres()->sync($centre_ids); + $cu->centres()->sync($centre_ids); } /** * Create a CentreUser from a form - * @param AdminNewCentreUserRequest $request - * @return RedirectResponse * @throws Throwable */ public function store(AdminNewCentreUserRequest $request): RedirectResponse @@ -196,12 +185,13 @@ public function store(AdminNewCentreUserRequest $request): RedirectResponse // Throw it back to the user return redirect()->route('admin.centreusers.create')->withErrors('Creation failed - DB Error.'); } - return redirect()->route('admin.centreusers.index')->with('message', - 'Worker ' . $centreUser->name . ' created'); + return redirect()->route('admin.centreusers.index')->with( + 'message', + 'Worker ' . $centreUser->name . ' created' + ); } /** - * @return void * @throws CannotInsertRecord */ public function download(): void @@ -253,20 +243,18 @@ public function download(): void /** * Handle deleting a centre user - * @param int $id - * @return RedirectResponse */ - public function delete(int $id): RedirectResponse + public function retire(int $id): RedirectResponse { // must be disabled to delete $centreUser = CentreUser::onlyTrashed()->findOrFail($id); $name = $centreUser->name; - // remove any connections - $centreUser->centre->centreUsers()->detach($id); + // boot them - $centreUser->forceDelete(); + $centreUser->retire(); + return redirect()->route('admin.centreusers.index') - ->with('message', 'Worker ' . $name . ' deleted'); + ->with('message', 'Worker ' . $name . ' retired'); } /** diff --git a/app/Http/Controllers/Service/Admin/CentresController.php b/app/Http/Controllers/Service/Admin/CentresController.php index e3a6dd034..6ce2cb3c0 100644 --- a/app/Http/Controllers/Service/Admin/CentresController.php +++ b/app/Http/Controllers/Service/Admin/CentresController.php @@ -6,96 +6,68 @@ use App\Http\Controllers\Controller; use App\Http\Requests\AdminNewCentreRequest; use App\Http\Requests\AdminUpdateCentreRequest; -use Auth; -use DB; -use Exception; -use Illuminate\Contracts\View\Factory; -use Illuminate\Database\Eloquent\ModelNotFoundException; +use App\Sponsor; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\View\View; -use App\Sponsor; -use Log; +use Throwable; class CentresController extends Controller { - /** * Display a listing of Centres. - * - * @return Factory|View */ - public function index() + public function index(): View { - $centres = Centre::get(); + $centres = Centre::all(); return view('service.centres.index', compact('centres')); } /** * Show the form for creating new Centres. - * - * @return Factory|View */ - public function create() + public function create(): View { - $sponsors = Sponsor::get(); + $sponsors = Sponsor::all(); return view('service.centres.create', compact('sponsors')); } /** - * Return a json list of neighbour names and IDs - * - * @param $id - * @return JsonResponse + * Return a JSON list of neighbour names and IDs. */ - public function getNeighboursAsJson($id) + public function getNeighboursAsJson(Centre $centre): JsonResponse { - try { - /** @var Centre $centre */ - $centre = Centre::findOrFail($id); - $neighbours = $centre - ->neighbours() - ->whereKeyNot($id) - ->get(['name', 'id']) - ; - } catch (ModelNotFoundException $e) { - $neighbours = collect([]); - } + $neighbours = $centre + ->neighbours() + ->whereKeyNot($centre->getKey()) + ->get(['name', 'id']); + return response()->json($neighbours); } /** - * @param AdminNewCentreRequest $request - * @return RedirectResponse - * @throws \Throwable + * Store a newly created Centre. */ - public function store(AdminNewCentreRequest $request) + public function store(AdminNewCentreRequest $request): RedirectResponse { try { - $centre = DB::transaction(function () use ($request) { - - // Create a Centre - $c = new Centre([ - 'name' => $request->input('name'), - 'prefix' => $request->input('rvid_prefix'), - 'print_pref' => $request->input('print_pref'), - 'sponsor_id' => $request->input('sponsor') - ]); - $c->save(); - - return $c; + $centre = DB::transaction(static function () use ($request): Centre { + return Centre::create($request->validated()); }); - } catch (Exception $e) { - // Oops! Log that + } catch (Throwable $e) { Log::error('Bad transaction for ' . __CLASS__ . '@' . __METHOD__ . ' by service user ' . Auth::id()); Log::error($e->getTraceAsString()); - // Throw it back to the user + return redirect() ->route('admin.centres.create') ->withErrors('Creation failed - DB Error.'); } + return redirect() ->route('admin.centres.index') ->with('message', 'Centre ' . $centre->name . ' created'); @@ -103,43 +75,33 @@ public function store(AdminNewCentreRequest $request) /** * Show the form for editing a Centre. - * - * @return Factory|View */ - public function edit($id) + public function edit(Centre $centre): View { - $centre = Centre::find($id); - return view('service.centres.edit', compact('centre')); + $sponsors = Sponsor::all(); + return view('service.centres.edit', compact('centre', 'sponsors')); } /** - * Show the form for editing a Centre's name. - * - * @return Factory|View + * Update the specified Centre's fields */ - public function update(AdminUpdateCentreRequest $request, Centre $id) + public function update(AdminUpdateCentreRequest $request, Centre $centre): RedirectResponse { - try { - $centre = DB::transaction(function () use ($request, $id) { - // Update the system - $id->fill([ - 'name' => $request->input('name'), - ]); - $id->save(); + try { + DB::transaction(static function () use ($request, $centre): bool { + return $centre->update($request->validated()); + }); + } catch (Throwable $e) { + Log::error('Bad transaction for ' . __CLASS__ . '@' . __METHOD__ . ' by service user ' . Auth::id()); + Log::error($e->getTraceAsString()); - return $id; - }); - } catch (Exception $e) { - // Oops! Log that - Log::error('Bad transaction for ' . __CLASS__ . '@' . __METHOD__ . ' by service user ' . Auth::id()); - Log::error($e->getTraceAsString()); - // Throw it back to the user - return redirect() - ->route('admin.centres.create') - ->withErrors('Creation failed - DB Error.'); - } - return redirect() - ->route('admin.centres.index') - ->with('message', 'Centre ' . $centre->name . ' edited'); + return redirect() + ->route('admin.centres.edit', $centre->id) + ->withErrors('Update failed - DB Error.'); + } + + return redirect() + ->route('admin.centres.index') + ->with('message', 'Centre ' . $centre->name . ' edited'); } } diff --git a/app/Http/Controllers/Service/Admin/DeliveriesController.php b/app/Http/Controllers/Service/Admin/DeliveriesController.php index 7fe7b9fbe..378dacd36 100644 --- a/app/Http/Controllers/Service/Admin/DeliveriesController.php +++ b/app/Http/Controllers/Service/Admin/DeliveriesController.php @@ -87,10 +87,10 @@ public function store(AdminNewDeliveryRequest $request): RedirectResponse 'dispatched_at' => Carbon::createFromFormat('Y-m-d', $request->input('date-sent')), ]); - $nowTime = $delivery->created_at; - $user = auth()->user(); - $userId = $user->id; - $userType = class_basename($user); + $nowTime = $delivery->created_at; + $user = auth()->user(); + $userId = $user?->id; + $userType = $user ? get_class($user) : null; foreach ($transitions as $transitionDef) { Voucher::whereNull('delivery_id') @@ -116,7 +116,7 @@ public function store(AdminNewDeliveryRequest $request): RedirectResponse // Update vouchers atomically Voucher::whereIn('id', $vouchers->pluck('id')) ->update([ - 'delivery_id' => $delivery->id, + 'delivery_id' => $delivery->id, 'currentState' => $transitionDef->to, ]); }); diff --git a/app/Http/Controllers/Service/Admin/PaymentsController.php b/app/Http/Controllers/Service/Admin/PaymentsController.php index df713a488..af989ffb5 100644 --- a/app/Http/Controllers/Service/Admin/PaymentsController.php +++ b/app/Http/Controllers/Service/Admin/PaymentsController.php @@ -3,9 +3,9 @@ namespace App\Http\Controllers\Service\Admin; use App\Http\Controllers\Controller; +use App\Services\TransitionProcessor\TransitionProcessor; use App\StateToken; -use App\Trader; -use Carbon\Carbon; +use App\Voucher; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; @@ -14,234 +14,184 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Log; +use Illuminate\Support\Facades\Log; +use JsonException; class PaymentsController extends Controller { - private const HISTORY_CUTOFF = 21; - - /** Lightweight check for outstanding payments to highlight in dashboard - * @return bool - */ - public static function checkIfOutstandingPayments(): bool - { - $date = Carbon::now()->subDays(self::HISTORY_CUTOFF)->startOfDay(); - - $payments = DB::table('state_tokens') - ->where('created_at', '>', $date) - ->whereNull('admin_user_id') - ->count(); - - return $payments > 0; - } - /** * Lists the payments paid and pending - * @return Factory|View|Application */ public function index(): Factory|View|Application { - $pendingPaymentData = self::getStateTokensFromDate(); - $reimbursedPaymentData = self::getStateTokensFromDate(true); - return view('service.payments.index', [ - 'pending' => $pendingPaymentData, - 'reimbursed' => $reimbursedPaymentData, - ]); - } - - /** - * List Payments - * @param bool $paid - * @param Carbon|null $date - * @return array - */ - public static function getStateTokensFromDate(bool $paid = false, Carbon $date = null): array - { + $pending = StateToken::pending() + ->withinPaymentWindow() + ->withPaymentRelations() + ->orderByDesc('created_at') + ->get(); - //set the period we want scoped - $fromDate = $date ?? Carbon::now()->subDays(self::HISTORY_CUTOFF)->startOfDay(); - //get all the StateTokens for unpaid (pending) payment requests in the past 7 days - // (in theory nothing is ever unpaid for that long anyway) - $tokens = StateToken::with([ - 'user', - 'voucherStates', - 'voucherStates.voucher', - 'voucherStates.voucher.trader', - 'voucherStates.voucher.trader.market.sponsor', - 'voucherStates.voucher.sponsor', - ]) - ->where('created_at', '>', $fromDate->format('Y-m-d')) - ->whereNotNull('user_id') - // if $paid = true will make this a NotNull, thereby getting paid things - ->whereNull('admin_user_id', 'and', $paid) - ->orderBy('created_at', 'desc') + $reimbursed = StateToken::reimbursed() + ->withinPaymentWindow() + ->withPaymentRelations() + ->orderByDesc('created_at') ->get(); - return self::makePaymentDataStructure($tokens); + return view('service.payments.index', [ + 'pending' => self::makePaymentDataStructure($pending), + 'reimbursed' => self::makePaymentDataStructure($reimbursed), + ]); } /** - * Constructs the Payment structure for our blade - * @param Collection $tokens - * @return array + * Constructs the payment data structure for the payments blade. + * @throws JsonException */ public static function makePaymentDataStructure(Collection $tokens): array { - $pendingResults = []; + $results = []; + foreach ($tokens as $stateToken) { - // start tracking this set of results - $currentTokenResults = []; - $currentTokenResults['requestedBy'] = $stateToken->user->name ?? 'unknown'; - - // get the states for this token - $voucherStates = $stateToken->voucherStates->all(); - // count 'em while we're here - $currentTokenResults['vouchersTotal'] = count($voucherStates); - - //Get all the attributes we need via each voucherState - foreach ($voucherStates as $voucherState) { - $trader = $voucherState->voucher->trader; - //These are the main headers; check once and then take that going forward - $currentTokenResults['traderName'] ??= $trader->name; - if (empty($currentTokenResults['traderName'])) { - \Log::warning("Bad voucher trader name: ", json_encode($voucherStates)); - continue; - } - $currentTokenResults['marketName'] ??= $trader->market->name; - $currentTokenResults['area'] ??= $trader->market->sponsor->name; - - $areaList = $currentTokenResults['voucherAreas'] ?? []; - $areaName = $voucherState->voucher->sponsor->name; - $areaList[$areaName] = isset($areaList[$areaName]) - ? $areaList[$areaName] +=1 - : 1; - $currentTokenResults['voucherAreas'] = $areaList; + $voucherStates = $stateToken->voucherStates; + + $firstTrader = $voucherStates->first()?->voucher?->trader; + if ($firstTrader === null || empty($firstTrader->name)) { + Log::warning(sprintf( + 'Skipping token %s — missing trader on first voucher state', + $stateToken->uuid + )); + continue; } - foreach ($pendingResults as $index => $result) { - if ( - empty($result['requestedBy']) || - empty($result['vouchersTotal']) || - empty($result['traderName']) || - empty($result['marketName']) || - empty($result['area']) || - empty($result['voucherAreas']) - ) { - \Log::error("Bad pending results at index " . $index . " - " . json_encode($result)); - unset($pendingResults[$index]); - } + + // A trader without a market is a data integrity issue. + // Skip rather than throw — the admin should still see other tokens. + if ($firstTrader->market === null) { + Log::error(sprintf( + 'Skipping token %s — trader %d has no associated market', + $stateToken->uuid, + $firstTrader->id + )); + continue; } - // chuck that in the results array. - $pendingResults[$stateToken->uuid] = $currentTokenResults; + $currentTokenResults = [ + 'requestedBy' => $stateToken->user?->name ?? 'System', + 'vouchersTotal' => $voucherStates->count(), + 'traderName' => $firstTrader->name, + 'marketName' => $firstTrader->market->name, + 'area' => $firstTrader->market->sponsor?->name ?? '', + 'voucherAreas' => $voucherStates + ->countBy(function ($vs) { + return $vs->voucher->sponsor->name; + }) + ->all(), + ]; + + $requiredKeys = ['requestedBy', 'vouchersTotal', 'traderName', 'marketName', 'area', 'voucherAreas']; + if ( + collect($requiredKeys)->contains(function ($k) use ($currentTokenResults) { + return empty($currentTokenResults[$k]); + }) + ) { + Log::error(sprintf( + 'Incomplete payment data for token %s — %s', + $stateToken->uuid, + json_encode($currentTokenResults, JSON_THROW_ON_ERROR) + )); + continue; + } + + $results[$stateToken->uuid] = $currentTokenResults; } - return $pendingResults; + + return $results; } - /** Get a specific payment request by link - * @param $paymentUuid - * @return mixed + /** + * Get a specific payment request by link. + * + * Passes state_token = null to the view when the UUID is not found so the + * view can render an inline error message. A redirect was previously + * considered but PaymentsPageTest establishes that the correct UX is to + * stay on the paymentRequest page with an explanatory message — not to + * bounce the admin to the index. */ - public function show($paymentUuid) + public function show(string $paymentUuid): Factory|View { - // Initialise - $vouchers = []; - $trader = "trader"; - $number_to_pay = 0; - - // Find the StateToken of a given uuid - $state_token = StateToken::where('uuid', $paymentUuid)->first(); - if ($state_token !== null) { - - // Get the VoucherStates with this StateToken - $voucher_states = $state_token - ->voucherStates() - ->get(); - - // Get the voucher codes of states TODO - better - foreach ($voucher_states as $voucher_state) { - $vouchers[] = $voucher_state - ->voucher() - ->first(); - } - - // Count the payable vouchers - $number_to_pay = collect($vouchers) - ->where('currentstate', 'payment_pending') - ->count(); + $stateToken = StateToken::with([ + 'voucherStates.voucher.trader', + 'voucherStates.voucher.sponsor', + ]) + ->where('uuid', $paymentUuid) + ->first(); - // Get the trader's name - if (!empty($vouchers)) { - $trader = Trader::find($vouchers[0]->trader_id)->name; - } - } + $vouchers = $stateToken?->voucherStates + ->map(function ($vs) { + return $vs->voucher; + }) + ->filter() + ?? collect(); return view('service.payments.paymentRequest', [ - 'state_token' => $state_token, + 'state_token' => $stateToken, 'vouchers' => $vouchers, - 'trader' => $trader, - 'number_to_pay' => $number_to_pay, + 'trader' => $vouchers->first()?->trader?->name ?? 'Unknown trader', + 'number_to_pay' => $vouchers->where('currentstate', 'payment_pending')->count(), ]); } /** - * Pay a specific payment request by link - * @param Request $request - * @param $paymentUuid - * @return RedirectResponse + * Pay a specific payment request by link. + * + * trader: null is intentional — payout is admin-driven and handlePayout + * preserves the voucher's existing trader_id unchanged. See TransitionProcessor. */ - public function update(Request $request, $paymentUuid): RedirectResponse + public function update(Request $request, string $paymentUuid): RedirectResponse { - // Initialise - $vouchers = []; + $stateToken = StateToken::where('uuid', $paymentUuid)->firstOrFail(); - // Find the StateToken of a given uuid - $state_token = StateToken::where('uuid', $paymentUuid)->first(); - if ($state_token !== null) { + $query = Voucher::whereHas( + 'history', + static function ($q) use ($stateToken) { + return $q->where('state_token_id', $stateToken->id); + } + ); - // Get the VoucherStates with this StateToken - $voucher_states = $state_token - ->voucherStates() - ->get(); + $processor = new TransitionProcessor( + trader: null, + transition: 'payout', + sendPaymentEmail: false + ); - // Get the voucher codes of states TODO - better - foreach ($voucher_states as $voucher_state) { - $voucher = $voucher_state - ->voucher() - ->first(); + try { + DB::beginTransaction(); - $vouchers[] = $voucher; - } + $response = $processor->handle($query); - // Transition the vouchers - $success = true; - Log::info(sprintf( - "%s: Processing %d vouchers, uuid=%s, user=%s(%d), admin user=%s(%d), ip=%s", - __CLASS__, - count($vouchers), - $state_token->uuid, - $state_token->user?->name, - $state_token->user?->id, - Auth::user()->name, - Auth::user()->id, - $request->ip(), - )); - foreach ($vouchers as $v) { - if ($v->transitionAllowed('payout')) { - $v->applyTransition('payout'); - } else { - Log::info('Failure Processing Payout Transition'); - $success = false; - break; - } - } - if ($success) { - \Log::debug("Transition successful"); - $state_token->admin_user_id = Auth::user()->id; - $state_token->save(); - } else { - \Log::debug("Transition failed"); + if ($response->hasFailures()) { + DB::rollBack(); + return redirect() + ->route('admin.payments.index') + ->withErrors($response->toArray()); } + + $stateToken->admin_user_id = Auth::id(); + $stateToken->save(); + + DB::commit(); + + return redirect() + ->route('admin.payments.index') + ->with('notification', 'Vouchers Paid!'); + + } catch (\Throwable $e) { + DB::rollBack(); + Log::error('Payout failed unexpectedly', [ + 'uuid' => $paymentUuid, + 'error' => $e->getMessage(), + ]); + return redirect() + ->route('admin.payments.index') + ->withErrors(['error' => 'Payment processing failed. Please try again.']); } - return redirect()->route('admin.payments.index')->with('notification', 'Vouchers Paid!'); } } diff --git a/app/Http/Controllers/Service/Admin/SponsorsController.php b/app/Http/Controllers/Service/Admin/SponsorsController.php index d4e44950d..acea4e0f1 100644 --- a/app/Http/Controllers/Service/Admin/SponsorsController.php +++ b/app/Http/Controllers/Service/Admin/SponsorsController.php @@ -126,7 +126,7 @@ public function update(UpdateRulesRequest $request, int $id): RedirectResponse try { Evaluation::updateOrCreate(['sponsor_id' => $id, 'name' => $key], $payload); } catch (Exception $e) { - Log::error("Failed to update evaluation $key for sponsor #{$id} by user " . Auth::id(), [ + Log::error("Failed to update evaluation $key for sponsor #$id by user " . Auth::id(), [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); diff --git a/app/Http/Controllers/Service/Admin/VouchersController.php b/app/Http/Controllers/Service/Admin/VouchersController.php index eaeb47e5f..30b2e9cb4 100644 --- a/app/Http/Controllers/Service/Admin/VouchersController.php +++ b/app/Http/Controllers/Service/Admin/VouchersController.php @@ -9,7 +9,6 @@ use App\Sponsor; use App\Voucher; use App\VoucherState; -use Auth; use Carbon\Carbon; use DB; use Illuminate\Contracts\Foundation\Application; @@ -123,10 +122,10 @@ public function retireBatch(AdminUpdateVoucherRequest $request): RedirectRespons try { DB::transaction(static function () use ($rangeDef, $transitions) { - $nowTime = now(); - $user = auth()->user(); - $userId = $user->id; - $userType = class_basename($user); + $nowTime = now(); + $user = auth()->user(); + $userId = $user?->id; + $userType = $user ? get_class($user) : null; foreach ($transitions as $transitionDef) { Voucher::inDefinedRange($rangeDef) @@ -156,7 +155,7 @@ public function retireBatch(AdminUpdateVoucherRequest $request): RedirectRespons } catch (Throwable $e) { Log::error('Bad transaction for ' . __METHOD__, [ 'user_id' => auth()->id(), - 'error' => $e->getMessage(), + 'error' => $e->getMessage(), ]); return redirect() @@ -166,7 +165,7 @@ public function retireBatch(AdminUpdateVoucherRequest $request): RedirectRespons } $successCodes = implode(' ', $voidableCodes->all()); - $failedCodes = implode( + $failedCodes = implode( ' ', array_diff($allCodes->all(), $voidableCodes->all()) ); @@ -174,9 +173,9 @@ public function retireBatch(AdminUpdateVoucherRequest $request): RedirectRespons $notificationMsg = trans( 'service.messages.vouchers_batchretiretransition.success', [ - 'transition_to' => end($transitions)->to, - 'success_codes' => $successCodes, - 'fail_code_details'=> $failedCodes + 'transition_to' => end($transitions)->to, + 'success_codes' => $successCodes, + 'fail_code_details' => $failedCodes ? "{$failedCodes} could not be retired." : '', ] diff --git a/app/Http/Controllers/Service/Data/FamilyContactsController.php b/app/Http/Controllers/Service/Data/FamilyContactsController.php new file mode 100644 index 000000000..0f60fa79a --- /dev/null +++ b/app/Http/Controllers/Service/Data/FamilyContactsController.php @@ -0,0 +1,50 @@ +format('YmdHis'); + return response()->streamDownload(function () { + + $output = fopen('php://output', 'wb'); + + fputcsv($output, ['Rvid', 'Name', 'Email', 'Telno', 'Centre', 'Area']); + + Carer::query() + ->whereNotNull('emailsecret') + ->orWhereNotNull('telnosecret') + ->with(['family.initialCentre.sponsor']) + ->lazyById(200) + ->chunk(200) + ->each(function ($chunk) use ($output) { + $chunk->each(function (Carer $carer) use ($output) { + fputcsv($output, [ + $carer->family?->Rvid, + $carer->name, + $carer->emailsecret->reveal(), + $carer->telnosecret->reveal(), + $carer->family?->initialCentre?->name, + $carer->family?->initialCentre?->sponsor?->name, + ]); + }); + ob_flush(); + flush(); + }); + + fclose($output); + }, "carers-export_$now.csv", [ + 'Content-Type' => 'text/csv', + 'Cache-Control' => 'no-cache', + 'X-Content-Type-Options' => 'nosniff', + ]); + } +} diff --git a/app/Http/Controllers/Store/BundleController.php b/app/Http/Controllers/Store/BundleController.php index ef9cfb67d..374445ac0 100644 --- a/app/Http/Controllers/Store/BundleController.php +++ b/app/Http/Controllers/Store/BundleController.php @@ -6,362 +6,341 @@ use App\Carer; use App\Centre; use App\Family; -use App\Voucher; -use App\Registration; -use App\Http\Requests\StoreAppendBundleRequest; -use App\Http\Requests\StoreUpdateBundleRequest; use App\Http\Controllers\Controller; -use Auth; -use Carbon\Carbon; -use Exception; +use App\Http\Requests\StoreAppendBundleRequest; +use App\Http\Requests\StorePickupBundleRequest; +use App\Http\Requests\StoreTransitionBundleRequest; +use App\Registration; +use App\Services\TransitionProcessor\TransitionProcessor; +use App\Trader; +use App\Voucher; +use Illuminate\Contracts\View\View; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Arr; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\HtmlString; -use Log; +use Illuminate\Support\Str; +use RuntimeException; +use Throwable; class BundleController extends Controller { /** - * Returns the voucher-manager page for a given registration - * - * @param Registration $registration - * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + * Return the voucher-manager page for a given registration. */ - public function create(Registration $registration) + public function create(Registration $registration): View { $user = Auth::user(); - $data = [ - "user_name" => $user->name, - "centre_name" => ($user->centre) ? $user->centre->name : null, - ]; - - // Grabs a copy of all carers - $carers = $registration->family->carers->all(); $bundle = $registration->currentBundle()->vouchers; + $valuation = $registration->getValuation(); + $carers = $registration->family->carers->all(); - $sorted_bundle = $bundle->sortBy('code'); - - // Find the last collected bundle. $lastCollectedBundle = $registration->bundles() ->whereNotNull('disbursed_at') ->whereDate('disbursed_at', '<=', Carbon::today()->toDateString()) - ->orderBy('disbursed_at', 'desc') - ->limit(1) + ->orderByDesc('disbursed_at') ->first(); - ; - - // Turn it's disbursement date into a human date. - $lastCollection = ($lastCollectedBundle && (!empty($lastCollectedBundle->disbursed_at))) - // 'disbursed_at' is auto-carbon'd by the Bundle model - ? $lastCollectedBundle->disbursed_at->format('l jS \of F Y') - : null; - $valuation = $registration->getValuation(); - $programme = Auth::user()->centre->sponsor->programme; - - return view('store.manage_vouchers', array_merge( - $data, - [ - "registration" => $registration, - "lastCollection" => $lastCollection, - "children" => $registration->family->children, - "centre" => Auth::user()->centre, - "carers" => $carers, - "pri_carer" => array_shift($carers), - "vouchers" => $sorted_bundle, - "vouchers_amount" => count($bundle), - "entitlement" => $valuation->getEntitlement(), - "noticeReasons" => $valuation->getNoticeReasons(), - "programme" => $programme - ] - )); + $lastCollection = $lastCollectedBundle?->disbursed_at?->format('l jS \of F Y'); + + return view('store.manage_vouchers', [ + 'user_name' => $user->name, + 'centre_name' => $user->centre?->name, + 'registration' => $registration, + 'lastCollection' => $lastCollection, + 'children' => $registration->family->children, + 'centre' => $user->centre, + 'carers' => $carers, + 'pri_carer' => array_shift($carers), + 'vouchers' => $bundle->sortBy('code'), + 'vouchers_amount' => $bundle->count(), + 'entitlement' => $valuation->getEntitlement(), + 'noticeReasons' => $valuation->getNoticeReasons(), + 'programme' => $user->centre->sponsor->programme, + 'pool_size' => $user->centre->getPoolSize() ?? 0, + ]); } /** - * Does a single or multiple voucher. - * - * @param StoreAppendBundleRequest $request - * @param Registration $registration - * @return \Illuminate\Http\RedirectResponse + * Append a single voucher, range of vouchers, or a quantity drawn from the pool + * to the current bundle. */ - public function addVouchersToCurrentBundle(StoreAppendBundleRequest $request, Registration $registration) - { - // Generate code range from given values (may be only 1) - $voucherCodes = Voucher::generateCodeRange($request->get("start"), $request->get("end")); - - // Count vouchers and check them - $numVouchers = count($voucherCodes); - - if ($numVouchers <= config('arc.bundle_max_voucher_append')) { - // Get current Bundle - /** @var Bundle $bundle */ - $bundle = $registration->currentBundle(); - // try to add the vouchers. - $errors = $bundle->addVouchers($voucherCodes); + public function addVouchersToCurrentBundle( + StoreAppendBundleRequest $request, + Registration $registration, + ): RedirectResponse { + $managerRoute = $this->managerRoute($registration); + + if ($request->filled('voucher-quantity')) { + try { + $voucherCodes = $registration->centre->claimFromPool( + (int)$request->input('voucher-quantity'), + ) + ->pluck('code') + ->all(); + } catch (Throwable $e) { + return $this->redirectAfterRequest( + ['pool' => $e->getMessage()], + $managerRoute, + $managerRoute, + ); + } } else { - $errors = ['append' => $numVouchers]; + $voucherCodes = Voucher::generateCodeRange( + $request->input('start'), + $request->input('end'), + ); } - //Check the voucher isn't already recorded, payment_pending, paid or retired + $errors = (count($voucherCodes) <= config('arc.bundle_max_voucher_append')) + ? $registration->currentBundle()->addVouchers($voucherCodes) + : ['append' => count($voucherCodes)]; - - // Return to manager in all cases - $successRoute = $failRoute = route( - 'store.registration.voucher-manager', - ['registration' => $registration->id] - ); - - return $this->redirectAfterRequest($errors, $successRoute, $failRoute); + return $this->redirectAfterRequest($errors, $managerRoute, $managerRoute); } /** - * Create OR replace a registrations current active bundle - * - * @param StoreUpdateBundleRequest $request - * @param Registration $registration - * @return \Illuminate\Http\RedirectResponse + * Disburse the current bundle if collection details are present. */ - public function update(StoreUpdateBundleRequest $request, Registration $registration) + public function pickup(StorePickupBundleRequest $request, Registration $registration): RedirectResponse { - // Init for later - $errors = []; + $managerRoute = $this->managerRoute($registration); + $bundle = $registration->currentBundle(); - // Default return to manager - $successRoute = $failRoute = route( - 'store.registration.voucher-manager', - ['registration' => $registration->id] + $errors = $this->attemptDisbursal( + $bundle, + // Should be here, due to form request validation + $request->only(['collected_at', 'collected_by', 'collected_on']) ); - // Filter inputs for only our interests - $inputs = $request->all([ - 'collected_at', - 'collected_by', - 'collected_on' - ]); - - // If we don't mention them in form input because we are updating status of existing bundle vouchers - if ($request->exists('vouchers')) { - $inputs['vouchers'] = $request->input('vouchers'); - } + return $this->redirectAfterRequest($errors, route('store.registration.index'), $managerRoute, $bundle); + } - /** @var \App\Bundle $bundle */ + /** + * Disburse the current bundle and trigger a collection transition. + */ + public function collectBundle(StoreTransitionBundleRequest $request, Registration $registration): RedirectResponse + { + $managerRoute = $this->managerRoute($registration); $bundle = $registration->currentBundle(); + $errors = []; - // Are we updating vouchers? - if (array_key_exists('vouchers', $inputs)) { - // remove empty values + try { + // As this is an important change, we need to rollback, rather than plough on. + DB::transaction(function () use ($request, $bundle, &$errors) { + $errors = $this->attemptDisbursal( + $bundle, + // Should be here, due to form request validation + $request->only(['collected_at', 'collected_by', 'collected_on']) + ); - $voucherCodes = array_filter( - $inputs['vouchers'], - function ($value) { - return !empty($value); + if (!empty($errors)) { + throw new RuntimeException('disbursal errors'); } - ); - - $voucherCodes = (!empty($voucherCodes)) - ? Voucher::cleanCodes(($voucherCodes)) - : []; // Will result in the removal of the vouchers from the bundle. - // sync vouchers. - $errors[] = $bundle->syncVouchers($voucherCodes); - } + $trader = Trader::findOrFail($request->input('trader_id')); + $processor = new TransitionProcessor($trader, 'collect'); + $response = $processor->handle($bundle->vouchers()); - // Check we have values on our inputs; This should have been covered in validation... - if (isset($inputs['collected_at']) && - isset($inputs['collected_by']) && - isset($inputs['collected_on']) - ) { - // Check there are actual vouchers to disburse, or this is a bit. - if ($bundle->vouchers->count() === 0) { - $errors["empty"] = true; - } else { - // Add the date; - $bundle->disbursed_at = Carbon::createFromFormat( - 'Y-m-d', - $inputs['collected_on'] - )->startOfDay()->toDateTimeString(); - - try { - // Find and add the carer - $carer = Carer::findOrFail($inputs['collected_by']); - $bundle->collectingCarer()->associate($carer); - - // Find and add the centre - $centre = Centre::findOrFail($inputs['collected_at']); - $bundle->disbursingCentre()->associate($centre); - - // Add the current user as disbursingUser. - $bundle->disbursingUser()->associate(Auth::user()); - - // Store it. - $bundle->save(); - } catch (Exception $e) { - // Fires if finOrFail() or save() breaks - // Log that error by hand - Log::error('Bad transaction for ' . __CLASS__ . '@' . __METHOD__ . ' by service user ' . Auth::id()); - Log::error($e->getTraceAsString()); - $errors['transaction'] = true; + if ($response->hasFailures()) { + $errors['transition'] = $response->getFailureCodes(); + throw new RuntimeException('transition errors'); } - - // Return to Index as we've disbursed, and user may want to search - $successRoute = route( - 'store.registration.index' - ); - } + }); + } catch (Throwable $e) { + Log::error(sprintf( + 'Rollback Bad transaction for %s@%s by user %s because of: %e', + self::class, + __FUNCTION__, + Auth::id() ?? 'unauthenticated', + $e->getMessage() + )); } - return $this->redirectAfterRequest($errors, $successRoute, $failRoute, $bundle); + return $this->redirectAfterRequest($errors, route('store.registration.index'), $managerRoute, $bundle); } /** - * Function to remove all vouchers from a bundle - * - * @param Registration $registration - * @return \Illuminate\Http\RedirectResponse + * Remove all vouchers from the current bundle. */ - public function removeAllVouchersFromCurrentBundle(Registration $registration) + public function removeAllVouchersFromCurrentBundle(Registration $registration): RedirectResponse { - /** @var Bundle $bundle */ $bundle = $registration->currentBundle(); + $errors = $bundle->alterVouchers($bundle->vouchers()->get()); + $route = $this->managerRoute($registration); - // Get all the voucjhers for this bundle - $vouchers = $bundle->vouchers()->get(); + return $this->redirectAfterRequest($errors, $route, $route); + } - // Call alterVouchers with no codes to check, and no bundle to detransiton and remove it. - $errors = $bundle->alterVouchers($vouchers, [], null); + /** + * Remove a single voucher from the current bundle. + */ + public function removeVoucherFromCurrentBundle( + Registration $registration, + Voucher $voucher, + ): RedirectResponse { + $bundle = $registration->currentBundle(); + $route = $this->managerRoute($registration); - // Back to manager in all cases - $successRoute = $failRoute = route( - 'store.registration.voucher-manager', - ['registration' => $registration->id] - ); + $errors = $voucher->bundle_id === $bundle->id + ? $bundle->alterVouchers(collect([$voucher])) + : ['foreign' => [$voucher->code]]; - return $this->redirectAfterRequest($errors, $successRoute, $failRoute); + return $this->redirectAfterRequest($errors, $route, $route); } /** - * Removes a single voucher from a bundle - * @param Registration $registration - * @param Voucher $voucher - * @return \Illuminate\Http\RedirectResponse + * Guard against an empty bundle then delegate to disburseBundle. + * Returns an error map on failure, or an empty array on success. */ - public function removeVoucherFromCurrentBundle(Registration $registration, Voucher $voucher) + private function attemptDisbursal(Bundle $bundle, array $inputs): array { - /** @var Bundle $bundle */ - $bundle = $registration->currentBundle(); - - // It is attached to our bundle, right? - if ($voucher->bundle_id == $bundle->id) { - // Call alterVouchers with no codes to check, and no bundle to detransiton and remove it. - $errors = $bundle->alterVouchers(collect([$voucher]), [], null); - } else { - // Error it out (how did you get here? - $errors["foreign"] = [$voucher->code]; + if ($bundle->vouchers->isEmpty()) { + return ['empty' => true]; } - // Back to manager in all cases - $successRoute = $failRoute = route( - 'store.registration.voucher-manager', - ['registration' => $registration->id] - ); + return $this->disburseBundle($bundle, $inputs) ? [] : ['transaction' => true]; + } - return $this->redirectAfterRequest($errors, $successRoute, $failRoute); + /** + * Attempt to mark a bundle as disbursed. + */ + private function disburseBundle(Bundle $bundle, array $inputs): bool + { + try { + $bundle->disbursed_at = Carbon::createFromFormat('Y-m-d', $inputs['collected_on']) + ->startOfDay(); + + $bundle->collectingCarer()->associate(Carer::findOrFail($inputs['collected_by'])); + $bundle->disbursingCentre()->associate(Centre::findOrFail($inputs['collected_at'])); + $bundle->disbursingUser()->associate(Auth::user()); + $bundle->save(); + } catch (Throwable $e) { + Log::error(sprintf( + 'Bad transaction for %s@%s by service user %s', + self::class, + __FUNCTION__, + Auth::id() ?? 'unauthenticated', + )); + Log::error($e->getTraceAsString()); + return false; + } + return true; } /** - * Filters and prepares errors before returning to the voucher-manager - * - * @param $errors - * @param $successRoute - * @param $failRoute - * @param $bundle - * @return \Illuminate\Http\RedirectResponse + * Named route to the voucher-manager for a registration. */ - public function redirectAfterRequest($errors, $successRoute, $failRoute, $bundle = null) + private function managerRoute(Registration $registration): string { - $programme = Auth::user()->centre->sponsor->programme; + return route('store.registration.voucher-manager', ['registration' => $registration->id]); + } + + /** + * Build flash messages from an error map and redirect accordingly. + */ + public function redirectAfterRequest( + array $errors, + string $successRoute, + string $failRoute, + ?Bundle $bundle = null, + ): RedirectResponse { if (!empty($errors)) { - // Assemble messages - $messages = []; - foreach ($errors as $type => $values) { - switch ($type) { - case "transaction": - if ($values) { - $messages[] = 'Database transaction problem'; - } - break; - case "transition": - $messages[] = "Voucher state change problem with: " . join(', ', $values); - break; - case "codes": - $messages[] = "These codes are invalid: " . join(', ', $values); - break; - case "disbursed": - $messages[] = "These vouchers have been given out: " . join(', ', $values); - break; - case "used": - $messages[] = "These vouchers have already been used: " . join(', ', $values); - break; - case "bundled": - // Some vouchers were allocated to a family already. Partition these based on whether the user - // has permission to remove them from the current family, so we can make a nice interactive - // error message. - - $relevant = []; - $inaccessible = []; - - foreach ($values as $voucher) { - $registration = $voucher->bundle->registration; - - if (Auth::user()->isRelevantCentre($registration->centre)) { - // The user can deallocate the voucher from its current family at this route. - $route = route( - 'store.registration.voucher-manager', - ['registration' => $registration->id] - ); - - $relevant[] = "" . e($voucher->code) . ''; - } else { - // The user does not have permission to remove the voucher's current allocation. - $inaccessible[] = $voucher->code; - } - } - - // Generate error messages where vouchers of the sort existed, using unescaped HTML where necessary. - $relevant && $messages[] = new HtmlString( - "These vouchers are currently allocated to a different " . Family::getAlias($programme) . ". Click on the voucher number to view the other " . Family::getAlias($programme) . "'s record: " . join(', ', $relevant) - ); - $inaccessible && $messages[] = "These vouchers are allocated to a different " . Family::getAlias($programme) . " in a centre you can't access: " . join(', ', $inaccessible); - - break; - case "empty": - if ($values) { - $messages[] = "Action denied on empty bundle"; - } - break; - case "append": - if ($values) { - $messages[] = "Failed adding more than " . config('arc.bundle_max_voucher_append') . " vouchers"; - } - break; - default: - $messages[] = 'There was an unknown error'; - break; - } - } - // Spit the basic error messages back return redirect($failRoute) ->withInput() - ->with('error_messages', $messages); - } else { - $message = "Vouchers updated"; - if ($bundle instanceof Bundle) { - $numberOfVouchers = $bundle->vouchers->count(); - $fullFamily = $bundle->registration()->withFullFamily()->first(); - $familyName = $fullFamily->family->pri_carer; - $message = 'You have just marked ' . $numberOfVouchers . ' ' . str_plural('voucher', $numberOfVouchers) . ' as collected by ' . $familyName; + ->with('error_messages', $this->buildErrorMessages($errors)); + } + + $message = $bundle instanceof Bundle + ? $this->buildSuccessMessage($bundle) + : 'Vouchers updated'; + + return redirect($successRoute)->with('message', $message); + } + + /** + * Map the error array to human-readable message strings / HtmlString objects. + */ + private function buildErrorMessages(array $errors): array + { + $messages = []; + + foreach ($errors as $type => $values) { + $codeStrings = implode(', ', (array)$values); + $messages[] = match ($type) { + 'transaction' => 'Database transaction problem', + 'empty' => 'Action denied on empty bundle', + 'append' => 'Failed adding more than ' . config('arc.bundle_max_voucher_append') . ' vouchers', + 'transition' => 'Voucher state change problem with: ' . $codeStrings, + 'codes' => 'These codes are invalid: ' . $codeStrings, + 'disbursed' => 'These vouchers have been given out: ' . $codeStrings, + 'used' => 'These vouchers have already been used: ' . $codeStrings, + 'foreign' => 'These vouchers do not belong to this bundle: ' . $codeStrings, + 'bundled' => $this->buildBundledMessages((array)$values), + default => 'There was an unknown error', + }; + } + + return Arr::flatten($messages); + } + + /** + * Build one or two messages for vouchers already bundled elsewhere, + * partitioned by whether the current user can access the other registration. + */ + private function buildBundledMessages(array $vouchers): array + { + $user = Auth::user(); + $programme = $user->centre->sponsor->programme; + $familyAlias = Family::getAlias($programme); + $relevant = []; + $inaccessible = []; + + foreach ($vouchers as $voucher) { + $registration = $voucher->bundle->registration; + + if ($user->isRelevantCentre($registration->centre)) { + $relevant[] = '' . e($voucher->code) . ''; + } else { + $inaccessible[] = $voucher->code; } - // Otherwise, sure, return to the new view. - return redirect($successRoute) - ->with('message', $message); } + + $messages = []; + + if (!empty($relevant)) { + $messages[] = new HtmlString( + "These vouchers are currently allocated to a different $familyAlias. " + . "Click on the voucher number to view the other $familyAlias's record: " + . implode(', ', $relevant), + ); + } + + if (!empty($inaccessible)) { + $messages[] = "These vouchers are allocated to a different $familyAlias in a centre you can't access: " + . implode(', ', $inaccessible); + } + + return $messages; + } + + /** + * Build the success message shown after a bundle is disbursed. + */ + private function buildSuccessMessage(Bundle $bundle): string + { + $count = $bundle->vouchers->count(); + $fullFamily = $bundle->registration()->withFullFamily()->first(); + $name = $fullFamily->family->pri_carer; + + return sprintf( + 'You have just marked %d %s as collected by %s', + $count, + Str::plural('voucher', $count), + $name, + ); } } diff --git a/app/Http/Controllers/Store/FamilyController.php b/app/Http/Controllers/Store/FamilyController.php index fd27caacc..99f3094fb 100644 --- a/app/Http/Controllers/Store/FamilyController.php +++ b/app/Http/Controllers/Store/FamilyController.php @@ -69,33 +69,4 @@ public function rejoin(StoreRejoinRegistrationFamilyRequest $request, Registrati return redirect() ->route('store.registration.edit', ['registration'=> $registration->id ]); } - - /** - * - * Check status of family (active or not active) - * - * * - * @param Registration $registration - * @return bool - */ - public static function status(Registration $registration) - { - // get Family - /** @var $family Family */ - $family = $registration->family; - // Family is active and has never left - if ($family->leaving_on === null && $family->rejoin_on === null) { - $family->status = true; - // Family is not active and has not rejoined - } elseif ($family->leaving_on !== null && $family->rejoin_on === null) { - $family->status = false; - // They left then rejoined - } elseif ($family->leaving_on < $family->rejoin_on) { - $family->status = true; - // They left then rejoined then left again - } elseif ($family->leaving_on > $family->rejoin_on) { - $family->status = false; - } - return $family->status; - } } diff --git a/app/Http/Controllers/Store/RegistrationController.php b/app/Http/Controllers/Store/RegistrationController.php index 75be64415..9acbdeff5 100644 --- a/app/Http/Controllers/Store/RegistrationController.php +++ b/app/Http/Controllers/Store/RegistrationController.php @@ -13,134 +13,157 @@ use App\Services\VoucherEvaluator\EvaluatorFactory; use App\Services\VoucherEvaluator\Valuation; use App\User; -use Auth; use Carbon\Carbon; -use DB; -use Exception; use HighSolutions\LaravelSearchy\Facades\Searchy; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Application; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Arr; -use Log; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Stringable; use PDF; use Throwable; class RegistrationController extends Controller { - /** - * List all the Registrations (search-ably) - * - * This is a con. It only lists the registrations available to a User's CC's Sponsor - * This means a User can see the Registrations in his 'neighbour' CCs under a Sponsor - * - * Also, the view contains the search functionality. - * - */ - public function index(Request $request): View|Factory|Application + public function index(Request $request): Factory|View|RedirectResponse { - // Masthead bit /** @var User $user */ $user = Auth::user(); - $data = [ - 'user_name' => $user->name, - 'centre_name' => $user->centre?->name, - 'programme' => $user->centre?->sponsor?->programme, - ]; - - // get the inputs - $family_name = $request->get('family_name'); - $fuzzy = $request->get('fuzzy'); - // Slightly roundabout method of getting the permitted centres to poll - $neighbour_centre_ids = $user - ->relevantCentres() - ->pluck('id') - ->toArray(); + $familyName = $request->string('family_name'); + $fuzzy = $request->boolean('fuzzy'); + $descending = $request->input('direction') === 'desc'; + $neighbourCentreIds = $user->relevantCentres()->pluck('id'); + + $baseQuery = Registration::query() + ->withPrimaryCarer() + ->whereIn('registrations.centre_id', $neighbourCentreIds); + + $centreId = session('CentreUserCurrentCentreId'); + if ( + $user->centres->count() > 1 && + $request->boolean('filter_by_centre') && + $centreId + ) { + $baseQuery->where('registrations.centre_id', $centreId); + } - // get primary carers - $pri_carers = Carer::query() - ->selectRaw('MIN(carers.id) AS min_id') - ->whereIn('carers.family_id', function ($q) use ($neighbour_centre_ids) { - // limited to families that have registration in our centres - $q->select('registrations.family_id') - ->from('registrations') - ->whereIn('registrations.centre_id', $neighbour_centre_ids) - ->distinct(); - }) - ->groupBy('carers.family_id') - ->pluck('min_id') - ->toArray(); + if (!$request->boolean('families_left')) { + $baseQuery->WhereActiveFamily(); + } - // pick a search type - $filtered_family_ids = $fuzzy - ? $this->fuzzySearch($family_name, $pri_carers) - : $this->exactSearch($family_name, $pri_carers); + $useFuzzy = $fuzzy + && $familyName->isNotEmpty() + && config('database.connections.' . config('database.default') . '.driver') === 'mysql'; - //find the registrations - $q = Registration::query(); + [$registrations, $resolvedFuzzy] = $useFuzzy + ? [$this->fetchFuzzy($request, $baseQuery, $familyName, $neighbourCentreIds, $descending), true] + : [$this->fetchExact($baseQuery, $familyName, $descending), false]; - if (!empty($neighbour_centre_ids)) { - $q = $q->whereIn('centre_id', $neighbour_centre_ids); + if ($registrations->currentPage() > $registrations->lastPage()) { + return redirect()->to( + $registrations->url($registrations->lastPage()) + ); } - // only for cc users with access to more than 1 centre - if ($user->centres->count() > 1) { - // get the centre_id from the masthead dropdown which is set by session (so we can filter reg selection) - $filtered_centre_id = session('CentreUserCurrentCentreId'); - if ($filtered_centre_id && $filtered_centre_id !== "all") { - $q = $q->where('centre_id', '=', $filtered_centre_id); - } - } + return view('store.index_registration', [ + 'user_name' => $user->name, + 'centre_name' => $user->centre?->name, + 'programme' => $user->centre?->sponsor?->programme, + 'registrations' => $registrations, + 'fuzzy' => $resolvedFuzzy, + ]); + } - if (!empty($filtered_family_ids)) { - $q = $q->whereIn('family_id', $filtered_family_ids) - // Somehow, whereIn re-orders the filtered array into numeric order. - // this would be the "cheap" solution, IF sqlite supported FIELD so we could test that. - // ->orderByRaw(DB::raw("FIELD(family_id, " .implode(',', $filtered_family_ids). ")")); - ; + /** + * Exact-match strategy: everything resolved in SQL. + * Returns a paginator ready for the view. + */ + private function fetchExact( + Builder $query, + Stringable $familyName, + bool $descending, + ): LengthAwarePaginator { + if ($familyName->isNotEmpty()) { + $query->filterByCarerName((string)$familyName); } - // Check if the request asks us to display inactive families - $q = $request->get('families_left') ? $q : $q->WhereActiveFamily(); + $query->orderByCarerName($descending); - // Check if the request should filter by centre - $q = $request->get('centre') ? $q->where('centre_id', $request->get('centre')) : $q; + return $query + ->WithFullFamily() + ->paginate(perPage: 10) + ->withQueryString(); + } - // This isn't ideal as it relies on getting all the families, then sorting them. - // However, the whereIn statements above destroy any sorted order on family_ids. - $reg_models = $q->WithFullFamily() - ->get() + /** + * Fuzzy strategy (MySQL-only): Searchy ranks IDs by relevance; + * ordering is preserved via PHP-side pagination. + * Returns a paginator ready for the view. + */ + private function fetchFuzzy( + Request $request, + Builder $query, + Stringable $familyName, + Collection $neighbourCentreIds, + bool $descending, + ): LengthAwarePaginator { + $rankedFamilyIds = collect( + Searchy::search('carers') + ->fields('name') + ->query((string)$familyName) + ->get() + )->pluck('family_id')->toArray(); + + $permittedFamilyIds = Registration::query() + ->whereIn('family_id', $rankedFamilyIds) + ->whereIn('centre_id', $neighbourCentreIds) + ->pluck('family_id') + ->toArray(); + + $familyIds = collect($rankedFamilyIds) + ->filter(function (int $id) use ($permittedFamilyIds) { + return in_array($id, $permittedFamilyIds, strict: true); + }) ->values() - ->sortBy('family.pri_carer', SORT_NATURAL, $request->get('direction') === 'desc'); + ->toArray(); + + $positionMap = array_flip($familyIds); + + $all = $query + ->whereIn('registrations.family_id', $familyIds) + ->WithFullFamily() + ->get() + ->sortBy( + function ($reg) use ($positionMap) { + return $positionMap[$reg->family_id] ?? PHP_INT_MAX; + }, + SORT_REGULAR, + $descending, + ) + ->values(); - // throw it into a paginator. $page = LengthAwarePaginator::resolveCurrentPage(); - $perPage = 10; - $offset = ($page * $perPage) - $perPage; - $registrations = new LengthAwarePaginator( - $reg_models->slice($offset, $perPage), - $reg_models->count(), - $perPage, - $page, - [ - 'path' => LengthAwarePaginator::resolveCurrentPath(), - 'query' => array_except($request->query(), 'page'), - ] - ); - $data = array_merge( - $data, - [ - 'registrations' => $registrations, - 'fuzzy' => (bool)$fuzzy, + return new LengthAwarePaginator( + items: $all->forPage($page, 10), + total: $all->count(), + perPage: 10, + currentPage: $page, + options: [ + 'path' => LengthAwarePaginator::resolveCurrentPath(), + 'query' => $request->except('page'), ] ); - return view('store.index_registration', $data); } /** @@ -171,11 +194,55 @@ public function create(): View|Factory|Application "sponsorsRequiresID" => $sponsorsRequiresID, "programme" => $user->centre->sponsor->programme, 'leaver' => false, - 'children' => $children ?? [] + 'children' => $children ?? [], ]; return view('store.create_registration', $data); } + /** + * Makes children from input data + * @param array $children + * @return array + */ + private function makeChildrenFromInput(array $children = []): array + { + return Arr::map( + $children, + static function ($child): Child { + // Note: Carbon uses different time formats than laravel validation + // For crazy reasons known only to the creators of Carbon, when no day provided, + // createFromFormat - defaults to 31 - which bumps to next month if not a real day. + // So we want '2013-02-01' not '2013-02-31'... + $month_of_birth = Carbon::createFromFormat('Y-m-d', $child['dob'] . '-01'); + + // Check and set verified, or null + $verified = null; + if (array_key_exists('verified', $child)) { + $verified = (bool)$child['verified']; + } + + // Check and set deferred, or null + $deferred = 0; + if (array_key_exists('deferred', $child)) { + $deferred = (bool)$child['deferred']; + } + + // Check and set is_pri_carer, or null + $is_pri_carer = null; + if (array_key_exists('is_pri_carer', $child)) { + $is_pri_carer = (bool)$child['is_pri_carer']; + } + return new Child([ + 'born' => $month_of_birth->isPast(), + 'dob' => $month_of_birth->toDateTimeString(), + 'verified' => $verified, + 'deferred' => $deferred, + 'is_pri_carer' => $is_pri_carer, + ]); + } + ); + } + /** * Show the Registration / Family edit form * @@ -190,7 +257,7 @@ public function edit(Registration $registration): View|Factory|Application $data = [ 'user_name' => $user->name, 'centre_name' => $user->centre?->name, - 'programme' => $user->centre?->sponsor->programme + 'programme' => $user->centre?->sponsor->programme, ]; // Get the registration, with deep eager-loaded Family (with Children and Carers) @@ -203,7 +270,7 @@ public function edit(Registration $registration): View|Factory|Application /** @var Valuation $valuation */ $valuation = $registration->getValuation(); - // Grab carers copy for shift)ing without altering family->carers + // Grab carers copy for shifting without altering family->carers $carers = $registration->family->carers->all(); $pri_carer = array_shift($carers); $pri_carer_ethnicity = $pri_carer->ethnicity; @@ -388,52 +455,43 @@ public function printBatchIndividualFamilyForms() /** * Stores an incoming Registration. - * - * @param StoreNewRegistrationRequest $request - * @return RedirectResponse - * @throws Throwable $e */ public function store(StoreNewRegistrationRequest $request): RedirectResponse { - // Create Carers, merge primary carer - $carers = array_map( - static function ($carer) use ($request) { + $data = $request->validated(); + + $carers = array_merge( + array_map(static function (string $name) use ($data): Carer { return new Carer([ - 'name' => $carer, - 'ethnicity' => $request->get("pri_carer_ethnicity"), - 'language' => $request->get("pri_carer_language") + 'name' => $name, + 'ethnicity' => $data['pri_carer_ethnicity'] ?? null, + 'language' => $data['pri_carer_language'] ?? null, + 'telnosecret' => $data['pri_carer_telno'] ?? null, + 'emailsecret' => $data['pri_carer_email'] ?? null, ]); - }, - array_merge( - (array)$request->get('pri_carer'), - (array)$request->get('new_carers') + }, (array)($data['pri_carer'] ?? [])), + array_map( + static function (string $name): Carer { + return new Carer(['name' => $name]); + }, + (array)($data['new_carers'] ?? []) ) ); - // Create Children - $children = $this->makeChildrenFromInput( - (array)$request->get('children') - ); + $children = $this->makeChildrenFromInput((array)($data['children'] ?? [])); - // Create Registration $registration = new Registration([ 'consented_on' => Carbon::now(), - 'eligibility_hsbs' => $request->get('eligibility-hsbs'), - 'eligibility_nrpf' => $request->get('eligibility-nrpf'), - 'eligible_from' => ($request->get('eligibility-hsbs') === 'healthy-start-receiving') - ? Carbon::now() - : null, + 'eligibility_hsbs' => $data['eligibility-hsbs'] ?? null, + 'eligibility_nrpf' => $data['eligibility-nrpf'] ?? null, + 'eligible_from' => ($data['eligibility-hsbs'] ?? null) === 'healthy-start-receiving' ? Carbon::now() : null, ]); - // Duplicate families are fine at this point. $family = new Family(); - - // Set the RVID using the User's Centre. $family->lockToCentre(Auth::user()->centre); - // Try to transact, so we can roll it back try { - DB::transaction(static function () use ($registration, $family, $carers, $children) { + DB::transaction(callback: static function () use ($registration, $family, $carers, $children): void { $family->save(); $family->carers()->saveMany($carers); $family->children()->saveMany($children); @@ -441,255 +499,139 @@ static function ($carer) use ($request) { $registration->centre()->associate(Auth::user()->centre); $registration->save(); }); - } catch (Exception $e) { - // Oops! Log that - Log::error('Bad transaction for ' . __CLASS__ . '@' . __METHOD__ . ' by service user ' . Auth::id()); + } catch (Throwable $e) { + Log::error(sprintf('Bad transaction for %s@%s by service user %s', __CLASS__, __METHOD__, Auth::id())); Log::error($e->getTraceAsString()); - // Throw it back to the user + return redirect()->route('store.registration.create')->withErrors('Registration failed.'); } - // Or return the success - Log::info('Registration ' . $registration->id . ' created by service user ' . Auth::id()); - // and go to the edit page for the new registration - return redirect() - ->route('store.registration.edit', ['registration' => $registration->id]) - ->with('message', 'Registration created.'); - } - /** - * Makes children from input data - * @param array $children - * @return array - */ - private function makeChildrenFromInput(array $children = []): array - { - return Arr::map( - $children, - static function ($child) { - // Note: Carbon uses different time formats than laravel validation - // For crazy reasons known only to the creators of Carbon, when no day provided, - // createFromFormat - defaults to 31 - which bumps to next month if not a real day. - // So we want '2013-02-01' not '2013-02-31'... - $month_of_birth = Carbon::createFromFormat('Y-m-d', $child['dob'] . '-01'); + Log::info(sprintf('Registration %s created by service user %s', $registration->id, Auth::id())); - // Check and set verified, or null - $verified = null; - if (array_key_exists('verified', $child)) { - $verified = (bool)$child['verified']; - } - - // Check and set deferred, or null - $deferred = 0; - if (array_key_exists('deferred', $child)) { - $deferred = (bool)$child['deferred']; - } - - // Check and set is_pri_carer, or null - $is_pri_carer = null; - if (array_key_exists('is_pri_carer', $child)) { - $is_pri_carer = (bool)$child['is_pri_carer']; - } - - return new Child([ - 'born' => $month_of_birth->isPast(), - 'dob' => $month_of_birth->toDateTimeString(), - 'verified' => $verified, - 'deferred' => $deferred, - 'is_pri_carer' => $is_pri_carer, - ]); - } - ); + return redirect() + ->route('store.registration.edit', $registration) + ->with('message', 'Registration created.'); } /** * Update a Registration - * - * @param StoreUpdateRegistrationRequest $request - * @param Registration $registration - * @return RedirectResponse */ public function update(StoreUpdateRegistrationRequest $request, Registration $registration): RedirectResponse { + $data = $request->validated(); $amendedCarers = []; - // Fetch eligibility - $eligibility_hsbs = $request->get('eligibility-hsbs'); - $eligibility_nrpf = $request->get('eligibility-nrpf'); - $deferred = $request->get('deferred'); - - //Prevent the date changing if you're just editing a different field - $eligible_from = ($eligibility_hsbs === 'healthy-start-receiving' && !$registration->eligible_from) - ? Carbon::now() - : null; - - // NOTE: Following refactor where we needed to retain Carer ids. - // Possible that we might want to add flag to carer to distinguish Main from Secondary, - // to simplify method below for sorting and updating carer entries. - - // Update primary carer. - $carerInput = (array)$request->get("pri_carer"); - $carerEthnicity = $request->get("pri_carer_ethnicity"); - $carerLanguage = $request->get('pri_carer_language'); - $carerKey = key($carerInput); - $carer = Carer::find($carerKey); + // Primary carer + $priCarerInput = (array)($data['pri_carer'] ?? []); + $priCarerId = (int)array_key_first($priCarerInput); + $priCarer = Carer::findOrFail($priCarerId); - if ($carer->name !== $carerInput[$carer->id]) { - $carer->name = $carerInput[$carer->id]; - $amendedCarers[] = $carer; + if ($priCarer->name !== $priCarerInput[$priCarerId]) { + $priCarer->name = $priCarerInput[$priCarerId]; + $amendedCarers[] = $priCarer; } - if ($carerEthnicity !== null && $carerEthnicity !== $carerEthnicity[$carer->id]) { - $carer->ethnicity = $carerEthnicity[$carer->id]; - $amendedCarers[] = $carer; + + $priEthnicity = $data['pri_carer_ethnicity'][$priCarerId] ?? null; + if ($priEthnicity !== null && $priCarer->ethnicity !== $priEthnicity) { + $priCarer->ethnicity = $priEthnicity; + $amendedCarers[] = $priCarer; } - if ($carerLanguage !== null && $carerLanguage !== $carerLanguage[$carer->id]) { - $carer->language = $carerLanguage[$carer->id]; - $amendedCarers[] = $carer; + + $priLanguage = $data['pri_carer_language'][$priCarerId] ?? null; + if ($priLanguage !== null && $priCarer->language !== $priLanguage) { + $priCarer->language = $priLanguage; + $amendedCarers[] = $priCarer; } - // Find secondary carers id's in the DB - $carersInput = (array)$request->get("sec_carers"); - $carersKeys = $registration->family->carers->pluck("id")->toArray(); - // remove carerKey from that; - if (($key = array_search($carerKey, $carersKeys)) !== false) { - unset($carersKeys[$key]); + // emailsecret and telnosecret are special + $priEmail = $data['pri_carer_email'][$priCarerId] ?? null; + if ($priEmail !== null && $priCarer->emailsecret->reveal() !== $priEmail) { + $priCarer->emailsecret = $priEmail; + $amendedCarers[] = $priCarer; } - // Those in the DB, not in the input can be scheduled for deletion; - $carersInputKeys = array_keys($carersInput); - $carersKeysToDelete = array_diff($carersKeys, $carersInputKeys); + $priTelno = $data['pri_carer_telno'][$priCarerId] ?? null; + if ($priTelno !== null && $priCarer->telnosecret->reveal() !== $priTelno) { + $priCarer->telnosecret = $priTelno; + $amendedCarers[] = $priCarer; + } - // Get the secondary carers. - $carers = Carer::whereIn("id", $carersInputKeys)->get(); + // Secondary carers — diff DB state against input to find stale IDs + $secCarersInput = (array)($data['sec_carers'] ?? []); + $staleCarerIds = $registration->family->carers + ->pluck('id') + ->reject(function (int $id) use ($priCarerId): bool { + return $id === $priCarerId; + }) + ->diff(array_keys($secCarersInput)) + ->values() + ->all(); - // roll though those and amend them if necessary. - foreach ($carers as $carer) { - if ($carer->name !== $carersInput[$carer->id]) { - $carer->name = $carersInput[$carer->id]; + foreach (Carer::whereIn('id', array_keys($secCarersInput))->get() as $carer) { + if ($carer->name !== $secCarersInput[$carer->id]) { + $carer->name = $secCarersInput[$carer->id]; $amendedCarers[] = $carer; } } - // Create new carers + // New carers and children $newCarers = array_map( - static function ($new_carer) { - return new Carer(['name' => $new_carer]); + static function (string $name): Carer { + return new Carer(['name' => $name]); }, - (array)$request->get('new_carers') + (array)($data['new_carers'] ?? []) ); - // Create New Children - $children = $this->makeChildrenFromInput( - (array)$request->get('children') - ); + $children = $this->makeChildrenFromInput((array)($data['children'] ?? [])); - $family = $registration->family; + $eligibleFrom = ( + ($data['eligibility-hsbs'] ?? null) === 'healthy-start-receiving' && + !$registration->eligible_from + ) + ? Carbon::now() + : null; - // Try to transact, so we can roll it back try { DB::transaction(static function () use ( $registration, - $family, $amendedCarers, $newCarers, - $carersKeysToDelete, + $staleCarerIds, $children, - $eligibility_hsbs, - $eligibility_nrpf, - $eligible_from - ) { - - // delete the missing carers - Carer::whereIn('id', $carersKeysToDelete)->get()->each(function ($carer) { - $carer->delete(); - }); - - // delete the children. still messy. - $family->children()->delete(); + $data, + $eligibleFrom, + ): void { + $family = $registration->family; - // save the new ones! + Carer::whereIn('id', $staleCarerIds)->delete(); $family->carers()->saveMany($newCarers); - $family->children()->saveMany($children); - // save changes to the changed names - collect($amendedCarers)->each( - function (Carer $model) { - $model->save(); - } - ); + $family->children()->delete(); + $family->children()->saveMany($children); - // update eligibility - $registration->eligibility_hsbs = $eligibility_hsbs; - $registration->eligibility_nrpf = $eligibility_nrpf; - $registration->eligible_from = $eligible_from; + collect($amendedCarers)->unique()->each(function (Carer $carer) { + return $carer->save(); + }); - // save changes to registration. - $registration->save(); + $registration->fill([ + 'eligibility_hsbs' => $data['eligibility-hsbs'] ?? null, + 'eligibility_nrpf' => $data['eligibility-nrpf'] ?? null, + 'eligible_from' => $eligibleFrom, + ])->save(); }); } catch (Throwable $e) { - // Oops! Log that - Log::error('Bad transaction for ' . __CLASS__ . '@' . __METHOD__ . ' by service user ' . Auth::id()); + Log::error(sprintf('Bad transaction for %s@%s by service user %s', __CLASS__, __METHOD__, Auth::id())); Log::error($e->getTraceAsString()); - // Throw it back to the user - return redirect()->route('store.registration.edit', ['registration' => $registration->id]) - ->withErrors('Registration update failed.'); - } - // Or return the success - Log::info('Registration ' . $registration->id . ' updated by service user ' . Auth::id()); - // and go back to edit page for the changed registration - return redirect() - ->route('store.registration.edit', ['registration' => $registration->id]) - ->with('message', 'Registration updated.'); - } - private function exactSearch($family_name, $pri_carers): array - { - $carers = Carer::query() - ->where('name', 'LIKE', "%$family_name%") - ->whereIn('id', $pri_carers) - ->get(); - - $startsWithExact = []; - $wholeWord = []; - $theRest = []; - - foreach ($carers as $carer) { - $names = array_map('strtolower', explode(" ", $carer->name)); - - if (count($names) !== 0) { - if (strtolower($names[0]) === strtolower($family_name)) { - $startsWithExact[] = $carer->family_id; - } elseif (in_array($family_name, $names)) { - $wholeWord[] = $carer->family_id; - } else { - $theRest[] = $carer->family_id; - } - } + return redirect() + ->route('store.registration.edit', $registration) + ->withErrors('Registration update failed.'); } - return array_merge($startsWithExact, $wholeWord, $theRest); - } + Log::info(sprintf('Registration %s updated by service user %s', $registration->id, Auth::id())); - private function fuzzySearch($family_name, $pri_carers): array - { - // Get the current database driver - $connection = config('database.default'); - $driver = config("database.connections.$connection.driver"); - - if ($driver === 'mysql') { - // We can use Searchy for mysql; defaults to "fuzzy" search; - // results are a collection of basic objects, but we can still "pluck()" - $filtered_family_ids = Searchy::search('carers') - ->fields('name') - ->query($family_name) - ->getQuery() - ->whereIn('id', $pri_carers) - ->pluck('family_id') - ->toArray(); - } else { - // We may not be able to use Searchy, so we default to unfuzzy. - $filtered_family_ids = $this->exactSearch($family_name, $pri_carers); - } - - return $filtered_family_ids; + return redirect() + ->route('store.registration.edit', $registration) + ->with('message', 'Registration updated.'); } } diff --git a/app/Http/Controllers/Store/SessionController.php b/app/Http/Controllers/Store/SessionController.php index 2d244ddc4..62839c763 100644 --- a/app/Http/Controllers/Store/SessionController.php +++ b/app/Http/Controllers/Store/SessionController.php @@ -12,7 +12,13 @@ public function update(StoreUpdateSessionRequest $request): RedirectResponse { // Set session session(['CentreUserCurrentCentreId' => $request->input('centre')]); - // redirect to a specific place - return redirect()->route('store.registration.index'); + + $previous = url()->previous(); + // if previous is outside the site, route somewhere safe + if (! str_starts_with($previous, url('/'))) { + $previous = route('dashboard'); + } + + return redirect()->to($previous); } } diff --git a/app/Http/Requests/AdminNewCentreRequest.php b/app/Http/Requests/AdminNewCentreRequest.php index c71743cb9..33c5d0e3d 100644 --- a/app/Http/Requests/AdminNewCentreRequest.php +++ b/app/Http/Requests/AdminNewCentreRequest.php @@ -4,7 +4,6 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -use App\Rules\NotExistsRule; class AdminNewCentreRequest extends FormRequest { @@ -13,49 +12,68 @@ class AdminNewCentreRequest extends FormRequest * * @return bool */ - public function authorize() + public function authorize(): bool { // Covered by Admin user auth return true; } /** - * Get the validation rules that apply to the request. - * - * @return array + * Normalise input before validation runs. + */ + public function prepareForValidation(): void + { + $this->merge([ + 'can_collect' => $this->boolean('can_collect'), + 'prefix' => strtoupper((string)$this->input('prefix', '')), + ]); + } + + /** + * Validation rules */ - public function rules() + public function rules(): array { return [ - // MUST be present, string - 'name' => 'required|string', - // MUST be present, integer, in table - 'sponsor' => 'required|integer|exists:sponsors,id', - // MUST be present, string, 1-5 characters and not in use - 'rvid_prefix' => [ + 'name' => ['required', 'string', 'unique:centres,name'], + 'sponsor_id' => ['required', 'exists:sponsors,id'], + 'prefix' => [ 'required', 'string', 'between:1,5', - new NotExistsRule('centres', 'prefix'), + 'unique:centres,prefix', ], - // MUST be present, in print_prefs 'print_pref' => [ 'required', - Rule::in(config('arc.print_preferences')) - ] + Rule::in(config('arc.print_preferences')), + ], + 'can_collect' => ['nullable', 'boolean'] + ]; + } + + /** + * Human-readable attribute names used in error messages. + */ + public function attributes(): array + { + return [ + 'sponsor_id' => 'sponsor', + 'prefix' => 'RVID prefix', + 'print_pref' => 'print preference', + 'can-collect' => 'collection', ]; } /** - * Prep input for validation + * Custom error messages. */ - public function prepareForValidation() + public function messages(): array { - if ($this->has('rvid_prefix')) { - $this->merge( - // In this system, we're want it uppercase - ['rvid_prefix' => strtoupper($this->input('rvid_prefix'))] - ); - } + return [ + 'name.unique' => 'That name is already in use.', + 'prefix.unique' => 'That RVID prefix is already in use.', + 'prefix.between' => 'The RVID prefix must be between 1 and 5 characters.', + 'print_pref.in' => 'The selected print preference is not valid.', + ]; } } diff --git a/app/Http/Requests/AdminUpdateCentreRequest.php b/app/Http/Requests/AdminUpdateCentreRequest.php index 4a7a64055..ae10fa81c 100644 --- a/app/Http/Requests/AdminUpdateCentreRequest.php +++ b/app/Http/Requests/AdminUpdateCentreRequest.php @@ -3,29 +3,79 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class AdminUpdateCentreRequest extends FormRequest { /** * Determine if the user is authorized to make this request. - * - * @return bool */ - public function authorize() + public function authorize(): bool { return true; } /** - * Get the validation rules that apply to the request. - * - * @return array + * Normalise input before validation runs. */ - public function rules() + public function prepareForValidation(): void { - return [ - 'id' => 'required|integer|exists:centres,id', - 'name' => 'required|string', - ]; + $this->merge([ + 'can_collect' => $this->boolean('can_collect'), + 'prefix' => strtoupper((string)$this->input('prefix', '')), + ]); + } + + /** + * Validation rules + */ + public function rules(): array + { + $centreId = $this->route('centre')?->id; + return [ + 'name' => [ + 'required', + 'string', + Rule::unique('centres', 'name')->ignore($centreId), + ], + 'sponsor_id' => ['required', 'exists:sponsors,id'], + 'prefix' => [ + 'required', + 'string', + 'between:1,5', + Rule::unique('centres', 'prefix')->ignore($centreId), + ], + 'print_pref' => [ + 'required', + Rule::in(config('arc.print_preferences')), + ], + 'can_collect' => ['nullable', 'boolean'] + ]; + } + + /** + * Human-readable attribute names used in error messages. + */ + public function attributes(): array + { + return [ + 'sponsor_id' => 'sponsor', + 'prefix' => 'RVID prefix', + 'print_pref' => 'print preference', + 'can-collect' => 'collection', + ]; + } + + /** + * Custom error messages. + */ + public function messages(): array + { + return [ + 'name.unique' => 'That name is already in use.', + 'prefix.unique' => 'That RVID prefix is already in use.', + 'prefix.between' => 'The RVID prefix must be between 1 and 5 characters.', + 'print_pref.in' => 'The selected print preference is not valid.', + ]; } } diff --git a/app/Http/Requests/StoreAppendBundleRequest.php b/app/Http/Requests/StoreAppendBundleRequest.php index 3d45a7217..d238be953 100644 --- a/app/Http/Requests/StoreAppendBundleRequest.php +++ b/app/Http/Requests/StoreAppendBundleRequest.php @@ -8,11 +8,9 @@ class StoreAppendBundleRequest extends FormRequest { /** - * Determine if the user is authorized to make this request. - * - * @return bool + * Determine if the user is authorized to make this request */ - public function authorize() + public function authorize(): true { // TODO : determine of existing registration route protection is sufficient. return true; @@ -20,38 +18,51 @@ public function authorize() /** * Get the validation rules that apply to the request. - * - * @return array */ - public function rules() + public function rules(): array { /* * These rules validate that the form data is well-formed. * It is NOT responsible for the context validation of that data. */ - $rules = [ - // MUST be present, not null and string - 'start' => 'required|string|exists:vouchers,code', - // MAY be present, nullable, string, code exists, is GT start and same sponsor as start - 'end' => 'nullable|string|exists:vouchers,code|codeGreaterThan:start|sameSponsor:start', + return [ + 'voucher-quantity' => [ + 'nullable', + 'integer', + 'between:1,' . config('arc.bundle_max_voucher_append'), + 'required_without:start', + ], + 'start' => [ + 'exclude_if:voucher-quantity,present', + 'required_without:voucher-quantity', + 'string', + 'exists:vouchers,code', + ], + 'end' => [ + 'exclude_if:voucher-quantity,present', + 'nullable', + 'string', + 'exists:vouchers,code', + 'codeGreaterThan:start', + 'sameSponsor:start', + ], ]; - - return $rules; } - protected function prepareForValidation() + protected function prepareForValidation(): void { - // get the input and remove null/empty values. - $input = array_filter( - $this->all(['start', 'end']), - 'strlen' - ); + if ($this->filled('voucher-quantity')) { + $this->replace(['voucher-quantity' => (int)$this->input('voucher-quantity')]); + return; + } + + $input = array_filter($this->all(['start', 'end']), 'strlen'); foreach ($input as $key => $value) { $clean = Voucher::cleanCodes((array)$value); - $input[$key] = strtoupper((array_shift($clean))); + $input[$key] = strtoupper(array_shift($clean)); } - // replace old input with new input + $this->replace($input); } -} \ No newline at end of file +} diff --git a/app/Http/Requests/StoreNewRegistrationRequest.php b/app/Http/Requests/StoreNewRegistrationRequest.php index be28076e0..396ecee6e 100644 --- a/app/Http/Requests/StoreNewRegistrationRequest.php +++ b/app/Http/Requests/StoreNewRegistrationRequest.php @@ -29,24 +29,28 @@ public function rules() * These rules validate that the form data is well-formed. * It is NOT responsible for the context validation of that data. */ - $rules = [ + return [ // MUST be present; MUST be in "yes, on, 1, or true" 'consent' => 'required|accepted', - // SOMETIMES is present; MUST be in listed states - 'eligibility-hsbs' => [ - 'sometimes', - 'required', - Rule::in(config('arc.reg_eligibilities_hsbs')), - ], - 'eligibility-nrpf' => [ - 'sometimes', - 'required', - Rule::in(config('arc.reg_eligibilities_nrpf')), - ], // MUST be present; MUST be a not-null string 'pri_carer' => 'required|string', - // MAY be present; MUST be a not-null string + 'pri_carer_ethnicity' => [ + Rule::in(array_merge( + [0, '0'], + array_keys(config('arc.ethnicity_desc')) + )), + ], + 'pri_carer_language' => [ + 'nullable', + 'not-regex:/^.*[\p{C}].*$/u', + 'regex:/^[A-Za-z.\s\'—-]+$/', + ], + // May be nullable, MUST be a standard + 'pri_carer_email' => 'nullable|email:rfc', + 'pri_carer_telno' => 'nullable|phone:GB', + // MAY be present, Min 1 'new_carers' => 'array|min:1', + // MAY be present, fit regexes 'new_carers.*' => [ 'not-regex:/^.*[\p{C}].*$/u', 'regex:/^[A-Za-z.\s\'—-]+$/', @@ -57,9 +61,30 @@ public function rules() 'children.*.dob' => 'required_if:children.*.verified,=,true|date_format:Y-m', // MAY be present; MUST be a boolean 'children.*.verified' => 'boolean', - 'is_pri_carer' => 'boolean' + 'children.*.deferred' => 'boolean', + 'children.*.is_pri_carer' => 'boolean', + // SOMETIMES is present (SP doesn't have them) MUST be in listed states + 'eligibility-hsbs' => [ + 'sometimes', + 'required', + Rule::in(config('arc.reg_eligibilities_hsbs')), + ], + 'eligibility-nrpf' => [ + 'sometimes', + 'required', + Rule::in(config('arc.reg_eligibilities_nrpf')), + ], ]; + } - return $rules; + /** + * Get custom attribute names for validator error messages. + */ + public function attributes(): array + { + return [ + 'pri_carer_email' => 'email', + 'pri_carer_telno' => 'telephone number', + ]; } } diff --git a/app/Http/Requests/StorePickupBundleRequest.php b/app/Http/Requests/StorePickupBundleRequest.php new file mode 100644 index 000000000..7229c7fb8 --- /dev/null +++ b/app/Http/Requests/StorePickupBundleRequest.php @@ -0,0 +1,34 @@ + 'required|date_format:Y-m-d', + 'collected_at' => 'required|integer|exists:centres,id', + 'collected_by' => 'required|integer|exists:carers,id' + ]; + } +} diff --git a/app/Http/Requests/StoreTransitionBundleRequest.php b/app/Http/Requests/StoreTransitionBundleRequest.php new file mode 100644 index 000000000..327ba68cb --- /dev/null +++ b/app/Http/Requests/StoreTransitionBundleRequest.php @@ -0,0 +1,35 @@ + 'required|date_format:Y-m-d', + 'collected_at' => 'required|integer|exists:centres,id', + 'collected_by' => 'required|exists:carers,id', + 'trader_id' => 'required|integer|exists:traders,id' + ]; + } +} diff --git a/app/Http/Requests/StoreUpdateBundleRequest.php b/app/Http/Requests/StoreUpdateBundleRequest.php deleted file mode 100644 index 0dd13e4b5..000000000 --- a/app/Http/Requests/StoreUpdateBundleRequest.php +++ /dev/null @@ -1,43 +0,0 @@ - 'nullable|distinct|string', - // Mutually dependent - 'collected_on' => 'required_with_all:collected_at,collected_by|date_format:Y-m-d', - 'collected_at' => 'integer|required_with_all:collected_on,collected_by|exists:centres,id', - 'collected_by' => 'integer|required_with_all:collected_at,collected_on|exists:carers,id' - ]; - - return $rules; - } -} diff --git a/app/Http/Requests/StoreUpdateRegistrationRequest.php b/app/Http/Requests/StoreUpdateRegistrationRequest.php index 1e97f1a61..b17068e95 100644 --- a/app/Http/Requests/StoreUpdateRegistrationRequest.php +++ b/app/Http/Requests/StoreUpdateRegistrationRequest.php @@ -32,15 +32,28 @@ public function rules() * These rules validate that the form data is well-formed. * It is NOT responsible for the context validation of that data. */ - $rules = [ + return [ // MUST be present, array, 1 member 'pri_carer' => "required|array|min:1|max:1", // Element MUST be present; MUST be a not-null string 'pri_carer.*' => 'required|string', + // May be nullable, MUST be a standard + 'pri_carer_email.*' => 'nullable|email:rfc', + 'pri_carer_telno.*' => 'nullable|phone:GB', + 'pri_carer_ethnicity.*' => [ + Rule::in(array_merge( + [0, '0'], + array_keys(config('arc.ethnicity_desc')) + )) + ], + 'pri_carer_language.*' => [ + 'nullable', + 'not-regex:/^.*[\p{C}].*$/u', + 'regex:/^[A-Za-z.\s\'—-]+$/', + ], // MAY be present; MUST be a not-null string 'sec_carers' => 'array|min:1', 'sec_carers.*' => 'string', - // MAY be present; MUST be a not-null string 'new_carers' => 'array|min:1', 'new_carers.*' => [ 'not-regex:/^.*[\p{C}].*$/u', @@ -52,7 +65,9 @@ public function rules() 'children.*.dob' => 'required_if:children.*.verified,=,true|date_format:Y-m', // MAY be present; MUST be a boolean 'children.*.verified' => 'boolean', - // SOMETIMES is present; MUST be in listed states + 'children.*.deferred' => 'boolean', + 'children.*.is_pri_carer' => 'boolean', + // SOMETIMES is present (SP doesn't have them) MUST be in listed states 'eligibility-hsbs' => [ 'sometimes', Rule::in(config('arc.reg_eligibilities_hsbs')), @@ -62,7 +77,16 @@ public function rules() Rule::in(config('arc.reg_eligibilities_nrpf')), ], ]; + } - return $rules; + /** + * Get custom attribute names for validator error messages. + */ + public function attributes(): array + { + return [ + 'pri_carer_email' => 'email', + 'pri_carer_telno' => 'telephone number', + ]; } } diff --git a/app/Jobs/ProcessTransitionJob.php b/app/Jobs/ProcessTransitionJob.php index 8cc5872aa..6726e016d 100644 --- a/app/Jobs/ProcessTransitionJob.php +++ b/app/Jobs/ProcessTransitionJob.php @@ -2,25 +2,30 @@ namespace App\Jobs; -use App\Services\TransitionProcessor; +use App\Services\TransitionProcessor\TransitionProcessor; use App\Trader; use App\User; +use App\Voucher; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Http\JsonResponse; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use Imtigger\LaravelJobStatus\JobStatus; use Imtigger\LaravelJobStatus\Trackable; -use Illuminate\Support\Facades\Auth; use Log; class ProcessTransitionJob implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Trackable; + use Dispatchable; + use InteractsWithQueue; + use Queueable; + use SerializesModels; + use Trackable; private Trader $trader; private array $voucherCodes; @@ -35,8 +40,6 @@ class ProcessTransitionJob implements ShouldQueue /** * Create a new job instance. - * - * @return void */ public function __construct(Trader $trader, array $voucherCodes, string $transition, int $runAsId) { @@ -48,13 +51,10 @@ public function __construct(Trader $trader, array $voucherCodes, string $transit } /** - * Sends the user to an url where they can monitor the job - * @param JobStatus $jobStatus - * @return JsonResponse + * Sends the user to a URL where they can monitor the job. */ public static function monitor(JobStatus $jobStatus): JsonResponse { - // this is the body data; needs to tell the client what to do $data = array_merge( [ 'location' => route('api.queued-task.show', ['jobStatus' => $jobStatus->id]), @@ -67,13 +67,8 @@ public static function monitor(JobStatus $jobStatus): JsonResponse return response()->json($data, 202); } - /** - * @param JobStatus $jobStatus - * @return JsonResponse - */ private static function pollingResponse(JobStatus $jobStatus): JsonResponse { - // tell the client where to look. $data = array_merge( [ 'location' => route('api.queued-task.show', ['jobStatus' => $jobStatus->id]), @@ -85,38 +80,22 @@ private static function pollingResponse(JobStatus $jobStatus): JsonResponse return response()->json($data); } - /** - * @param JobStatus $jobStatus - * @return JsonResponse - */ public static function queuedHandler(JobStatus $jobStatus): JsonResponse { return self::pollingResponse($jobStatus); } - /** - * @param JobStatus $jobStatus - * @return JsonResponse - */ public static function executingHandler(JobStatus $jobStatus): JsonResponse { return self::pollingResponse($jobStatus); } - /** - * @param JobStatus $jobStatus - * @return JsonResponse - */ public static function retryingHandler(JobStatus $jobStatus): JsonResponse { // this probably won't happen, but for safety's sake we'll catch it. return self::pollingResponse($jobStatus); } - /** - * @param JobStatus $jobStatus - * @return JsonResponse - */ public static function finishedHandler(JobStatus $jobStatus): JsonResponse { // we're done! should be `303 Other` the user to somewhere they can pick up their data. @@ -131,10 +110,6 @@ public static function finishedHandler(JobStatus $jobStatus): JsonResponse ]); } - /** - * @param JobStatus $jobStatus - * @return JsonResponse - */ public static function failedHandler(JobStatus $jobStatus): JsonResponse { // TODO think of a better failed handler @@ -144,34 +119,40 @@ public static function failedHandler(JobStatus $jobStatus): JsonResponse /** * Execute the job. * - * @return void + * $voucherCodes is kept as an array on the job property because a Builder + * cannot be serialised to the queue. The Builder is constructed here inside + * handle(), matching the controller pattern exactly. */ public function handle(): void { Auth::logout(); // Login if we're not - if (!Auth::check()) { - Auth::login(User::find($this->runAsId)); - Log::info("This session logged in [" . Auth::user()->id . "]"); + if (Auth::check()) { + $loginMessage = "This session already has a user [%s]"; } else { - Log::info("This session already has a user [" . Auth::user()->id . "]"); - } + Auth::login(User::find($this->runAsId)); + $loginMessage = "This session logged in [%s]"; + } + $id = Auth::user()->id; + Log::info(sprintf($loginMessage, $id)); - if (Auth::user()->id === $this->runAsId) { + if ($id === $this->runAsId) { + $query = Voucher::whereIn('code', $this->voucherCodes); + $foundCodes = $query->pluck('code')->all(); + $invalidCodes = array_values(array_diff($this->voucherCodes, $foundCodes)); $processor = new TransitionProcessor($this->trader, $this->transition); - $processor->handle($this->voucherCodes); - - $responseData = $processor->constructResponseMessage(); + $response = $processor->handle($query); + $response->addInvalid($invalidCodes); $key = Str::uuid(); - Cache::put($key, $responseData); + Cache::put($key, $response->constructResponseMessage()); $this->setOutput(['key' => $key]); Auth::logout(); - } else { - Log::error("Incorrect user [" . Auth::user()->id . "] for transition job expecting [" . $this->runAsId . "]"); + } else { + Log::error(sprintf("Incorrect user [%s] for transition job expecting [%d]", $id, $this->runAsId)); } } } diff --git a/app/Jobs/ResetDemoEnvironment.php b/app/Jobs/ResetDemoEnvironment.php new file mode 100644 index 000000000..3f82a38fc --- /dev/null +++ b/app/Jobs/ResetDemoEnvironment.php @@ -0,0 +1,35 @@ + true, '--force' => true]); + + $client = $clients->createPasswordGrantClient( + userId: null, + name: 'Rose Vouchers Password Grant Client', + redirect: '', + provider: 'users', + ); + + $envWriter->updateKey('PASSWORD_CLIENT_SECRET', $client->plainSecret); + } +} + diff --git a/app/Listeners/CentreUserAuthenticated.php b/app/Listeners/CentreUserAuthenticated.php index f4cab287e..1ece875e8 100644 --- a/app/Listeners/CentreUserAuthenticated.php +++ b/app/Listeners/CentreUserAuthenticated.php @@ -2,9 +2,8 @@ namespace App\Listeners; -use App\CentreUser; -use Config; use Illuminate\Auth\Events\Authenticated; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Session; class CentreUserAuthenticated @@ -20,12 +19,14 @@ public function handle(Authenticated $event): void } // a fresh login won't have this key if (Session::missing('CentreUserCurrentCentreId')) { - // set the session to the centre default or all the centres they're allowed - $default = Config::get('arc.default_to_home_centre') - ? $event->user->homeCentre?->id - : 'all'; + $id = $event->user->homeCentre?->id + ?? $event->user->centres()->first()?->id; - Session::put('CentreUserCurrentCentreId', $default); + if (!$event->user->homeCentre?->id) { + Log::warning("Centre User {$event->user->id} does not have a home centre"); + } + + Session::put('CentreUserCurrentCentreId', $id); } } } diff --git a/app/Listeners/StateHistoryManager.php b/app/Listeners/StateHistoryManager.php index f5178b28a..502dc95f6 100644 --- a/app/Listeners/StateHistoryManager.php +++ b/app/Listeners/StateHistoryManager.php @@ -30,16 +30,17 @@ public function postTransition(TransitionEvent $event) { $sm = $event->getStateMachine(); $model = $sm->getObject(); + $user = auth()->user(); + $user_type = $user ? get_class($user) : null; // We need to collect the user_type (basically the classname) // as we now have several with conflicting ids. // Will permit accurate tidying up late. - $user_type = class_basename(auth()->user()); $model->history()->create([ "transition" => $event->getTransition(), "from" => $event->getState(), // what the state was before. "to" => $sm->getState(), - "user_id" => auth()->id(), // the user ID + "user_id" => $user?->id, // the user ID "user_type" => $user_type, // the type of user (we now have many) "source" => "", ]); diff --git a/app/Market.php b/app/Market.php index 8cd9c1e77..90d417172 100644 --- a/app/Market.php +++ b/app/Market.php @@ -3,6 +3,7 @@ namespace App; use DateTimeInterface; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -28,6 +29,7 @@ class Market extends Model 'name', 'location', 'sponsor_id', + 'centre_id', 'payment_message', ]; @@ -50,44 +52,60 @@ class Market extends Model 'sponsor_shortcode', ]; + /** + * If this is an "internal market" it will have a centre + */ + public function centre(): BelongsTo + { + return $this->belongsTo(Centre::class); + } + + /** * Get the sponsor this market belongs to. - * - * @return BelongsTo */ - public function sponsor() + public function sponsor(): BelongsTo { return $this->belongsTo(Sponsor::class); } /** * Get the traders this market has. - * - * @return HasMany */ - public function traders() + public function traders(): HasMany { return $this->hasMany(Trader::class); } + /** + * Scope to markets that have at least one trader — i.e. are open for business. + */ + public function scopeTrading(Builder $query): Builder + { + return $query->has('traders'); + } + /** * Get the sponsor shortcode. - * - * @return string */ - public function getSponsorShortcodeAttribute() + public function getSponsorShortcodeAttribute(): ?string { - return $this->sponsor->shortcode; + return $this->sponsor?->shortcode; } /** * Prepare a date for array / JSON serialization. - * - * @param \DateTimeInterface $date - * @return string */ - protected function serializeDate(DateTimeInterface $date) + protected function serializeDate(DateTimeInterface $date): string { return $date->format('Y-m-d H:i:s'); } + + /** + * Is it internal? + */ + public function isInternal(): bool + { + return $this->centre_id !== null; + } } diff --git a/app/Observers/CentreObserver.php b/app/Observers/CentreObserver.php new file mode 100644 index 000000000..7d38058eb --- /dev/null +++ b/app/Observers/CentreObserver.php @@ -0,0 +1,35 @@ +marketService = $marketService; + } + + public function created(Centre $centre): void + { + if ($centre->can_collect) { + $this->marketService->ensureTradingMarket($centre); + } + } + + public function updating(Centre $centre): void + { + if (!$centre->isDirty('can_collect')) { + return; + } + + if ($centre->can_collect) { + $this->marketService->ensureTradingMarket($centre); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8344a0f22..a98e9479e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,12 +2,14 @@ namespace App\Providers; -use App\Views\Composers\PaymentsComposer; +use App\CentreUser; +use App\Services\EnvWriter; +use App\View\Composers\PaymentsComposer; use Illuminate\Pagination\Paginator; -use Illuminate\Support\Facades\Blade; -use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\View; +use Illuminate\Support\ServiceProvider; use Laravel\Passport\Passport; class AppServiceProvider extends ServiceProvider @@ -31,20 +33,28 @@ public function boot(): void // Needed because we're still serialising cookies! Passport::withCookieSerialization(); + View::composer('*', PaymentsComposer::class); - // adds "push once" - Blade::directive('pushonce', static function ($expression) { - [$pushName, $pushSub] = explode(':', trim(substr($expression, 1, -1))); + // Gates + Gate::define('take-developer-actions', static function () { + // permit if the application is debugging + return config('app.debug') === true; + }); - $key = '__pushonce_' . str_replace('-', '_', $pushName) . '_' . str_replace('-', '_', $pushSub); + Gate::define('collect-vouchers', static function (CentreUser $centreUser) { + $centreId = session('CentreUserCurrentCentreId'); - return "{$key})): \$__env->{$key} = 1; \$__env->startPush('{$pushName}'); ?>"; - }); - Blade::directive('endpushonce', static function ($expression) { - return 'stopPush(); endif; ?>'; - }); + if (! $centreId) { + return false; + } - View::composer('*', PaymentsComposer::class); + // is this user permitted to work on this can_collect centre? + return $centreUser + ->centres() + ->where('centres.id', $centreId) + ->where('centres.can_collect', true) + ->exists(); + }); } /** @@ -54,6 +64,8 @@ public function boot(): void */ public function register(): void { - // manual registration of non-auto-discovered packages + $this->app->bind(EnvWriter::class, function () { + return new EnvWriter(base_path('.env')); + }); } } diff --git a/app/Registration.php b/app/Registration.php index e60729449..14c82f0ab 100644 --- a/app/Registration.php +++ b/app/Registration.php @@ -8,10 +8,13 @@ use App\Traits\Evaluable; use Carbon\Carbon; use Eloquent; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use App\Centre; +use App\Family; /** * @mixin Eloquent @@ -77,8 +80,6 @@ public function getEvaluator(): AbstractEvaluator /** * Works out if a Registration can be counted as "Active" - * - * @return bool */ public function isActive(): bool { @@ -111,31 +112,25 @@ public function isActive(): bool /** * Get the Registration's Family - * - * @return BelongsTo */ public function family(): BelongsTo { - return $this->belongsTo('App\Family'); + return $this->belongsTo(Family::class); } /** * Get the Registration's Centre - * - * @return BelongsTo */ public function centre(): BelongsTo { - return $this->belongsTo('App\Centre'); + return $this->belongsTo(Centre::class); } /** * Get the first un-disbursed bundle on a Registration for any Centre. * There should only be one... else make one if there are none. - * - * @return Model */ - public function currentBundle() + public function currentBundle(): Bundle { $bundle = $this->bundles() ->where('disbursed_at', null) @@ -166,10 +161,8 @@ public function bundles(): HasMany /** * Fetches the Registrations full Family and dependent models. - * @param $query - * @return mixed */ - public function scopeWithFullFamily($query) + public function scopeWithFullFamily(Builder $query): Builder { return $query->with([ // This may not be efficient, but it is convenient for ordering when required. @@ -183,10 +176,8 @@ public function scopeWithFullFamily($query) /** * Fetches only Registrations with an Active Family - * @param $query - * @return mixed */ - public function scopeWhereActiveFamily($query) + public function scopeWhereActiveFamily(Builder $query): Builder { return $query->whereHas('family', function ($q) { $q->whereNull('leaving_on'); @@ -200,4 +191,43 @@ public function lastBundle(): HasOne ->whereNotNull('disbursed_at') ->latest('disbursed_at'); } + + /** + * Join the primary carer (MIN id per family) directly into the query, + * so we can filter and sort by carer name in SQL rather than PHP. + */ + public function scopeWithPrimaryCarer(Builder $query): Builder + { + return $query + ->select('registrations.*') + ->joinSub( + Carer::query() + ->selectRaw('MIN(id) AS id, family_id') + ->groupBy('family_id'), + 'pri_carers', + 'pri_carers.family_id', '=', 'registrations.family_id' + ) + ->join('carers', 'carers.id', '=', 'pri_carers.id'); + } + + public function scopeOrderByCarerName(Builder $query, bool $descending = false): Builder + { + return $query->orderBy('carers.name', $descending ? 'desc' : 'asc'); + } + + public function scopeFilterByCarerName(Builder $query, string $term): Builder + { + return $query + ->whereLike('carers.name', "%{$term}%") + ->orderByRaw( + "CASE + WHEN LOWER(carers.name) = LOWER(?) THEN 0 + WHEN LOWER(carers.name) LIKE LOWER(?) THEN 1 + WHEN LOWER(carers.name) LIKE LOWER(?) + OR LOWER(carers.name) LIKE LOWER(?) THEN 2 + ELSE 3 + END", + [$term, "{$term} %", "% {$term} %", "% {$term}"] + ); + } } diff --git a/app/Services/CentreCollectionMarketService.php b/app/Services/CentreCollectionMarketService.php new file mode 100644 index 000000000..bfeb7fd88 --- /dev/null +++ b/app/Services/CentreCollectionMarketService.php @@ -0,0 +1,36 @@ +id)->trading()->exists()) { + return; + } + + // there are no active markets with traders that are assigned to this centre, + // better make one ... + $market = Market::create([ + 'name' => $centre->name . ' (Internal)', + 'location' => $centre->name, + 'centre_id' => $centre->id, + // assign it the centre's sponsor/area + 'sponsor_id' => $centre->sponsor->id, + // don't need one of these, the payment recommendation will be automated + 'payment_message' => '', + ]); + + // ... and its trader + Trader::create([ + 'name' => $centre->name . ' (Internal)', + 'market_id' => $market->id, + ]); + } +} diff --git a/app/Services/EnvWriter.php b/app/Services/EnvWriter.php new file mode 100644 index 000000000..c3b5c96d1 --- /dev/null +++ b/app/Services/EnvWriter.php @@ -0,0 +1,21 @@ +envPath); + + file_put_contents($this->envPath, preg_replace( + "/^{$key}=.*/m", + "{$key}={$newValue}", + $contents + )); + } +} diff --git a/app/Services/TransitionProcessor.php b/app/Services/TransitionProcessor.php deleted file mode 100644 index 1d4d25a70..000000000 --- a/app/Services/TransitionProcessor.php +++ /dev/null @@ -1,314 +0,0 @@ - [], - 'success_reject' => [], - 'own_duplicate' => [], - 'other_duplicate' => [], - 'invalid' => [], - 'failed_reject' => [], - 'undelivered' => [], - ]; - - public array $vouchers_for_payment = []; - - private Trader $trader; - - private Collection $vouchers; - - private string $transition; - - /** - * @param Trader $trader - * @param string $transition - */ - public function __construct(trader $trader, string $transition) - { - $this->transition = $transition; - $this->trader = $trader; - } - - /** - * Preps and picks a strategy for transitioning vouchers - * @param array $voucherCodes - * @return array|array[] - */ - public function handle(array $voucherCodes): array - { - // set a lock, to prevent double submits - $store = new SemaphoreStore(); - $factory = new LockFactory($store); - $lock = $factory->createLock('transition'); - - \Log::debug("Acquiring lock for vouchers " . join(", ", $voucherCodes)); - if ($lock->acquire()) { - // get and the available vouchers - $this->vouchers = Voucher::findByCodes($voucherCodes); - - // Get the ones not in that list - they are bad codes. - // We need to re-key the array here because otherwise the json response will return object for non 0 starting. - $this->responses['invalid'] = array_values( - array_diff( - $voucherCodes, - $this->vouchers->pluck('code')->toArray() - ) - ); - - switch ($this->transition) { - case 'collect' : - $this->handleCollect(); - break; - case 'confirm': - $this->handleConfirm(); - break; - case 'reject': - $this->handleReject(); - break; - default: - $this->handleDefault(); - } - - $lock->release(); - } else { - Log::info("Unable to achieve lock in transition processor for { $this->trader->id } doing $this->transition"); - $this->responses['own_duplicate'][] = '000000'; - } - return $this->responses; - } - - /** - * handles collection vouchers - * @return void - */ - public function handleCollect(): void - { - // Fetch the date we start to care about deliveries - $collect_delivery_date = Carbon::parse(config('arc.first_delivery_date')); - $transition = $this->transition; - - foreach ($this->vouchers as $voucher) { - \Log::debug("handleCollect on voucher " . $voucher->code); - // Don't transition newer, undelivered vouchers - if (// delivery_id is null - $voucher->delivery_id === null && - // The cut-off date is less than or equal to the created_at - $collect_delivery_date->lessThanOrEqualTo($voucher->created_at) - ) { - // Don't proceed, just file this voucher for a message - $this->responses['undelivered'][] = $voucher->code; - \Log::debug("Undelivered voucher " . $voucher->code); - continue; - } - - if ($this->doTransition($voucher, $transition, $this->trader->id)) { - $this->responses['success_add'][] = $voucher->code; - } - } - } - - /** - * Actually does the transition - will save a voucher on the way through - * @param Voucher $voucher - * @param string $transition - * @param int|null $againstTraderId - * @return bool - */ - private function doTransition(Voucher $voucher, string $transition, ?int $againstTraderId = null): bool - { - try { - if ($voucher->transitionAllowed($transition)) { - $voucher->trader_id = $againstTraderId; - $voucher->applyTransition($transition); - \Log::debug(sprintf("Transition %s on %s for %d", $transition, $voucher, $againstTraderId)); - } else { - // No? drop vouchers into a relevant bin - if ($voucher->trader_id === $againstTraderId) { - // Trader has already submitted this voucher - $this->responses['own_duplicate'][] = $voucher->code; - \Log::debug(sprintf("Transition denied %s on %s for %d, own_duplicate", $transition, $voucher, $againstTraderId)); - } else { - // Another trader has mistakenly submitted this voucher, - // Or the transition isn't valid (i.e. expired state) - $this->responses['other_duplicate'][] = $voucher->code; - \Log::debug(sprintf("Transition denied %s on %s for %d, other_duplicate", $transition, $voucher, $againstTraderId)); - } - return false; - } - } catch (Exception $e) { - // Something went really wrong - impossible transition string? - Log::warning($e->getMessage()); - return false; - } - return true; - } - - /** - * confirms a voucher set for payment - * @return void - */ - public function handleConfirm(): void - { - // If 'confirm', we'll need a StateToken for Later, with an ID for Admin Payment flagging - $stateToken = factory(StateToken::class)->create(); - $stateToken->user_id = Auth::user()->id; - $stateToken->save(); - $transition = $this->transition; - - foreach ($this->vouchers as $voucher) { - - // Can we do a transition already? - if ($this->doTransition($voucher, $transition, $this->trader->id)) { - // add to a list for sending to ARC admin. This is a request for payment. - $this->vouchers_for_payment[] = $voucher; - - // Fetch the last transition and add the state - $voucher->getPriorState()->stateToken()->associate($stateToken)->save(); - } - } - // If there are any confirmed ones... trigger the email. - if (!empty($this->vouchers_for_payment)) { - Log::info('SENDING MAIL ' . count($this->vouchers_for_payment)); - self::emailVoucherPaymentRequest($this->trader, $this->vouchers_for_payment); - } - } - - /** - * Email a Trader's Voucher Payment Request. - * @param Trader $trader - * @param array $vouchers - * @return void - */ - public static function emailVoucherPaymentRequest(Trader $trader, array $vouchers): void - { - $title = "A report containing voucher payment request for $trader->name."; - // Request date string as dd-mm-yyyy - $date = Carbon::now()->format('d-m-Y'); - // Todo factor excel/csv create functions out into service. - $file = TraderController::createVoucherListFile($trader, $vouchers, $title, $date, Auth::user()->name); - $programme_amounts = TraderController::getProgrammeAmounts($vouchers); - - event(new VoucherPaymentRequested(Auth::user(), $trader, $vouchers, $file, $programme_amounts)); - } - - /** - * This handles rejections back to the free voucher pool - * @return void - */ - public function handleReject(): void - { - foreach ($this->vouchers as $voucher) { - // Work out which transition we need to roll back to for "rejects" - $last_state = $voucher->getPriorState(); - if ($last_state === null) { - $this->responses['failed_reject'][] = $voucher->from; - continue; - } - - // alter the transition - $transition = "reject-to-" . $last_state->from; - - // Can we do a transition already? - if ($this->doTransition($voucher, $transition, null)) { - $this->responses['success_reject'][] = $voucher->code; - } - } - } - - /** - * This is for undefined transitions, as a catchall. - * @return void - */ - public function handleDefault(): void - { - $transition = $this->transition; - foreach ($this->vouchers as $voucher) { - // Can we do a transition already? - $this->doTransition($voucher, $transition, $this->trader->id); - } - } - - /** - * Helper to construct voucher validation response messages. - * @return array - */ - public function constructResponseMessage(): array - { - // If there are any confirmed ones respond appropriately. - if (!empty($this->vouchers_for_payment)) { - return ['message' => trans('api.messages.voucher_payment_requested')]; - } - - // If there is only one voucher code being checked. - $total_submitted = 0; - $error_type = ''; - $responses = $this->responses; - foreach ($responses as $key => $code) { - $total_submitted += count($code); - - if (count($code) === 1) { - // We will only use this if there is a total of 1 voucher submitted. - // So no problem if 2 sets have 1 voucher in them. It is ignored. - $error_type = $key; - } - } - if ($total_submitted === 1) { - return match ($error_type) { - 'success_add' => [ - 'message' => trans('api.messages.voucher_success_add'), - ], - 'success_reject' => [ - 'message' => trans('api.messages.voucher_success_reject'), - ], - 'own_duplicate' => [ - 'warning' => trans('api.errors.voucher_own_dupe', [ - 'code' => $responses['own_duplicate'][0], - ]), - ], - 'other_duplicate' => [ - 'warning' => trans('api.errors.voucher_other_dupe', [ - 'code' => $responses['other_duplicate'][0], - ]), - ], - 'failed_reject' => [ - 'warning' => trans('api.errors.voucher_failed_reject', [ - 'code' => $responses['failed_reject'][0], - ]), - ], - 'undelivered' => [ - 'warning' => trans('api.errors.voucher_unavailable', [ - 'code' => $responses['undelivered'][0], - ]), - ], - default => [ - 'error' => trans('api.errors.voucher_unavailable'), - ], - }; - } - - // for a complex response - return [ - 'message' => trans('api.messages.batch_voucher_submit', [ - 'success_amount' => count($responses['success_add']), - 'duplicate_amount' => count($responses['own_duplicate']) + count($responses['other_duplicate']), - 'invalid_amount' => count($responses['invalid']) + count($responses['undelivered']), - ]), - ]; - } -} diff --git a/app/Services/TransitionProcessor/TransitionProcessor.php b/app/Services/TransitionProcessor/TransitionProcessor.php new file mode 100644 index 000000000..54e56e381 --- /dev/null +++ b/app/Services/TransitionProcessor/TransitionProcessor.php @@ -0,0 +1,337 @@ +response = new TransitionResponse(); + $this->collectDeliveryDate = Carbon::parse(config('arc.first_delivery_date')); + } + + /** + * Accepts a query builder describing the vouchers to transition. + * + * The caller is responsible for scoping the query (e.g. by trader, state). + * This method opens the cursor itself via ->lazy(), so no models are + * instantiated outside the processor. + * + * Returns a TransitionResponse whose message and failure state can be + * inspected by the caller. Callers that hold user-submitted code strings + * should call $response->addInvalid($invalidCodes) before reading the + * response message. + */ + public function handle(Builder|Relation $query): TransitionResponse + { + $lock = (new LockFactory(new SemaphoreStore()))->createLock('transition'); + + Log::debug(sprintf( + 'Acquiring lock for transition [%s] on trader %s, %d vouchers', + $this->transition, + $this->trader?->id ?? 'none', + $query->count() + )); + + if (!$lock->acquire()) { + Log::info(sprintf( + 'Unable to acquire lock in TransitionProcessor for trader %s doing %s', + $this->trader?->id ?? 'none', + $this->transition + )); + $this->response->addCode('own_duplicate', '000000'); + return $this->response; + } + + try { + $this->processInChunks($query); + } finally { + $lock->release(); + } + + if ($this->sendPaymentEmail && $this->trader !== null && $this->response->hasPayments()) { + $vouchersForEmail = Voucher::findMany($this->response->getVouchersForPayment())->all(); + Log::info('SENDING MAIL ' . count($vouchersForEmail)); + self::emailVoucherPaymentRequest($this->trader, $vouchersForEmail); + } + + return $this->response; + } + + /** + * Opens a database cursor over the query and processes one voucher at a time. + * + * lazy($chunkSize) issues SELECT queries in pages of $chunkSize behind the + * scenes, but only one model is held in memory at a time from PHP's perspective. + * + * The StateToken for confirm transitions is created once here so it spans + * the entire batch — creating it inside the loop would associate each page + * of vouchers with a different token and break the payment audit trail. + */ + private function processInChunks(Builder|Relation $query): void + { + $stateToken = $this->transition === 'confirm' ? $this->initStateToken() : null; + + foreach ($query->lazy($this->chunkSize) as $voucher) { + match ($this->transition) { + 'collect' => $this->handleCollect($voucher), + 'confirm' => $this->handleConfirm($voucher, $stateToken), + 'reject' => $this->handleReject($voucher), + 'payout' => $this->handlePayout($voucher), + default => $this->handleDefault($voucher), + }; + } + } + + /** + * Creates a StateToken and associates the authenticated user if present. + */ + private function initStateToken(): StateToken + { + $stateToken = StateToken::create([ + 'uuid' => StateToken::generateUnusedToken(), + ]); + if (Auth::check()) { + $stateToken->user_id = Auth::id(); + $stateToken->save(); + } + return $stateToken; + } + + /** + * Attempts to apply a transition to a voucher. + * + * This is a generic utility — it knows nothing about why a transition + * is or is not allowed. Callers are responsible for classifying denials + * into the appropriate response bucket before or after calling this method. + * + * trader_id is only written when $againstTraderId is explicitly provided. + * Callers that do not need to change the trader (payout, reject, default) + * omit it. Callers that need to clear it (reject) do so explicitly after + * this method returns. + * + * Returns true only when the transition was applied. + */ + private function doTransition( + Voucher $voucher, + string $transition, + ?int $againstTraderId = null + ): bool { + try { + if ($voucher->transitionAllowed($transition)) { + // trader_id is set before the transition so that postTransition's + // $model->save() persists it in the same write as the state change. + if ($againstTraderId !== null) { + $voucher->trader_id = $againstTraderId; + } + $voucher->applyTransition($transition); + Log::debug(sprintf( + 'Transition %s on %s for trader %s', + $transition, + $voucher, + $againstTraderId ?? $voucher->trader_id ?? 'none' + )); + return true; + } + + Log::debug(sprintf( + 'Transition denied %s on %s for trader %s', + $transition, + $voucher, + $againstTraderId ?? $voucher->trader_id ?? 'none' + )); + } catch (Exception $e) { + // Catches impossible transition strings or unexpected model errors. + Log::warning($e->getMessage()); + } + + return false; + } + + /** + * Collects a voucher, skipping undelivered ones introduced after the + * first delivery date. + * + * own_duplicate and other_duplicate are classification decisions that + * belong here rather than in doTransition — they are specific to the + * collect transition where competing trader claims are possible and the + * trader identity is the deciding factor for why a denial occurred. + * @throws SMException + */ + private function handleCollect(Voucher $voucher): void + { + Log::debug('handleCollect on voucher ' . $voucher->code); + + if ( + $voucher->delivery_id === null && + $this->collectDeliveryDate->lessThanOrEqualTo($voucher->created_at) + ) { + $this->response->addCode('undelivered', $voucher->code); + Log::debug('Undelivered voucher ' . $voucher->code); + return; + } + + if (!$voucher->transitionAllowed('collect')) { + if ($voucher->trader_id === $this->trader->id) { + // This trader has already submitted this voucher. + $this->response->addCode('own_duplicate', $voucher->code); + Log::debug(sprintf( + 'Transition denied collect on %s for trader %s: own_duplicate', + $voucher, + $this->trader->id + )); + } else { + // Another trader submitted this voucher, or the state is invalid. + $this->response->addCode('other_duplicate', $voucher->code); + Log::debug(sprintf( + 'Transition denied collect on %s for trader %s: other_duplicate', + $voucher, + $this->trader->id + )); + } + return; + } + + if ($this->doTransition($voucher, 'collect', $this->trader->id)) { + $this->response->addCode('success_add', $voucher->code); + } + } + + /** + * Confirms a voucher for payment and associates it with the batch StateToken. + */ + private function handleConfirm(Voucher $voucher, StateToken $stateToken): void + { + if ($this->doTransition($voucher, 'confirm', $this->trader->id)) { + // Accumulate IDs only — full models are loaded after the loop for the email. + $this->response->recordPayment($voucher->id); + $voucher->getPriorState()->stateToken()->associate($stateToken)->save(); + } + } + + /** + * Rejects a voucher back to the free pool, resolving the correct rollback + * transition from the voucher's prior state. + * + * trader_id is cleared explicitly here after the transition rather than + * relying on null being passed into doTransition — null means "do not + * touch trader_id", so the clearance must be an intentional separate step. + * An explicit save is required here to persist the clearance because + * postTransition only saves whatever is dirty at transition time. + */ + private function handleReject(Voucher $voucher): void + { + $last_state = $voucher->getPriorState(); + if ($last_state === null) { + $this->response->addCode('failed_reject', $voucher->code); + return; + } + + if ($this->doTransition($voucher, 'reject-to-' . $last_state->from)) { + $voucher->trader_id = null; + $voucher->save(); + $this->response->addCode('success_reject', $voucher->code); + } + } + + /** + * Pays a voucher. + * + * trader_id is not passed to doTransition because payout is admin-driven — + * the trader that originally collected the voucher must not be overwritten. + * A denied payout is a generic processing failure with no trader-conflict + * meaning, so no own_duplicate or other_duplicate classification is needed. + */ + private function handlePayout(Voucher $voucher): void + { + if ($this->doTransition($voucher, 'payout')) { + $this->response->addCode('success_add', $voucher->code); + } else { + $this->response->addCode('failed_payout', $voucher->code); + } + } + + /** + * Catchall for any transition string not explicitly handled above. + * + * trader_id is intentionally not written here. The only transition that + * should set trader_id is collect (via handleCollect), and the only one + * that should clear it is reject (via handleReject). All other transitions + * leave trader_id untouched. + * + * $trader is still required on the processor when reaching this path — + * not to write to the voucher, but because any transition routed here + * is assumed to be trader-context-scoped. If a genuinely trader-free + * transition is added in future, give it its own match arm. + */ + private function handleDefault(Voucher $voucher): void + { + if ($this->trader === null) { + throw new \LogicException(sprintf( + 'Transition "%s" reached handleDefault with no trader on the processor. ' . + 'Add an explicit match arm in processInChunks() if this transition ' . + 'is intentionally trader-free.', + $this->transition + )); + } + + if ($this->doTransition($voucher, $this->transition)) { + $this->response->addCode('success_add', $voucher->code); + } + } + + /** + * Emails the trader's users a voucher payment request with an attached report. + */ + public static function emailVoucherPaymentRequest(Trader $trader, array $vouchers): void + { + $title = "A report containing voucher payment request for $trader->name."; + $date = Carbon::now()->format('d-m-Y'); + + $file = TraderController::createVoucherListFile($trader, $vouchers, $title, $date, Auth::user()->name); + $programme_amounts = TraderController::getProgrammeAmounts($vouchers); + + event(new VoucherPaymentRequested(Auth::user(), $trader, $vouchers, $file, $programme_amounts)); + } +} diff --git a/app/Services/TransitionProcessor/TransitionResponse.php b/app/Services/TransitionProcessor/TransitionResponse.php new file mode 100644 index 000000000..498bb7005 --- /dev/null +++ b/app/Services/TransitionProcessor/TransitionResponse.php @@ -0,0 +1,177 @@ +buckets = new MessageBag(); + } + + /** + * Adds a single voucher code to the named bucket. + * Called per-voucher by TransitionProcessor handlers. + */ + public function addCode(string $bucket, string $code): void + { + if (!in_array($bucket, self::BUCKETS, true)) { + throw new InvalidArgumentException("Unknown response bucket: [$bucket]"); + } + $this->buckets->add($bucket, $code); + } + + /** + * Batch-adds invalid codes. Called by callers that hold user-submitted + * code strings (VoucherController, ProcessTransitionJob) after handle() returns. + * Callers that build their own query (CLI commands, scoped jobs) never call this. + */ + public function addInvalid(array $codes): void + { + $this->buckets->merge(['invalid' => $codes]); + } + + public function recordPayment(int $voucherId): void + { + $this->vouchersForPayment[] = $voucherId; + } + + public function getVouchersForPayment(): array + { + return $this->vouchersForPayment; + } + + public function hasPayments(): bool + { + return !empty($this->vouchersForPayment); + } + + /** + * Returns true if any failure bucket is non-empty. + */ + public function hasFailures(): bool + { + return (bool) Arr::first( + Arr::except($this->buckets->toArray(), self::SUCCESS_BUCKETS), + static function ($codes): bool { + return !empty($codes); + } + ); + } + + /** + * Returns all codes from every failure bucket, flattened. + * Arr::flatten on an empty result naturally returns []. + */ + public function getFailureCodes(): array + { + return Arr::flatten( + Arr::except($this->buckets->toArray(), self::SUCCESS_BUCKETS) + ); + } + + /** + * Delegates to MessageBag::toArray() for logging or serialisation. + */ + public function toArray(): array + { + return $this->buckets->toArray(); + } + + // ------------------------------------------------------------------------- + // Response message + // ------------------------------------------------------------------------- + + /** + * Constructs a human-readable API response message from the accumulated + * result buckets. Single-voucher submissions receive a specific message; + * batches receive a summary. Payment confirmations short-circuit to their + * own message regardless of individual voucher outcomes. + */ + public function constructResponseMessage(): array + { + if ($this->hasPayments()) { + return ['message' => trans('api.messages.voucher_payment_requested')]; + } + + // MessageBag::count() totals every message across all keys. + $total_submitted = $this->buckets->count(); + + if ($total_submitted === 1) { + // Find the single bucket that holds the one code. + // If two buckets each had one entry, total_submitted would be 2 + // and this branch would not be reached. + $error_type = array_key_first( + Arr::where($this->buckets->toArray(), static function ($codes): bool { + return count($codes) === 1; + }) + ) ?? ''; + + return match ($error_type) { + 'success_add' => [ + 'message' => trans('api.messages.voucher_success_add'), + ], + 'success_reject' => [ + 'message' => trans('api.messages.voucher_success_reject'), + ], + 'own_duplicate' => [ + 'warning' => trans('api.errors.voucher_own_dupe', [ + 'code' => $this->buckets->first('own_duplicate'), + ]), + ], + 'other_duplicate' => [ + 'warning' => trans('api.errors.voucher_other_dupe', [ + 'code' => $this->buckets->first('other_duplicate'), + ]), + ], + 'failed_reject' => [ + 'warning' => trans('api.errors.voucher_failed_reject', [ + 'code' => $this->buckets->first('failed_reject'), + ]), + ], + 'undelivered' => [ + 'warning' => trans('api.errors.voucher_unavailable', [ + 'code' => $this->buckets->first('undelivered'), + ]), + ], + default => [ + 'error' => trans('api.errors.voucher_unavailable'), + ], + }; + } + + return [ + 'message' => trans('api.messages.batch_voucher_submit', [ + 'success_amount' => count($this->buckets->get('success_add')), + 'duplicate_amount' => count($this->buckets->get('own_duplicate')) + + count($this->buckets->get('other_duplicate')), + 'invalid_amount' => count($this->buckets->get('invalid')) + + count($this->buckets->get('undelivered')), + ]), + ]; + } +} diff --git a/app/StateToken.php b/app/StateToken.php index 36d33d993..61c21d284 100644 --- a/app/StateToken.php +++ b/app/StateToken.php @@ -2,13 +2,17 @@ namespace App; -use DB; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\HasOne; -use Ramsey\Uuid\Uuid; -use Log; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Str; + /** + * @property string $uuid + * @property int|null $user_id + * @property int|null $admin_user_id * @property VoucherState $voucherStates * @property User $user * @property AdminUser $adminUser @@ -17,8 +21,6 @@ class StateToken extends Model { /** * The attributes that are mass assignable. - * - * @var array */ protected $fillable = [ 'uuid', @@ -27,63 +29,95 @@ class StateToken extends Model ]; /** - * The attributes that should be hidden for arrays. - * - * @var array + * The attributes that should be cast. */ - protected $hidden = [ + protected $casts = [ + 'user_id' => 'integer', + 'admin_user_id' => 'integer', ]; /** - * Makes and checks for an unused token - * - * @return string - */ - public static function generateUnusedToken() + * Generate a unique, unused UUID token. + */ + public static function generateUnusedToken(): string { do { - // TODO: Deal with possibility uuid4() may throw an exception of it's own? - try { - $candidate = Uuid::uuid4()->toString(); - } catch (\Exception $e) { - // Uuid4() throws exceptions, apparently! Log that and die, I guess? - Log::warning($e->getMessage()); - abort(500, $e->getMessage()); - } - // Check if it's in use. - $usedToken = self::isUsedToken($candidate); - } while ($usedToken === true); + $candidate = Str::uuid()->toString(); + } while (static::isUsedToken($candidate)); return $candidate; } /** - * Checks a UUID has been used - * @param $candidate - * @return bool + * Check whether a UUID token is already in use. + */ + public static function isUsedToken(string $candidate): bool + { + return static::where('uuid', $candidate)->exists(); + } + + /** + * Lightweight check for outstanding payments to highlight in dashboard */ - public static function isUsedToken($candidate) + public static function checkIfOutstandingPayments(): bool { - $tableName = 'state_tokens'; - return DB::table($tableName) - ->where('uuid', $candidate) + return self::pending() + ->withinPaymentWindow() ->exists(); } /** - * The vouchers that share this StateToken - * - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * Constrains results to the payment window. + */ + public function scopeWithinPaymentWindow(Builder $query, ?Carbon $date = null): void + { + $from = $date ?? Carbon::now() + ->startOfDay() + ->subDays(config('arc.payment_window_days')) + ; + + $query->where('created_at', '>=', $from); + } + + /** + * Constrains to payment requests not yet actioned by an admin. + */ + public function scopePending(Builder $query): void + { + $query->whereNull('admin_user_id'); + } + + /** + * Constrains to payment requests already actioned by an admin. + */ + public function scopeReimbursed(Builder $query): void + { + $query->whereNotNull('admin_user_id'); + } + + /** + * Eager-loads all relationships required to render the payments view. + * Kept as a scope so callers don't have to know or repeat the tree. + */ + public function scopeWithPaymentRelations(Builder $query): void + { + $query->with([ + 'user', + 'voucherStates.voucher.trader.market.sponsor', + 'voucherStates.voucher.sponsor', + ]); + } + + /** + * The voucher states that share this StateToken. */ - public function voucherStates() + public function voucherStates(): HasMany { return $this->hasMany(VoucherState::class); } /** - * The user that created this StateToken - * - * @return BelongsTo + * The user that created this StateToken. */ public function user(): BelongsTo { @@ -91,9 +125,7 @@ public function user(): BelongsTo } /** - * The admin user that updated this StateToken - * - * @return BelongsTo + * The admin user associated with this StateToken. */ public function adminUser(): BelongsTo { diff --git a/app/Support/LazySecureModel.php b/app/Support/LazySecureModel.php new file mode 100644 index 000000000..0b6cc1418 --- /dev/null +++ b/app/Support/LazySecureModel.php @@ -0,0 +1,223 @@ +hideEncryptedAttributes(); + }); + } + + public function hideEncryptedAttributes(): static + { + $this->makeHidden($this->encryptedFields()); + return $this; + } + + public function encryptedFields(): array + { + return static::$encryptedFieldCache[static::class] + ??= static::getCipherSweetEncryptedRow()->listEncryptedFields(); + } + + /** + * Lazy decrypt full encrypted row once, then cache it on the model instance. + */ + public function decryptEncryptedRowForLazyAccess(): array + { + if ($this->lazyDecryptedRowCache !== null) { + return $this->lazyDecryptedRowCache; + } + + $row = static::getCipherSweetEncryptedRow() + ->setPermitEmpty(config('ciphersweet.permit_empty', false)); + + $payload = []; + + foreach ($this->encryptedFields() as $field) { + // Important: use raw/original DB values, not accessors. + $payload[$field] = $this->getRawOriginal($field); + + // Some hydration paths may not populate "original" as expected. + // Fall back to raw attributes if needed. + if (!array_key_exists($field, $this->getOriginal()) && array_key_exists($field, $this->getAttributes())) { + $payload[$field] = $this->getAttributes()[$field]; + } + + // Ensure every configured encrypted field exists in the payload, + // even when null, to satisfy CipherSweet row expectations. + $payload[$field] ??= null; + } + + return $this->lazyDecryptedRowCache = $row->decryptRow($payload); + } + + /** + * If someone accesses $model->emailsecret directly and email is encrypted, + * return a LazySecretValue instead of plaintext/ciphertext. + */ + public function getAttribute($key): mixed + { + if (is_string($key) && $this->isEncryptedField($key)) { + return $this->secret($key); + } + + return parent::getAttribute($key); + } + + public function isEncryptedField(string $field): bool + { + return in_array($field, $this->encryptedFields(), true); + } + + /** + * Explicit non-magic access to a secret wrapper. + */ + public function secret(string $field): LazySecureValue + { + if (!$this->isEncryptedField($field)) { + throw new InvalidArgumentException(sprintf( + '"%s" is not a configured encrypted field on %s.', + $field, + static::class + )); + } + + return new LazySecureValue($this, $field); + } + + /** + * Belt and Braces: remove secrets from array serialization regardless of $hidden changes elsewhere + */ + public function toArray(): array + { + $array = parent::toArray(); + + foreach ($this->encryptedFields() as $field) { + unset($array[$field]); + } + + return $array; + } + + /** + * Safe debug output, prevents secrets in debugs + */ + public function __debugInfo(): array + { + return [ + 'model' => static::class, + 'id' => $this->getKey(), + 'attributes' => collect(parent::attributesToArray()) + ->except($this->encryptedFields()) + ->all(), + 'hidden_encrypted_fields' => $this->encryptedFields(), + ]; + } + + /** + * Override in concrete models, policies, or a shared auth trait. + */ + public function authorizeReveal(string $field): void + { + // no-op by default + } + + /** + * Optional helper for safe cloning into jobs/events/resources. + */ + public function withoutSecrets(): static + { + $clone = clone $this; + $clone->flushSecretCache(); + + foreach ($clone->encryptedFields() as $field) { + unset($clone->{$field}); + } + + $clone->makeHidden($clone->encryptedFields()); + + return $clone; + } + + /** + * Clear cached decrypted values after mutation/refresh. + */ + public function flushSecretCache(): self + { + $this->lazyDecryptedRowCache = null; + return $this; + } + + /** + * Important: whenever attributes are replaced wholesale, clear cache. + */ + public function setRawAttributes(array $attributes, $sync = false): self + { + $this->flushSecretCache(); + return parent::setRawAttributes($attributes, $sync); + } + + public function refresh(): self + { + $this->flushSecretCache(); + return parent::refresh(); + } + + public function save(array $options = []): bool + { + $this->hydrateUnmodifiedEncryptedFieldsBeforeSave(); + return parent::save($options); + } + + protected function hydrateUnmodifiedEncryptedFieldsBeforeSave(): void + { + // New (not-yet-persisted) models have no ciphertext to guard against. + if (!$this->exists) { + return; + } + + $encryptedFields = $this->encryptedFields(); + + // If every encrypted field is dirty, the developer has already set plaintext + // on all of them — CipherSweet will receive plaintext for each. Nothing to do. + $unmodifiedFields = array_filter( + $encryptedFields, + function (string $field) { + return !$this->isDirty($field); + } + ); + + if (empty($unmodifiedFields)) { + return; + } + + // Decrypt the full row once (result is cached on the instance). + $decrypted = $this->decryptEncryptedRowForLazyAccess(); + + foreach ($unmodifiedFields as $field) { + // Write plaintext directly into attributes, bypassing getAttribute() + // so CipherSweet's observer sees plaintext, not ciphertext. + $this->attributes[$field] = $decrypted[$field] ?? null; + } + } + +} diff --git a/app/Support/LazySecureValue.php b/app/Support/LazySecureValue.php new file mode 100644 index 000000000..138bc3ef7 --- /dev/null +++ b/app/Support/LazySecureValue.php @@ -0,0 +1,56 @@ +model, 'authorizeReveal')) { + throw new \LogicException(sprintf( + '%s must define authorizeReveal()', + $this->model::class + )); + } + + $this->model->authorizeReveal($this->field); + + $row = $this->model->decryptEncryptedRowForLazyAccess(); + + return $row[$this->field] ?? null; + } + + public function isNull(): bool + { + return $this->reveal() === null; + } + + public function jsonSerialize(): mixed + { + return '[secret]'; + } + + public function __toString(): string + { + return '[secret]'; + } + + public function __debugInfo(): array + { + return [ + 'secret' => '[hidden]', + 'field' => $this->field, + 'model' => $this->model::class, + ]; + } +} + diff --git a/app/Support/UsesCipherSweetLazy.php b/app/Support/UsesCipherSweetLazy.php new file mode 100644 index 000000000..140d68cef --- /dev/null +++ b/app/Support/UsesCipherSweetLazy.php @@ -0,0 +1,42 @@ +saving($m); + } + ); + + static::saved( + static function ($m) { + app(ModelObserver::class)->saved($m); + } + ); + + static::deleting( + static function ($m) { + app(ModelObserver::class)->deleting($m); + } + ); + } +} diff --git a/app/Traits/Retirable.php b/app/Traits/Retirable.php new file mode 100644 index 000000000..f78679d40 --- /dev/null +++ b/app/Traits/Retirable.php @@ -0,0 +1,83 @@ +retired_at !== null) { + throw new DomainException( + "Retired " . get_class($model) . " [{$model->id}] cannot be restored." + ); + } + }); + } + + /** + * Magically invoked - Adds retired_at to the model + */ + public function initializeRetirable(): void + { + $this->casts['retired_at'] = 'datetime'; + } + + /** + * Map of field => retirement value. + * Override in the consuming class to match its DDL constraints. + */ + protected function retirableFields(): array + { + return [ + 'remember_token' => null, + ]; + } + + public function retire(): void + { + if ($this->retired_at !== null) { + return; + } + + if (!$this->trashed()) { + throw new DomainException( + get_class($this) . " [$this->id] must be disabled before it can be retired." + ); + } + + $this->forceFill( + array_merge($this->retirableFields(), ['retired_at' => now()]) + )->save(); + } + + public function isRetired(): bool + { + return $this->retired_at !== null; + } + + public function scopeRetired($query) + { + return $query->withTrashed()->whereNotNull('retired_at'); + } + + // Not trashed or retired + public function scopeActive($query) + { + return $query->whereNull('retired_at'); + } +} diff --git a/app/Traits/Statable.php b/app/Traits/Statable.php index c4a51845a..165c6c535 100644 --- a/app/Traits/Statable.php +++ b/app/Traits/Statable.php @@ -1,14 +1,10 @@ applyTransition($transition); - } else { - return $this->getStateMachine()->getState(); - } + return (is_null($transition)) + ? $this->getStateMachine()->getState() + : $this->applyTransition($transition); } /** - * @param string $transition - * @return bool + * Do the transition */ - public function applyTransition($transition) + public function applyTransition(string $transition): bool { return $this->getStateMachine()->apply($transition); } /** * Checks if a transition is "allowed" by the FSM graph - * - * @param string $transition - * @return bool * @throws SMException */ - public function transitionAllowed(string $transition) : bool + public function transitionAllowed(string $transition): bool { return $this->getStateMachine()->can($transition); } /** * Gets a collection of Models representing the history. - * - * @return HasMany */ - public function history() + public function history(): HasMany { return $this->hasMany(self::HISTORY_MODEL); } /** * Creates a transitionDef object - * - * @param $fromState - * @param $transitionName - * @return object */ - public static function createTransitionDef($fromState, $transitionName) + public static function createTransitionDef(string $fromState, string $transitionName): ?object { // Set a transition details, because we can't pull the protected StateMachine config. $transition = config('state-machine.' . self::SM_CONFIG . '.transitions.' . $transitionName) ?? null; diff --git a/app/User.php b/app/User.php index 8f85cd21b..5c82b620e 100644 --- a/app/User.php +++ b/app/User.php @@ -20,7 +20,9 @@ */ class User extends Authenticatable { - use HasApiTokens, Notifiable, SoftDeletes; + use HasApiTokens; + use Notifiable; + use SoftDeletes; protected $casts = [ 'deleted_at' => 'datetime', diff --git a/app/View/Components/GeneralInput.php b/app/View/Components/GeneralInput.php new file mode 100644 index 000000000..5a077798e --- /dev/null +++ b/app/View/Components/GeneralInput.php @@ -0,0 +1,92 @@ +get($errorKey) are used, which + * mirrors the existing @includeWhen('store.partials.errors') + * behaviour for fields that carry server messages. + * @param string|null $alertId Optional id attribute forwarded to the errors partial + * (matches the existing 'id' => 'carer-alert' usage). + * @param string|null $filter Named sanitiser applied via JS on the 'input' event. + * @param string|null $placeholder Optional placeholder text. + */ + public function __construct( + public readonly string $name, + public readonly string $label, + public readonly string $type = 'text', + public readonly int|string|null $modelId = null, + public readonly ?string $value = null, + public readonly ?string $errorKey = null, + public readonly ?string $errorMessage = null, + public readonly ?string $alertId = null, + public readonly ?string $filter = null, + public readonly ?string $placeholder = null, + ) { + } + + public function render(): View + { + /** @var ViewErrorBag $errors */ + $errors = session('errors', new ViewErrorBag()); + + // ── Name & old() key ────────────────────────────────────────────── + // Array-style name when editing an existing record: field[123] + // Dot-notation old() key for the same case: field.123 + $inputName = $this->modelId !== null + ? "{$this->name}[{$this->modelId}]" + : $this->name; + + $oldKey = $this->modelId !== null + ? "{$this->name}.{$this->modelId}" + : $this->name; + + // ── Error key ──────────────────────────────────────────────────── + // Callers can override when the validation key doesn't match the + // rendered name (e.g. pri_carer_language uses a flat key even in + // edit mode where the name is array-style). + $resolvedErrorKey = $this->errorKey ?? $oldKey; + + // ── Value ──────────────────────────────────────────────────────── + // On a failed submission old() wins so the user's input is sticky. + // On a clean edit page $this->value (the model attribute) is shown. + // On a clean create page both are null → empty field. + $displayValue = old($oldKey) ?? $this->value; + + // ── Errors ─────────────────────────────────────────────────────── + $hasError = $errors->has($resolvedErrorKey); + // Prefer an explicit single message; fall back to whatever the + // Validator produced (mirrors the original @includeWhen usage). + $errorMessages = $this->errorMessage !== null + ? [$this->errorMessage] + : $errors->get($resolvedErrorKey); + + return view('components.general-input', [ + 'inputName' => $inputName, + 'hasError' => $hasError, + 'displayValue' => $displayValue, + 'errorMessages' => $errorMessages, + ]); + } +} diff --git a/app/View/Components/GeneralSelect.php b/app/View/Components/GeneralSelect.php new file mode 100644 index 000000000..5368a3cad --- /dev/null +++ b/app/View/Components/GeneralSelect.php @@ -0,0 +1,97 @@ + label pairs to render + * as