This commit is contained in:
trogers1884 2025-03-13 19:50:56 -05:00
parent 747b6b4004
commit 1bbe96c246
10 changed files with 1139 additions and 0 deletions

View File

@ -0,0 +1,136 @@
<?php
namespace App\Components\Help\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\SoftDeleteResolution;
class HelpContent extends Model
{
use SoftDeletes, SoftDeleteResolution;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'config.help_content';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'context_key',
'title',
'content',
'version',
'is_active'
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'is_active' => 'boolean',
'version' => 'integer',
];
/**
* Define which fields should be considered unique for conflict detection
* This is used by the SoftDeleteResolution trait to detect conflicts
*/
protected function uniqueFields(): array
{
return ['context_key', 'version'];
}
/**
* Boot the model
*/
protected static function boot()
{
parent::boot();
// Before saving, check for conflicting soft-deleted records
static::saving(function ($model) {
try {
$duplicate = $model->findSoftDeletedDuplicate();
if ($duplicate) {
// If we find a soft-deleted duplicate, restore it with the new attributes
$model->restoreSoftDeletedRecord($duplicate, $model->getAttributes());
return false; // Prevent original save
}
} catch (\Exception $e) {
\Log::error('Error checking for soft-deleted help content', [
'error' => $e->getMessage(),
'model' => get_class($model),
'attributes' => $model->getAttributes()
]);
}
return true;
});
}
/**
* Get the latest active version of help content for a given context key
*
* @param string $contextKey
* @return HelpContent|null
*/
public static function getLatestForContext(string $contextKey): ?HelpContent
{
return static::where('context_key', $contextKey)
->where('is_active', true)
->orderBy('version', 'desc')
->first();
}
/**
* Create a new version of help content
*
* @param string $contextKey
* @param string $title
* @param string $content
* @return HelpContent
*/
public static function createNewVersion(string $contextKey, string $title, string $content): HelpContent
{
// Find the latest version
$latestVersion = static::where('context_key', $contextKey)
->orderBy('version', 'desc')
->value('version') ?? 0;
// Create new version
return static::create([
'context_key' => $contextKey,
'title' => $title,
'content' => $content,
'version' => $latestVersion + 1,
'is_active' => true
]);
}
/**
* Set this help content as the active version for its context
*
* @return bool
*/
public function setAsActive(): bool
{
// Deactivate all other versions for this context
static::where('context_key', $this->context_key)
->where('id', '!=', $this->id)
->update(['is_active' => false]);
// Set this version as active
$this->is_active = true;
return $this->save();
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Components\Help\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Blade;
use App\Components\Help\View\Components\HelpIcon;
use App\Components\Help\View\Components\HelpModal;
class HelpServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
// Register routes
Route::middleware('web')
->group(function () {
$this->loadRoutesFrom(__DIR__.'/../routes/web.php');
});
// Register views
$this->loadViewsFrom(__DIR__.'/../resources/views', 'help');
// Register Blade components
Blade::component('help-icon', HelpIcon::class);
Blade::component('help-modal', HelpModal::class);
// Register migrations
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
// Publish assets
$this->publishes([
__DIR__.'/../resources/js' => public_path('js/components/help'),
__DIR__.'/../resources/css' => public_path('css/components/help'),
], 'help-assets');
// Publish views for customization
$this->publishes([
__DIR__.'/../resources/views' => resource_path('views/vendor/help'),
], 'help-views');
// Register the main help script to be included in the layout
view()->composer('*', function ($view) {
$view->with('helpScriptPath', asset('js/components/help/help-system.js'));
});
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Components\SoftDelete\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Traits\SoftDeleteConflictController;
/**
* SoftDeleteController
*
* This controller handles soft delete conflict resolution endpoints.
*/
class SoftDeleteController extends Controller
{
use SoftDeleteConflictController;
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Components\SoftDelete\Providers;
use Illuminate\Support\ServiceProvider;
class SoftDeleteServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
{
//
}
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
// Load routes
$this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
// Publish assets
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../resources/js' => public_path('js/soft-delete'),
], 'soft-delete-assets');
}
// Register blade components
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'soft-delete');
// Register the Blade component
\Illuminate\Support\Facades\Blade::component('soft-delete::components.conflict-modal', 'soft-delete-conflict-modal');
}
}

