From 1bbe96c246f74edad358ae79072f3469af1b8ddb Mon Sep 17 00:00:00 2001 From: trogers1884 Date: Thu, 13 Mar 2025 19:50:56 -0500 Subject: [PATCH] WIP --- app/Components/Help/Models/HelpContent.php | 136 ++++++++++ .../Help/Providers/HelpServiceProvider.php | 58 +++++ .../Http/Controllers/SoftDeleteController.php | 16 ++ .../Providers/SoftDeleteServiceProvider.php | 42 +++ .../js/soft-delete-conflict-handler.js | 127 +++++++++ .../views/components/conflict-modal.blade.php | 213 +++++++++++++++ app/Components/SoftDelete/routes/web.php | 22 ++ app/Traits/SoftDeleteConflictController.php | 171 ++++++++++++ app/Traits/SoftDeleteResolution.php | 244 ++++++++++++++++++ ...0_004509_create.config.hep_content_001.php | 110 ++++++++ 10 files changed, 1139 insertions(+) create mode 100644 app/Components/Help/Models/HelpContent.php create mode 100644 app/Components/Help/Providers/HelpServiceProvider.php create mode 100644 app/Components/SoftDelete/Http/Controllers/SoftDeleteController.php create mode 100644 app/Components/SoftDelete/Providers/SoftDeleteServiceProvider.php create mode 100644 app/Components/SoftDelete/resources/js/soft-delete-conflict-handler.js create mode 100644 app/Components/SoftDelete/resources/views/components/conflict-modal.blade.php create mode 100644 app/Components/SoftDelete/routes/web.php create mode 100644 app/Traits/SoftDeleteConflictController.php create mode 100644 app/Traits/SoftDeleteResolution.php create mode 100644 database/migrations/config/2025_03_10_004509_create.config.hep_content_001.php diff --git a/app/Components/Help/Models/HelpContent.php b/app/Components/Help/Models/HelpContent.php new file mode 100644 index 0000000..cc882be --- /dev/null +++ b/app/Components/Help/Models/HelpContent.php @@ -0,0 +1,136 @@ + '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(); + } +} diff --git a/app/Components/Help/Providers/HelpServiceProvider.php b/app/Components/Help/Providers/HelpServiceProvider.php new file mode 100644 index 0000000..aba20c7 --- /dev/null +++ b/app/Components/Help/Providers/HelpServiceProvider.php @@ -0,0 +1,58 @@ +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')); + }); + } +} diff --git a/app/Components/SoftDelete/Http/Controllers/SoftDeleteController.php b/app/Components/SoftDelete/Http/Controllers/SoftDeleteController.php new file mode 100644 index 0000000..c38f9f7 --- /dev/null +++ b/app/Components/SoftDelete/Http/Controllers/SoftDeleteController.php @@ -0,0 +1,16 @@ +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'); + } +} diff --git a/app/Components/SoftDelete/resources/js/soft-delete-conflict-handler.js b/app/Components/SoftDelete/resources/js/soft-delete-conflict-handler.js new file mode 100644 index 0000000..c4a9446 --- /dev/null +++ b/app/Components/SoftDelete/resources/js/soft-delete-conflict-handler.js @@ -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; diff --git a/app/Components/SoftDelete/resources/views/components/conflict-modal.blade.php b/app/Components/SoftDelete/resources/views/components/conflict-modal.blade.php new file mode 100644 index 0000000..382a064 --- /dev/null +++ b/app/Components/SoftDelete/resources/views/components/conflict-modal.blade.php @@ -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']) + + + + + + diff --git a/app/Components/SoftDelete/routes/web.php b/app/Components/SoftDelete/routes/web.php new file mode 100644 index 0000000..1420e85 --- /dev/null +++ b/app/Components/SoftDelete/routes/web.php @@ -0,0 +1,22 @@ +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'); +}); diff --git a/app/Traits/SoftDeleteConflictController.php b/app/Traits/SoftDeleteConflictController.php new file mode 100644 index 0000000..e1a4320 --- /dev/null +++ b/app/Traits/SoftDeleteConflictController.php @@ -0,0 +1,171 @@ +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); + } + } +} diff --git a/app/Traits/SoftDeleteResolution.php b/app/Traits/SoftDeleteResolution.php new file mode 100644 index 0000000..2be1b62 --- /dev/null +++ b/app/Traits/SoftDeleteResolution.php @@ -0,0 +1,244 @@ + '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; + } +} diff --git a/database/migrations/config/2025_03_10_004509_create.config.hep_content_001.php b/database/migrations/config/2025_03_10_004509_create.config.hep_content_001.php new file mode 100644 index 0000000..9e8c6de --- /dev/null +++ b/database/migrations/config/2025_03_10_004509_create.config.hep_content_001.php @@ -0,0 +1,110 @@ +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"); + } +};