From 36494a79446d5c0ec7f8338e822b367347eb7fe8 Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Thu, 7 Aug 2025 16:07:58 +0200 Subject: [PATCH 1/3] feat: :sparkles: add brand fields (logo/domain/url/url_claim) --- .../Requests/Bo/Brands/StoreBrandRequest.php | 30 +++++- .../Requests/Bo/Brands/UpdateBrandRequest.php | 30 ++++++ app/Models/Brand.php | 15 ++- database/factories/BrandFactory.php | 4 +- ..._171943_add_unique_key_on_brands_table.php | 52 +++++++++++ resources/lang/en/validation.php | 4 + resources/lang/fr/validation.php | 4 + .../views/bo/pages/brands/create.blade.php | 2 +- .../views/bo/pages/brands/edit.blade.php | 2 +- .../views/bo/pages/brands/form.blade.php | 93 ++++++++++++++++++- .../partials/inputs/varchar-picture.blade.php | 25 ++--- 11 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 database/migrations/2025_08_07_171943_add_unique_key_on_brands_table.php diff --git a/app/Http/Requests/Bo/Brands/StoreBrandRequest.php b/app/Http/Requests/Bo/Brands/StoreBrandRequest.php index 74af912..b1b81c0 100644 --- a/app/Http/Requests/Bo/Brands/StoreBrandRequest.php +++ b/app/Http/Requests/Bo/Brands/StoreBrandRequest.php @@ -3,9 +3,12 @@ namespace App\Http\Requests\Bo\Brands; use Illuminate\Foundation\Http\FormRequest; +use Uwil\LaravelCommon\Traits\Requests\HasPicture; class StoreBrandRequest extends FormRequest { + use HasPicture; + /** * Determine if the user is authorized to make this request. * @@ -16,6 +19,16 @@ class StoreBrandRequest extends FormRequest return auth('backend')->check(); } + /** + * Prepare the data for validation. + * + * @return void + */ + protected function prepareForValidation(): void + { + $this->mergePicture('logo'); + } + /** * Get the validation rules that apply to the request. * @@ -23,9 +36,16 @@ class StoreBrandRequest extends FormRequest */ public function rules(): array { - return [ - 'name' => 'required|string|min:5|max:255', + $rules = [ + 'name' => 'required|unique:brands,name|string|min:5|max:255', + 'url' => 'required|url|min:5|max:255', + 'url_claim' => 'required|url|min:5|max:255', + 'domain' => 'required|unique:brands,domain|string|min:5|max:255', ]; + return \array_merge( + $rules, + $this->pictureRules('logo', 550, 550, 550, 550), + ); } /** @@ -36,7 +56,11 @@ class StoreBrandRequest extends FormRequest public function attributes(): array { return [ - 'name' => trans('Nom de la marque'), + 'name' => trans('validation.attributes.name'), + 'logo' => trans('validation.custom.logo'), + 'url' => trans('validation.custom.url'), + 'url_claim' => trans('validation.custom.url_claim'), + 'domain' => trans('validation.custom.domain'), ]; } } diff --git a/app/Http/Requests/Bo/Brands/UpdateBrandRequest.php b/app/Http/Requests/Bo/Brands/UpdateBrandRequest.php index 976fef4..0871ed5 100644 --- a/app/Http/Requests/Bo/Brands/UpdateBrandRequest.php +++ b/app/Http/Requests/Bo/Brands/UpdateBrandRequest.php @@ -4,4 +4,34 @@ namespace App\Http\Requests\Bo\Brands; class UpdateBrandRequest extends StoreBrandRequest { + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules(): array + { + /** @var integer $brandModelKey */ + $brandModelKey = request()->route()->parameter('brand')->getKey(); + $rules = [ + 'name' => [ + 'required', + "unique:brands,name,$brandModelKey", + 'string', + 'min:5', + 'max:255', + ], + 'domain' => [ + 'required', + "unique:brands,domain,$brandModelKey", + 'string', + 'min:5', + 'max:255', + ], + ]; + return \array_merge( + parent::rules(), + $rules, + ); + } } diff --git a/app/Models/Brand.php b/app/Models/Brand.php index 5a41323..64267f7 100644 --- a/app/Models/Brand.php +++ b/app/Models/Brand.php @@ -7,6 +7,7 @@ use App\Traits\Models\ActivityLog; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; +use Uwil\LaravelCommon\Helpers\FileHelper; /** * @property integer $id Brand id. @@ -54,14 +55,22 @@ class Brand extends Model */ protected static function booted(): void { + static::creating(function (self $model) { + $model->logo = FileHelper::storeFile($model, $model->logo, true); + }); static::created(function () { self::resetCache(); }); - static::updated(function () { - self::resetCache(); + static::updating(function (self $model) { + $model->logo = FileHelper::storeFile($model, $model->logo, true); }); - static::deleted(function () { + static::updated(function (self $model) { self::resetCache(); + FileHelper::removeOldFile($model, 'logo'); + }); + static::deleted(function (self $model) { + self::resetCache(); + FileHelper::removeOldFile($model, 'logo'); }); } diff --git a/database/factories/BrandFactory.php b/database/factories/BrandFactory.php index b005079..f77185b 100644 --- a/database/factories/BrandFactory.php +++ b/database/factories/BrandFactory.php @@ -22,10 +22,10 @@ class BrandFactory extends Factory public function definition(): array { return [ - 'name' => $this->faker->company, + 'name' => $this->faker->unique()->company, 'url' => $this->faker->url, 'url_claim' => $this->faker->url, - 'domain' => $this->faker->domainName, + 'domain' => $this->faker->unique()->domainName, ]; } } diff --git a/database/migrations/2025_08_07_171943_add_unique_key_on_brands_table.php b/database/migrations/2025_08_07_171943_add_unique_key_on_brands_table.php new file mode 100644 index 0000000..0046b18 --- /dev/null +++ b/database/migrations/2025_08_07_171943_add_unique_key_on_brands_table.php @@ -0,0 +1,52 @@ +get()->map(function (Brand $brandModel) { + $brandModel->logo = FileHelper::storeFile( + $brandModel, + new \SplFileInfo( + \resource_path('../database/factories/assets/brands/default-logo-550x550.jpg') + ) + ); + $urlExample = "https://example-{$brandModel->getKey()}.com"; + $brandModel->url = $urlExample; + $brandModel->url_claim = $urlExample; + $brandModel->domain = parse_url($urlExample, PHP_URL_HOST); + $brandModel->saveOrFail(); + }); + + // Set unique keys. + Schema::table('brands', function (Blueprint $table) { + $table->unique('name', 'brands_name_unique'); + $table->unique('domain', 'brands_domain_unique'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::table('brands', function (Blueprint $table) { + $table->dropUnique('brands_name_unique'); + $table->dropUnique('brands_domain_unique'); + }); + } +}; diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index d9ca963..60a633d 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -233,5 +233,9 @@ return [ 'type' => 'type', 'status' => 'status', 'label' => 'label', + 'domain' => 'domain', + 'logo' => 'logo', + 'url' => 'url', + 'url_claim' => 'claim url', ], ]; diff --git a/resources/lang/fr/validation.php b/resources/lang/fr/validation.php index 5f9da36..5138f0c 100644 --- a/resources/lang/fr/validation.php +++ b/resources/lang/fr/validation.php @@ -233,5 +233,9 @@ return [ 'type' => 'type', 'status' => 'status', 'label' => 'label', + 'domain' => 'domaine', + 'logo' => 'logo', + 'url' => 'lien', + 'url_claim' => 'lien réclamation', ], ]; diff --git a/resources/views/bo/pages/brands/create.blade.php b/resources/views/bo/pages/brands/create.blade.php index 29f92cc..0b6cb2c 100644 --- a/resources/views/bo/pages/brands/create.blade.php +++ b/resources/views/bo/pages/brands/create.blade.php @@ -29,7 +29,7 @@
-
+ @csrf @include('bo.pages.brands.form')
diff --git a/resources/views/bo/pages/brands/edit.blade.php b/resources/views/bo/pages/brands/edit.blade.php index 2a20d0c..16f1457 100644 --- a/resources/views/bo/pages/brands/edit.blade.php +++ b/resources/views/bo/pages/brands/edit.blade.php @@ -29,7 +29,7 @@
- + @csrf @method('PUT') @include('bo.pages.brands.form') diff --git a/resources/views/bo/pages/brands/form.blade.php b/resources/views/bo/pages/brands/form.blade.php index 1200811..8233c38 100644 --- a/resources/views/bo/pages/brands/form.blade.php +++ b/resources/views/bo/pages/brands/form.blade.php @@ -2,22 +2,111 @@ {{-- TITLE --}}
- {{ __('Nom') }} + {{ str(__('validation.attributes.name'))->ucfirst() }}
@include('bo.partials.inputs.varchar', [ 'id' => 'brandName', - 'label' => __('Nom de la :model', ['model' => Str::singular(__('models.classes.brands'))]), + 'label' => __('Nom de la :model', ['model' => str(__('models.classes.brands'))->singular()]), 'hasWordCounter' => true, 'name' => 'name', 'required' => true, 'model' => $brandModel ?? null, + 'helper' => __('Doit décrire précisément la :model (3 caractères minimum, 255 maximum).', [ + 'model' => str(__('models.classes.brands'))->singular(), + ]), + ]) +
+
+
+
+ {{-- DOMAIN --}} +
+
+ {{ str(__('validation.custom.domain'))->ucfirst() }} +
+
+ @include('bo.partials.inputs.varchar', [ + 'id' => 'brandDomain', + 'label' => __(':field de la :model', [ + 'field' => str(__('validation.custom.domain'))->ucfirst(), + 'model' => str(__('models.classes.brands'))->singular(), + ]), + 'hasWordCounter' => true, + 'name' => 'domain', + 'required' => true, + 'model' => $brandModel ?? null, + 'placeholder' => parse_url('https://example.com/', PHP_URL_HOST), 'helper' => __('Doit décrire précisément le produit (3 caractères minimum, 255 maximum).'), ])
+ {{-- URL --}} +
+
+ {{ str(__('validation.custom.url'))->ucfirst() }} +
+
+ @include('bo.partials.inputs.varchar', [ + 'id' => 'brandUrl', + 'label' => __(':field de la :model', [ + 'field' => str(__('validation.custom.url'))->ucfirst(), + 'model' => str(__('models.classes.brands'))->singular(), + ]), + 'hasWordCounter' => true, + 'name' => 'url', + 'required' => true, + 'model' => $brandModel ?? null, + 'placeholder' => 'https://example.com/', + 'helper' => __('Doit décrire précisément le produit (3 caractères minimum, 255 maximum).'), + ]) +
+
+
+
+ {{-- URL_CLAIM --}} +
+
+ {{ str(__('validation.custom.url_claim'))->ucfirst() }} +
+
+ @include('bo.partials.inputs.varchar', [ + 'id' => 'brandUrlClaim', + 'label' => __(':field de la :model', [ + 'field' => str(__('validation.custom.url_claim'))->ucfirst(), + 'model' => str(__('models.classes.brands'))->singular(), + ]), + 'hasWordCounter' => true, + 'name' => 'url_claim', + 'required' => true, + 'model' => $brandModel ?? null, + 'placeholder' => 'https://example.com/', + 'helper' => __('Doit décrire précisément le produit (3 caractères minimum, 255 maximum).'), + ]) +
+
+
+
+
+
+ {{ str(__('validation.custom.url_claim'))->ucfirst() }} +
+
+ @include('bo.partials.inputs.varchar-picture', [ + 'id' => 'brandLogo', + 'name' => 'logo', + 'width' => 550, + 'height' => 550, + 'model' => $brandModel ?? null, + 'required' => true, + 'preview' => true, + ]) +
+
+
+
@push('scripts') diff --git a/resources/views/bo/partials/inputs/varchar-picture.blade.php b/resources/views/bo/partials/inputs/varchar-picture.blade.php index c41f82b..c9f3db6 100644 --- a/resources/views/bo/partials/inputs/varchar-picture.blade.php +++ b/resources/views/bo/partials/inputs/varchar-picture.blade.php @@ -15,17 +15,20 @@ --}} @php -$data = [ - 'id' => $id, - 'name' => $name, - 'helper' => $helper, - 'width' => $width, - 'height' => $height, - 'value' => $model->{$name} ?? $value ?? '', - 'preview' => $preview ?? false, - 'browseEvent' => $browseEvent ?? false, - 'required' => $required ?? false, -]; + $data = [ + 'id' => $id, + 'name' => $name, + 'helper' => __('crud.minicrud.dimensions_must_be', [ + 'widthpx' => "{$width}px", + 'heightpx' => "{$height}px", + ]), + 'width' => $width, + 'height' => $height, + 'value' => $model->{$name} ?? ($value ?? ''), + 'preview' => $preview ?? false, + 'browseEvent' => $browseEvent ?? false, + 'required' => $required ?? false, + ]; @endphp
@include('modules.input-error', ['inputName' => $name, 'helper' => null]) From da8074c5a45c73d663b0806515be03a80125085d Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Thu, 7 Aug 2025 16:17:24 +0200 Subject: [PATCH 2/3] fix: :bug: reorder function parameters to avoid PHP 8.0+ optional parameter warning --- app/Exceptions/Handler.php | 4 ++-- app/Http/Middleware/ApiResponse.php | 5 ++--- app/Http/Resources/Api/V2/ApiResponse.php | 4 ++-- app/Http/Resources/Api/V4/ApiResponse.php | 8 ++++---- tests/CRM/ApiV4/SingleOptinTest.php | 6 +++--- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 1d74c47..cfebc70 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -180,13 +180,13 @@ class Handler extends ExceptionHandler ): JsonResponse { if (Helper::isRequestForApiV2($request)) { return (new V2ApiResponse( + ApiStatus::KO, (object)array_merge($data, ['message' => $message, 'code' => $apiCode]), - ApiStatus::KO ))->toResponse($request)->setStatusCode($httpCode); } return (new V4ApiResponse( + ApiStatus::KO, (object)array_merge($data, ['message' => $message, 'code' => $apiCode]), - ApiStatus::KO ))->toResponse($request)->setStatusCode($httpCode); } } diff --git a/app/Http/Middleware/ApiResponse.php b/app/Http/Middleware/ApiResponse.php index fbbbd91..568bdd8 100644 --- a/app/Http/Middleware/ApiResponse.php +++ b/app/Http/Middleware/ApiResponse.php @@ -57,9 +57,8 @@ class ApiResponse $status = intval($response->getStatusCode() / 100) == 2 ? ApiStatus::OK : ApiStatus::KO; if (Helper::isRequestForApiV2($request)) { - return $response->setData(new V2ApiResponse((object)$data, $status)); + return $response->setData(new V2ApiResponse($status, (object)$data)); } - - return $response->setData(new V4ApiResponse((object)$data, $status)); + return $response->setData(new V4ApiResponse($status, (object)$data)); } } diff --git a/app/Http/Resources/Api/V2/ApiResponse.php b/app/Http/Resources/Api/V2/ApiResponse.php index 3d60a62..5209420 100644 --- a/app/Http/Resources/Api/V2/ApiResponse.php +++ b/app/Http/Resources/Api/V2/ApiResponse.php @@ -16,10 +16,10 @@ class ApiResponse extends JsonResource protected $status; /** - * @param \stdClass|null $data * @param \App\Enums\ApiStatus $status + * @param \stdClass|null $data */ - public function __construct(stdClass|null $data = null, ApiStatus $status) + public function __construct(ApiStatus $status, stdClass|null $data = null) { $this->status = $status; $this->data = $data; diff --git a/app/Http/Resources/Api/V4/ApiResponse.php b/app/Http/Resources/Api/V4/ApiResponse.php index 66d0bb7..1c1ca23 100644 --- a/app/Http/Resources/Api/V4/ApiResponse.php +++ b/app/Http/Resources/Api/V4/ApiResponse.php @@ -16,10 +16,10 @@ class ApiResponse extends JsonResource protected $status; /** - * @param \stdClass|null $data * @param \App\Enums\ApiStatus $status + * @param \stdClass|null $data */ - public function __construct(stdClass|null $data = null, ApiStatus $status) + public function __construct(ApiStatus $status, stdClass|null $data = null) { $this->status = $status; $this->data = $data; @@ -36,8 +36,8 @@ class ApiResponse extends JsonResource { //phpcs:enable return [ - 'status' => $this->status->value(), - 'data' => (array)($this->data ?? []), + 'status' => $this->status->value(), + 'data' => (array)($this->data ?? []), 'version' => config('api.version') ]; } diff --git a/tests/CRM/ApiV4/SingleOptinTest.php b/tests/CRM/ApiV4/SingleOptinTest.php index fd43ad7..bb398f0 100644 --- a/tests/CRM/ApiV4/SingleOptinTest.php +++ b/tests/CRM/ApiV4/SingleOptinTest.php @@ -41,7 +41,7 @@ class SingleOptinTest extends ApiBase /** * Test set single optin on with answer associated. - * route: apiV2.optin + * route: apiV4.optin * * @return void */ @@ -57,7 +57,7 @@ class SingleOptinTest extends ApiBase // * Make sure a customer answer is set. $firstAnswer = $questions->first()->answers->first(); - $response = $this->callApiWithToken($service, 'post', route('apiV2.optin'), [ + $response = $this->callApiWithToken($service, 'post', route('apiV4.optin'), [ 'email' => 'singleoptin@hotmail.fr', 'opt_in' => 1, 'answer_id' => [$firstAnswer->getkey()], @@ -134,7 +134,7 @@ class SingleOptinTest extends ApiBase public function testSingleOptinShouldFailIfAnswerIdDoesntExist(): void { $service = Service::query()->firstOrFail(); - $response = $this->callApiWithToken($service, 'post', route('apiV2.optin'), [ + $response = $this->callApiWithToken($service, 'post', route('apiV4.optin'), [ 'email' => 'singleoptin@hotmail.fr', 'opt_in' => 1, 'answer_id' => [0], From 351126ebf89d2b2f3f8c027f032a798406e8036a Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Thu, 7 Aug 2025 16:18:47 +0200 Subject: [PATCH 3/3] fix: :bug: update answer cache and cascade delete --- .../Controllers/Api/V4/OptinController.php | 2 +- app/Http/Controllers/Bo/AnswerController.php | 1 + .../Controllers/Bo/SingleOptinController.php | 7 +++--- app/Http/Middleware/Authenticate.php | 2 +- app/Models/Answer.php | 25 ++++++------------- app/Traits/Requests/.gitkeep | 0 6 files changed, 15 insertions(+), 22 deletions(-) delete mode 100644 app/Traits/Requests/.gitkeep diff --git a/app/Http/Controllers/Api/V4/OptinController.php b/app/Http/Controllers/Api/V4/OptinController.php index 77d4543..4c2272f 100644 --- a/app/Http/Controllers/Api/V4/OptinController.php +++ b/app/Http/Controllers/Api/V4/OptinController.php @@ -36,7 +36,7 @@ class OptinController extends Controller $singleOptin = SingleOptin::query() ->where('email', $validatedData['email']) ->whereHas('brand', function ($q) use ($service) { - $q->where('id', $service->brand->id); + $q->whereKey($service->brand->getKey()); }) ->firstOrNew(); diff --git a/app/Http/Controllers/Bo/AnswerController.php b/app/Http/Controllers/Bo/AnswerController.php index 074d1a8..aa4ba09 100644 --- a/app/Http/Controllers/Bo/AnswerController.php +++ b/app/Http/Controllers/Bo/AnswerController.php @@ -154,6 +154,7 @@ class AnswerController extends Controller */ public function destroy(Question $question, Answer $answer): \Illuminate\Http\RedirectResponse { + $answer->singleOptins()->detach(); if ($answer->deleteOrFail()) { return redirect()->route('bo.questions.answers.index', $question) ->with('success', trans( diff --git a/app/Http/Controllers/Bo/SingleOptinController.php b/app/Http/Controllers/Bo/SingleOptinController.php index f0df41b..29d0f39 100644 --- a/app/Http/Controllers/Bo/SingleOptinController.php +++ b/app/Http/Controllers/Bo/SingleOptinController.php @@ -31,11 +31,11 @@ class SingleOptinController extends Controller $query = SingleOptin::query() ->when( $request->filled('brand'), - fn($q) => $q->whereHas('brand', fn($q) => $q->where('id', $request->brand)) + fn($q) => $q->whereHas('brand', fn($q) => $q->whereKey($request->brand)), ) ->when( $request->filled('answer'), - fn($q) => $q->whereHas('answers', fn($q) => $q->where('id', $request->answer)) + fn($q) => $q->whereHas('answers', fn($q) => $q->whereKey($request->answer)), ); if ($request->filled('search')) { @@ -57,7 +57,7 @@ class SingleOptinController extends Controller $questionModels = Question::getAllModelsFromBrand($request->brand); if ($request->filled('question')) { - $answerModels = Answer::getAllModelsFromQuestion($request->question); + $answerModels = Answer::getAllAnswersFromQuestion($request->question); } } @@ -78,6 +78,7 @@ class SingleOptinController extends Controller */ public function destroy(SingleOptin $singleoptin): \Illuminate\Http\RedirectResponse { + $singleoptin->answers()->detach(); if ($singleoptin->deleteOrFail()) { return redirect()->route('bo.single_optins.index') ->with('success', trans( diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index a9aa087..6644383 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -31,7 +31,7 @@ class Authenticate extends Middleware } } } - return route('login'); + return route('bo.login'); } return null; diff --git a/app/Models/Answer.php b/app/Models/Answer.php index ff9fc2c..b2ac2ef 100644 --- a/app/Models/Answer.php +++ b/app/Models/Answer.php @@ -49,19 +49,6 @@ class Answer extends Model // * METHODS - /** - * Get all answers associated to the band. - * - * @param integer $questionModelKey - * @return \Illuminate\Database\Eloquent\Collection - */ - public static function getAllModelsFromQuestion(int $questionModelKey): \Illuminate\Database\Eloquent\Collection - { - return self::getAnswers()->filter( - fn($model) => $model->question->getKey() === $questionModelKey - )->values(); - } - /** * Reset all cache elements related to this model. * @@ -72,18 +59,22 @@ class Answer extends Model { $questionModelKey = $model->question->getKey(); Cache::forget("question-{$questionModelKey}-answers"); - self::getAnswers(); + self::getAllAnswersFromQuestion($questionModelKey); } /** * Get all answers. * + * @param integer $questionModelKey * @return \Illuminate\Database\Eloquent\Collection */ - public static function getAnswers(): \Illuminate\Database\Eloquent\Collection + public static function getAllAnswersFromQuestion(int $questionModelKey): \Illuminate\Database\Eloquent\Collection { - return Cache::remember('answers', 86400, function () { - return self::query()->orderBy('answer', 'ASC')->get(); + return Cache::remember("question-{$questionModelKey}-answers", 86400, function () use ($questionModelKey) { + return self::query() + ->whereHas('question', fn($q) => $q->whereKey($questionModelKey)) + ->orderBy('answer', 'ASC') + ->get(); }); } diff --git a/app/Traits/Requests/.gitkeep b/app/Traits/Requests/.gitkeep deleted file mode 100644 index e69de29..0000000