View File

@ -0,0 +1,127 @@
/**
* Soft Delete Conflict Handler
*
* This module handles the detection and resolution of soft delete conflicts.
* Save this file to public/js/soft-delete-conflict-handler.js
*/
const SoftDeleteConflictHandler = {
/**
* Initialize the handler for a specific form.
*
* @param {string} formSelector - The CSS selector for the form
* @param {string} modelClass - The fully qualified class name of the model
* @param {object} options - Additional options
*/
init: function(formSelector, modelClass, options = {}) {
const form = document.querySelector(formSelector);
if (!form) {
console.error(`Form not found: ${formSelector}`);
return;
}
// Store the model class on the form for later use
form.dataset.modelClass = modelClass;
// Set success and cancel redirect URLs if provided
if (options.successRedirect) {
form.dataset.successRedirect = options.successRedirect;
}
if (options.cancelRedirect) {
form.dataset.cancelRedirect = options.cancelRedirect;
}
// Intercept form submission
form.addEventListener('submit', (event) => {
// Don't prevent submission if we've already checked for conflicts
if (form.dataset.conflictChecked === 'true') {
return true;
}
// Prevent the default form submission
event.preventDefault();
// Check for conflicts
this.checkForConflicts(form, modelClass, (hasConflict, conflictData) => {
if (hasConflict) {
// Show the conflict resolution modal
this.showConflictModal(conflictData, form);
} else {
// No conflict, continue with form submission
form.dataset.conflictChecked = 'true';
form.submit();
}
});
});
},
/**
* Check for soft delete conflicts before form submission.
*
* @param {HTMLFormElement} form - The form element
* @param {string} modelClass - The fully qualified class name of the model
* @param {Function} callback - Callback function to handle the result
*/
checkForConflicts: function(form, modelClass, callback) {
// Create a FormData object from the form
const formData = new FormData(form);
// Add the model class
formData.append('model_class', modelClass);
// Send the request to check for conflicts
fetch('/admin/soft-delete/check-conflict', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.success && data.conflict) {
// Conflict found
callback(true, data.duplicate);
} else {
// No conflict
callback(false, null);
}
})
.catch(error => {
console.error('Error checking for conflicts:', error);
// On error, allow the form to submit to let server-side validation handle it
callback(false, null);
});
},
/**
* Show the conflict resolution modal.
*
* @param {object} conflictData - Data about the conflicting record
* @param {HTMLFormElement} form - The form element
*/
showConflictModal: function(conflictData, form) {
// Get form data as an object
const formDataObj = {};
const formData = new FormData(form);
for (const [key, value] of formData.entries()) {
formDataObj[key] = value;
}
// Dispatch a custom event to open the modal
window.dispatchEvent(new CustomEvent('open-soft-delete-modal', {
detail: {
conflict: conflictData,
formData: formDataObj,
formElement: form,
modelClass: form.dataset.modelClass
}
}));
}
};
// Expose to window for global access
window.SoftDeleteConflictHandler = SoftDeleteConflictHandler;

View File

@ -0,0 +1,213 @@
{{--
Save this file to resources/views/components/soft-delete-conflict-modal.blade.php
or to the appropriate component location in your structure
--}}
@props(['title' => 'Record Already Exists'])
<div
x-data="{ show: false, conflict: null, formData: null, formElement: null, modelClass: null }"
x-show="show"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
id="softDeleteConflictModal"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
x-on:open-soft-delete-modal.window="
show = true;
conflict = $event.detail.conflict;
formData = $event.detail.formData;
formElement = $event.detail.formElement;
modelClass = $event.detail.modelClass;
"
x-on:close-soft-delete-modal.window="show = false"
>
<div class="flex items-center justify-center min-h-screen p-4 text-center sm:p-0">
<div
x-show="show"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
aria-hidden="true"
@click="show = false"
></div>
<div
x-show="show"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"
>
<div class="sm:flex sm:items-start">
<div class="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-yellow-100 rounded-full sm:mx-0 sm:h-10 sm:w-10">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{{ $title }}
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
A record with the same unique identifier has been deleted previously. This might indicate a duplicate entry.
</p>
<div class="mt-4 p-3 bg-gray-50 rounded-lg">
<h4 class="text-sm font-medium text-gray-900">Deleted record details:</h4>
<div x-html="formatConflictDetails(conflict)" class="mt-2 text-sm text-gray-600"></div>
<div class="mt-1 text-xs text-gray-500">
Deleted at: <span x-text="conflict?.deleted_at || 'Unknown'"></span>
</div>
</div>
<p class="mt-4 text-sm text-gray-600">
You have the following options:
</p>
<ul class="mt-2 ml-5 list-disc text-sm text-gray-600">
<li>Restore the deleted record with your new values</li>
<li>Modify your current form input to avoid the conflict</li>
<li>Cancel the operation</li>
</ul>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
type="button"
class="inline-flex justify-center w-full px-4 py-2 text-base font-medium text-white bg-green-600 border border-transparent rounded-md shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm"
@click="resolveConflict()"
>
Restore Deleted Record
</button>
<button
type="button"
class="inline-flex justify-center w-full px-4 py-2 mt-3 text-base font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:w-auto sm:text-sm"
@click="show = false"
>
Modify Current Form
</button>
<button
type="button"
class="inline-flex justify-center w-full px-4 py-2 mt-3 mr-3 text-base font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:w-auto sm:text-sm"
@click="cancelOperation()"
>
Cancel
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('softDeleteConflictModal', () => ({
show: false,
conflict: null,
formData: null,
formElement: null,
modelClass: null,
formatConflictDetails(conflict) {
if (!conflict || !conflict.attributes) {
return 'No details available';
}
// Create a formatted HTML string with the conflict details
let html = '<dl class="grid grid-cols-2 gap-x-4 gap-y-2">';
// Skip internal fields like id, created_at, updated_at, deleted_at
const skipFields = ['id', 'created_at', 'updated_at', 'deleted_at'];
for (const [key, value] of Object.entries(conflict.attributes)) {
if (!skipFields.includes(key) && value !== null) {
html += `
<dt class="text-xs font-medium text-gray-500">${key}:</dt>
<dd class="text-xs text-gray-900">${value}</dd>
`;
}
}
html += '</dl>';
return html;
},
resolveConflict() {
if (!this.conflict || !this.formData || !this.modelClass) {
console.error('Missing required data for conflict resolution');
return;
}
// Create a form data object with the current form values
const formData = new FormData();
// Add all current form values
for (const [key, value] of Object.entries(this.formData)) {
formData.append(key, value);
}
// Add the model class
formData.append('model_class', this.modelClass);
// Send the request to restore the soft-deleted record
fetch(`/admin/soft-delete/resolve-conflict/${this.conflict.id}`, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show success message
this.showNotification('Success', data.message, 'success');
// Close the modal
this.show = false;
// Redirect to the appropriate page (usually the index page)
const form = this.formElement;
const redirectUrl = form.dataset.successRedirect || form.dataset.cancelRedirect || '/';
window.location.href = redirectUrl;
} else {
// Show error message
this.showNotification('Error', data.message || 'Failed to restore record', 'error');
}
})
.catch(error => {
console.error('Error resolving conflict:', error);
this.showNotification('Error', 'An unexpected error occurred', 'error');
});
},
cancelOperation() {
this.show = false;
// If a cancel URL is specified on the form, redirect to it
const form = this.formElement;
if (form && form.dataset.cancelRedirect) {
window.location.href = form.dataset.cancelRedirect;
}
},
showNotification(title, message, type) {
// This can be customized to use your preferred notification system
// For now, we'll use a simple alert
alert(`${title}: ${message}`);
}
}));
});
</script>
<style>
[x-cloak] { display: none !important; }
</style>

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Components\SoftDelete\Http\Controllers\SoftDeleteController;
/*
|--------------------------------------------------------------------------
| Soft Delete Conflict Resolution Routes
|--------------------------------------------------------------------------
|
| These routes provide endpoints for handling soft delete conflicts in the UI.
| They are prefixed with 'admin/soft-delete' and require authentication.
|
*/
Route::prefix('admin/soft-delete')->name('admin.soft-delete.')->middleware(['web', 'auth'])->group(function () {
Route::post('check-conflict', [SoftDeleteController::class, 'checkSoftDeleteConflict'])
->name('check-conflict');
Route::post('resolve-conflict/{id}', [SoftDeleteController::class, 'resolveSoftDeleteConflict'])
->name('resolve-conflict');
});

View File

@ -0,0 +1,171 @@
<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* SoftDeleteConflictController Trait
*
* This trait provides controller methods for handling soft delete conflicts in the UI.
*/
trait SoftDeleteConflictController
{
/**
* Check if a record would conflict with a soft-deleted record.
*
* @param Request $request
* @param string $modelClass The fully qualified class name of the model
* @return JsonResponse
*/
public function checkSoftDeleteConflict(Request $request, string $modelClass): JsonResponse
{
if (!class_exists($modelClass)) {
return response()->json([
'success' => false,
'message' => 'Invalid model class provided',
], 400);
}
try {
// Create a new instance of the model
/** @var Model $model */
$model = new $modelClass();
// Ensure the model uses SoftDeleteResolution trait
if (!method_exists($model, 'findSoftDeletedDuplicate')) {
return response()->json([
'success' => false,
'message' => 'Model does not support soft delete conflict resolution',
], 400);
}
// Only include fields that are present in request
$attributes = $request->only($model->getFillable());
// If editing an existing record, pass the ID to exclude it from the check
if ($request->has('id')) {
$attributes['id'] = $request->input('id');
}
// Check for soft-deleted duplicate
$duplicate = $model->findSoftDeletedDuplicate($attributes);
if ($duplicate) {
// Return details about the conflicting record
return response()->json([
'success' => true,
'conflict' => true,
'message' => 'A deleted record with the same unique identifier exists.',
'duplicate' => [
'id' => $duplicate->getKey(),
'attributes' => $duplicate->toArray(),
'deleted_at' => $duplicate->deleted_at->format('Y-m-d H:i:s'),
],
]);
}
// No conflict found
return response()->json([
'success' => true,
'conflict' => false,
'message' => 'No conflict found',
]);
} catch (\Exception $e) {
Log::error('Error checking for soft delete conflict', [
'error' => $e->getMessage(),
'model' => $modelClass,
'data' => $request->all(),
]);
return response()->json([
'success' => false,
'message' => 'An error occurred while checking for conflicts',
'error' => $e->getMessage(),
], 500);
}
}
/**
* Resolve a soft delete conflict by restoring the deleted record.
*
* @param Request $request
* @param string $modelClass The fully qualified class name of the model
* @param mixed $id The ID of the soft-deleted record to restore
* @return JsonResponse
*/
public function resolveSoftDeleteConflict(Request $request, string $modelClass, $id): JsonResponse
{
if (!class_exists($modelClass)) {
return response()->json([
'success' => false,
'message' => 'Invalid model class provided',
], 400);
}
try {
// Attempt to find the soft-deleted record
/** @var Model $model */
$modelInstance = new $modelClass();
// Ensure the model uses SoftDeleteResolution trait
if (!method_exists($modelInstance, 'restoreSoftDeletedRecord')) {
return response()->json([
'success' => false,
'message' => 'Model does not support soft delete conflict resolution',
], 400);
}
$deletedRecord = $modelClass::withTrashed()->find($id);
if (!$deletedRecord) {
return response()->json([
'success' => false,
'message' => 'Deleted record not found',
], 404);
}
if (!$deletedRecord->trashed()) {
return response()->json([
'success' => false,
'message' => 'Record is not deleted',
], 400);
}
// Only include fields that are present in request and fillable
$attributes = $request->only($modelInstance->getFillable());
// Restore the record with updated attributes
$restored = $modelInstance->restoreSoftDeletedRecord($deletedRecord, $attributes);
if (!$restored) {
return response()->json([
'success' => false,
'message' => 'Failed to restore record',
], 500);
}
return response()->json([
'success' => true,
'message' => 'Record restored successfully',
'record' => $restored->fresh()->toArray(),
]);
} catch (\Exception $e) {
Log::error('Error restoring soft-deleted record', [
'error' => $e->getMessage(),
'model' => $modelClass,
'id' => $id,
'data' => $request->all(),
]);
return response()->json([
'success' => false,
'message' => 'An error occurred while restoring the record',
'error' => $e->getMessage(),
], 500);
}
}
}

View File

@ -0,0 +1,244 @@
<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
/**
* SoftDeleteResolution Trait
*
* This trait provides functionality for handling conflicts with soft-deleted records
* that violate unique constraints when creating or updating models.
*
* Usage:
* 1. Add this trait to any model that uses SoftDeletes
* 2. Define uniqueFields() method in your model to specify which fields should be considered unique
* 3. Use findSoftDeletedDuplicate() to detect conflicts before saving
* 4. Use restoreSoftDeletedRecord() to restore a soft-deleted record with updated attributes
*
* Example:
* ```
* class User extends Model
* {
* use SoftDeletes, SoftDeleteResolution;
*
* // Define which fields should be considered for uniqueness checks
* protected function uniqueFields(): array
* {
* return ['email'];
* }
* }
*
* // In your controller:
* $newUser = new User(['name' => 'John', 'email' => 'john@example.com']);
*
* // Check for soft-deleted duplicate
* $duplicate = $newUser->findSoftDeletedDuplicate();
*
* if ($duplicate) {
* // Restore with updated attributes
* $restored = $newUser->restoreSoftDeletedRecord($duplicate, ['name' => 'John']);
* return "Restored previously deleted user with updated details.";
* }
*
* // Otherwise, proceed with normal save
* $newUser->save();
* ```
*/
trait SoftDeleteResolution
{
/**
* Check if the current model has the SoftDeletes trait
*
* @return bool
*/
protected function usesSoftDeletes(): bool
{
return in_array(SoftDeletes::class, class_uses_recursive($this));
}
/**
* Get fields that should be considered unique for this model
* This method should be implemented in the model using this trait
*
* @return array An array of field names that should be unique
*/
protected function uniqueFields(): array
{
// Default implementation returns empty array
// Models should override this method to specify their unique fields
return [];
}
/**
* Check if there's a soft-deleted record that would conflict with this one
*
* @param array $attributes Optional custom attributes to check instead of the model's current attributes
* @return Model|null The conflicting soft-deleted model, or null if none exists
*/
public function findSoftDeletedDuplicate(array $attributes = []): ?Model
{
// Ensure the model uses soft deletes
if (!$this->usesSoftDeletes()) {
Log::warning('SoftDeleteResolution trait used on model without SoftDeletes', [
'model' => get_class($this)
]);
return null;
}
// Get unique fields for this model
$uniqueFields = $this->uniqueFields();
if (empty($uniqueFields)) {
Log::warning('No unique fields defined for model using SoftDeleteResolution', [
'model' => get_class($this)
]);
return null;
}
try {
// Use provided attributes or current model attributes
$checkAttributes = empty($attributes) ? $this->getAttributes() : $attributes;
// Build query to find soft-deleted duplicates
$query = static::onlyTrashed();
foreach ($uniqueFields as $field) {
// Only include fields that exist in checkAttributes and have a value
if (array_key_exists($field, $checkAttributes) && !is_null($checkAttributes[$field])) {
$query->where($field, $checkAttributes[$field]);
}
}
// Return the first matching soft-deleted record
return $query->first();
} catch (\Exception $e) {
Log::error('Error checking for soft-deleted duplicates', [
'model' => get_class($this),
'error' => $e->getMessage(),
'attributes' => $attributes ?: $this->getAttributes()
]);
return null;
}
}
/**
* Restore a soft-deleted record and optionally update its attributes
*
* @param Model $model The soft-deleted model to restore
* @param array $attributes New attributes to apply during restoration
* @return Model|null The restored model, or null if restoration failed
*/
public function restoreSoftDeletedRecord(Model $model, array $attributes = []): ?Model
{
// Ensure the model is actually soft-deleted
if (!method_exists($model, 'restore') || is_null($model->deleted_at)) {
Log::warning('Attempted to restore a model that is not soft-deleted', [
'model' => get_class($model),
'id' => $model->getKey()
]);
return null;
}
try {
// Begin transaction to ensure atomicity
DB::beginTransaction();
// Apply any new attributes
if (!empty($attributes)) {
$model->fill($attributes);
}
// Restore the model (removes deleted_at timestamp)
$model->restore();
// Save any updated attributes
if (!empty($attributes)) {
$model->save();
}
// Commit transaction
DB::commit();
Log::info('Successfully restored soft-deleted record', [
'model' => get_class($model),
'id' => $model->getKey(),
'attributes_updated' => !empty($attributes)
]);
return $model;
} catch (\Exception $e) {
// Rollback transaction on error
DB::rollBack();
Log::error('Failed to restore soft-deleted record', [
'model' => get_class($model),
'id' => $model->getKey(),
'error' => $e->getMessage(),
'attributes' => $attributes
]);
return null;
}
}
/**
* Detect if a QueryException is due to a unique constraint violation on a soft-deleted record
*
* @param QueryException $exception The exception to check
* @return bool True if the exception is caused by a conflict with a soft-deleted record
*/
public function isSoftDeletedUniqueConstraintViolation(QueryException $exception): bool
{
// Check if the model uses soft deletes
if (!$this->usesSoftDeletes()) {
return false;
}
// Check if the exception is due to a unique constraint violation
// PostgreSQL error code 23505 is for unique_violation
$isPgsqlUniqueViolation = $exception->getCode() === '23505';
// SQLite and MySQL use different error codes
$isMysqlUniqueViolation = strpos($exception->getMessage(), 'Duplicate entry') !== false;
$isSqliteUniqueViolation = strpos($exception->getMessage(), 'UNIQUE constraint failed') !== false;
// If it's not a unique constraint violation, return false
if (!($isPgsqlUniqueViolation || $isMysqlUniqueViolation || $isSqliteUniqueViolation)) {
return false;
}
// At this point, we have a unique constraint violation
// Now we need to check if the conflicting record is soft-deleted
$uniqueFields = $this->uniqueFields();
if (empty($uniqueFields)) {
return false;
}
// Check if there's a soft-deleted record with the same unique values
return $this->findSoftDeletedDuplicate() !== null;
}
/**
* Attempt to resolve a unique constraint violation by restoring a soft-deleted record
*
* @param array $attributes Attributes for the new or updated record
* @return Model|null The restored model, or null if no resolution was possible
*/
public function resolveSoftDeletedConflict(array $attributes = []): ?Model
{
$duplicate = $this->findSoftDeletedDuplicate($attributes);
if ($duplicate) {
return $this->restoreSoftDeletedRecord($duplicate, $attributes);
}
return null;
}
}

View File

@ -0,0 +1,110 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use App\Helpers\Timer;
use Symfony\Component\Console\Output\ConsoleOutput;
return new class extends Migration
{
protected $connection = 'pgsql';
protected $schema = 'config';
protected $basename = 'help_content';
protected ConsoleOutput $msg;
protected int $msgKtr = 0;
public function __construct()
{
$this->msg = new ConsoleOutput();
}
public function upInstructions(): void
{
$this->writeMsg("Creating table: {$this->schema}.tbl_{$this->basename}");
$tblDef = "CREATE TABLE IF NOT EXISTS {$this->schema}.tbl_{$this->basename}
(
id BIGSERIAL,
context_key VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
version INTEGER DEFAULT 1,
is_active BOOLEAN DEFAULT true,
created_at timestamp(0) without time zone,
updated_at timestamp(0) without time zone,
deleted_at timestamp(0) with time zone,
CONSTRAINT pk_{$this->basename}_id PRIMARY KEY (id)
)
";
$idxs = ([
"CREATE UNIQUE INDEX unq_{$this->basename}_context_key_version ON {$this->schema}.tbl_{$this->basename} (context_key, version)",
"CREATE INDEX idx_{$this->basename}_context_key ON {$this->schema}.tbl_{$this->basename} (context_key)",
"CREATE INDEX idx_{$this->basename}_created_at ON {$this->schema}.tbl_{$this->basename} (created_at)",
"CREATE INDEX idx_{$this->basename}_updated_at ON {$this->schema}.tbl_{$this->basename} (updated_at)",
"CREATE INDEX idx_{$this->basename}_deleted_at ON {$this->schema}.tbl_{$this->basename} (deleted_at)"
]);
DB::connection($this->connection)->statement($tblDef);
foreach($idxs AS $idx){
DB::connection($this->connection)->statement($idx);
}
$this->writeMsg("Creating view: {$this->schema}.{$this->basename}");
$viewDef = "SELECT * FROM {$this->schema}.tbl_{$this->basename} WHERE deleted_at IS NULL";
$viewQry = "CREATE OR REPLACE VIEW {$this->schema}.{$this->basename} AS {$viewDef}";
DB::connection($this->connection)->statement($viewQry);
$this->writeMsg("Creating view: {$this->schema}.vdel_{$this->basename}");
$viewDef = "SELECT * FROM {$this->schema}.tbl_{$this->basename} WHERE deleted_at IS NOT NULL";
$viewQry = "CREATE OR REPLACE VIEW {$this->schema}.vdel_{$this->basename} AS {$viewDef}";
DB::connection($this->connection)->statement($viewQry);
}
public function downInstructions(): void
{
$this->writeMsg("Dropping view: {$this->schema}.vdel_{$this->basename}");
$dropViewQry = "DROP VIEW IF EXISTS {$this->schema}.vdel_{$this->basename}";
DB::connection($this->connection)->statement($dropViewQry);
$this->writeMsg("Dropping view: {$this->schema}.{$this->basename}");
$dropViewQry = "DROP VIEW IF EXISTS {$this->schema}.{$this->basename}";
DB::connection($this->connection)->statement($dropViewQry);
$this->writeMsg("Dropping table: {$this->schema}.tbl_{$this->basename}");
$dropTblQry = "DROP TABLE IF EXISTS {$this->schema}.tbl_{$this->basename}";
DB::connection($this->connection)->statement($dropTblQry);
}
/**
* Run the migrations.
*/
public function up(): void
{
$upTimer = new Timer();
$this->upInstructions();
$this->writeMsg("This task took {$upTimer->getElapsedTime()} minutes");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$downTimer = new Timer();
$this->downInstructions();
$this->writeMsg("This task took {$downTimer->getElapsedTime()} minutes");
}
// Output messages to the console when the
public function writeMsg($msg): void
{
if(!$this->msgKtr){
$this->msg->writeln('');
}
$this->msgKtr++;
$this->msg->writeln("$this->msgKtr) $msg");
}
};