From 89609454ad2ab9b59de88d99eedc552db18a2ea3 Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Wed, 23 Jul 2025 14:24:38 +0200 Subject: [PATCH 1/3] feat: :sparkles: segmentation optins with answers #41 --- .../Controllers/Api/V2/OptinController.php | 30 ++++++--- .../Controllers/Api/V2/StatsController.php | 29 ++++---- .../Controllers/Api/V4/OptinController.php | 24 ++++--- .../Controllers/Api/V4/QuestionController.php | 30 +++++---- .../Controllers/Api/V4/StatsController.php | 27 +++++--- .../Controllers/Bo/QuestionController.php | 9 ++- .../Requests/Api/V2/SingleOptinRequest.php | 6 +- .../Requests/Api/V4/SingleOptinRequest.php | 6 +- .../Resources/Api/V2/QuestionsResource.php | 12 ++-- .../Resources/Api/V4/QuestionsResource.php | 12 ++-- app/Models/Answer.php | 21 ++++-- app/Models/Customer.php | 30 +++++---- app/Models/CustomerAnswer.php | 49 -------------- app/Models/ModelAnswer.php | 66 +++++++++++++++++++ app/Models/Service.php | 6 ++ app/Models/SingleOptin.php | 19 ++++-- database/factories/SingleOptinFactory.php | 32 +++++++++ ...answer_foreign_key_single_optins_table.php | 66 +++++++++++++++++++ database/seeders/CustomerSeeder.php | 49 +++++++------- database/seeders/DatabaseSeeder.php | 3 +- database/seeders/SingleOptinSeeder.php | 23 +++++++ package-lock.json | 2 +- .../bo/pages/answers/index-list.blade.php | 5 +- routes/api.php | 4 +- tests/CRM/ApiV2/ApiBase.php | 2 +- tests/CRM/ApiV2/QuestionTest.php | 50 +++++++------- tests/CRM/ApiV2/SingleOptinTest.php | 58 ++++++++++++++++ tests/CRM/ApiV2/StatsTest.php | 19 +++--- tests/CRM/ApiV4/ApiBase.php | 2 +- tests/CRM/ApiV4/QuestionTest.php | 50 +++++++------- tests/CRM/ApiV4/SingleOptinTest.php | 58 ++++++++++++++++ tests/CRM/ApiV4/StatsTest.php | 19 +++--- tests/Client/CrmClientQuestionTest.php | 37 +++++------ 33 files changed, 584 insertions(+), 271 deletions(-) delete mode 100644 app/Models/CustomerAnswer.php create mode 100644 app/Models/ModelAnswer.php create mode 100644 database/factories/SingleOptinFactory.php create mode 100644 database/migrations/2025_07_22_170342_add_answer_foreign_key_single_optins_table.php create mode 100644 database/seeders/SingleOptinSeeder.php diff --git a/app/Http/Controllers/Api/V2/OptinController.php b/app/Http/Controllers/Api/V2/OptinController.php index 304ed7e..0b50036 100644 --- a/app/Http/Controllers/Api/V2/OptinController.php +++ b/app/Http/Controllers/Api/V2/OptinController.php @@ -11,7 +11,6 @@ use App\Http\Requests\Api\V2\SingleOptinRequest; use App\Http\Requests\Api\V2\SingleOptinRemoveWithHashcodeRequest; use App\Models\CustomerInfo; use App\Models\Service; -use Illuminate\Http\JsonResponse; use App\Models\SingleOptin; class OptinController extends Controller @@ -26,24 +25,31 @@ class OptinController extends Controller * if this email already has a full account. * @throws \App\Exceptions\Api\V2\CrmException If the single optin could not be added or removed. */ - public function optin(SingleOptinRequest $request, Service $service): JsonResponse + public function optin(SingleOptinRequest $request, Service $service): \Illuminate\Http\JsonResponse { - if (CustomerInfo::getFromEmail($service->brand, $request->email)->exists()) { + $validatedData = $request->validated(); + if (CustomerInfo::getFromEmail($service->brand, $validatedData['email'])->exists()) { throw new ApiErrorException(ApiError::singleOptinCustomerExists); } /** @var \App\Models\SingleOptin */ - $singleOptin = SingleOptin::query()->where('email', $request->email) + $singleOptin = SingleOptin::query() + ->where('email', $validatedData['email']) ->whereHas('brand', function ($q) use ($service) { $q->where('id', $service->brand->id); - })->firstOrNew(); + }) + ->firstOrNew(); - if (!$this->changeOptin($service, $request, \boolval($request->opt_in), $singleOptin)) { + if (!$this->changeOptin($service, $request, \boolval($validatedData['opt_in']), $singleOptin)) { // @codeCoverageIgnoreStart throw new CrmException('Api failed to update single optin.'); // @codeCoverageIgnoreEnd } + if (isset($validatedData['answer_id'])) { + $singleOptin->answers()->attach($validatedData['answer_id']); + } + return response()->json(); } @@ -55,13 +61,17 @@ class OptinController extends Controller * @return \Illuminate\Http\JsonResponse * @throws \App\Exceptions\Api\V2\CrmException If the single optin could not be removed. */ - public function optoutWithHashcode(SingleOptinRemoveWithHashcodeRequest $request, Service $service): JsonResponse - { + public function optoutWithHashcode( + SingleOptinRemoveWithHashcodeRequest $request, + Service $service + ): \Illuminate\Http\JsonResponse { /** @var \App\Models\SingleOptin */ - $singleOptin = SingleOptin::query()->where('hashcode', $request->hashcode) + $singleOptin = SingleOptin::query() + ->where('hashcode', $request->hashcode) ->whereHas('brand', function ($q) use ($service) { $q->whereId($service->brand->id); - })->firstOrFail(); + }) + ->firstOrFail(); if ($singleOptin and !$this->changeOptin($service, $request, \boolval($request->opt_in), $singleOptin)) { // @codeCoverageIgnoreStart diff --git a/app/Http/Controllers/Api/V2/StatsController.php b/app/Http/Controllers/Api/V2/StatsController.php index eb400a6..f1dfd43 100644 --- a/app/Http/Controllers/Api/V2/StatsController.php +++ b/app/Http/Controllers/Api/V2/StatsController.php @@ -8,10 +8,9 @@ use App\Http\Requests\Api\V2\GetQuestionStatsRequest; use App\Http\Resources\Api\V2\BrandStatsResource; use App\Http\Resources\Api\V2\QuestionStatsResource; use App\Models\BrandStat; -use App\Models\CustomerAnswer; +use App\Models\ModelAnswer; use App\Models\Question; use App\Models\Service; -use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -24,8 +23,10 @@ class StatsController extends Controller * @param \App\Models\Service $service * @return \Illuminate\Http\Resources\Json\ResourceCollection */ - public function brandStats(GetBrandStatsRequest $request, Service $service): ResourceCollection - { + public function brandStats( + GetBrandStatsRequest $request, + Service $service + ): \Illuminate\Http\Resources\Json\ResourceCollection { $stats = BrandStat::query()->whereDate('created_at', '>=', $request->start) ->whereDate('created_at', '<=', $request->end) ->whereBrandId($service->brand->id)->get(); @@ -39,8 +40,10 @@ class StatsController extends Controller * @param \App\Models\Question $question * @return \App\Http\Resources\Api\V2\QuestionStatsResource */ - public function questionStats(GetQuestionStatsRequest $request, Question $question): QuestionStatsResource - { + public function questionStats( + GetQuestionStatsRequest $request, + Question $question + ): \App\Http\Resources\Api\V2\QuestionStatsResource { // * Make sure we are accessing a customer that belongs to our service. $this->authorize('questionBelongsToService', $question); @@ -62,18 +65,22 @@ class StatsController extends Controller * @param \Illuminate\Support\Carbon $end * @return \Illuminate\Support\Collection */ - private function answersByDays(Collection $answers, Carbon $start, Carbon $end): Collection + private function answersByDays(Collection $answers, Carbon $start, Carbon $end): \Illuminate\Support\Collection { // * Totalize by date all answers. - $answersByDays = CustomerAnswer::query()->whereDate('created_at', '>=', $start) + $answersByDays = ModelAnswer::query() + ->whereDate('created_at', '>=', $start) ->whereDate('created_at', '<=', $end) ->whereIn('answer_id', $answers->pluck('id')) - ->get()->map(function ($custAnswer) { + ->get() + ->map(function ($custAnswer) { return [ - 'answer_id' => $custAnswer->answer_id, + 'answer_id' => $custAnswer->answer_id, 'created_at' => $custAnswer->created_at->isoFormat('YYYY-MM-DD'), ]; - })->groupBy(['created_at', 'answer_id'])->map(function ($day) { + }) + ->groupBy(['created_at', 'answer_id']) + ->map(function ($day) { return $day->map(function ($custAnswers) { return $custAnswers->count(); }); diff --git a/app/Http/Controllers/Api/V4/OptinController.php b/app/Http/Controllers/Api/V4/OptinController.php index ff8a76a..77d4543 100644 --- a/app/Http/Controllers/Api/V4/OptinController.php +++ b/app/Http/Controllers/Api/V4/OptinController.php @@ -11,7 +11,6 @@ use App\Http\Requests\Api\V4\SingleOptinRequest; use App\Http\Requests\Api\V4\SingleOptinRemoveWithHashcodeRequest; use App\Models\CustomerInfo; use App\Models\Service; -use Illuminate\Http\JsonResponse; use App\Models\SingleOptin; class OptinController extends Controller @@ -26,24 +25,31 @@ class OptinController extends Controller * if this email already has a full account. * @throws \App\Exceptions\Api\V4\CrmException If the single optin could not be added or removed. */ - public function optin(SingleOptinRequest $request, Service $service): JsonResponse + public function optin(SingleOptinRequest $request, Service $service): \Illuminate\Http\JsonResponse { - if (CustomerInfo::getFromEmail($service->brand, $request->email)->exists()) { + $validatedData = $request->validated(); + if (CustomerInfo::getFromEmail($service->brand, $validatedData['email'])->exists()) { throw new ApiErrorException(ApiError::singleOptinCustomerExists); } /** @var \App\Models\SingleOptin */ - $singleOptin = SingleOptin::query()->where('email', $request->email) + $singleOptin = SingleOptin::query() + ->where('email', $validatedData['email']) ->whereHas('brand', function ($q) use ($service) { $q->where('id', $service->brand->id); - })->firstOrNew(); + }) + ->firstOrNew(); - if (!$this->changeOptin($service, $request, \boolval($request->opt_in), $singleOptin)) { + if (!$this->changeOptin($service, $request, \boolval($validatedData['opt_in']), $singleOptin)) { // @codeCoverageIgnoreStart throw new CrmException('Api failed to update single optin.'); // @codeCoverageIgnoreEnd } + if (isset($validatedData['answer_id'])) { + $singleOptin->answers()->attach($validatedData['answer_id']); + } + return response()->json(); } @@ -55,8 +61,10 @@ class OptinController extends Controller * @return \Illuminate\Http\JsonResponse * @throws \App\Exceptions\Api\V4\CrmException If the single optin could not be removed. */ - public function optoutWithHashcode(SingleOptinRemoveWithHashcodeRequest $request, Service $service): JsonResponse - { + public function optoutWithHashcode( + SingleOptinRemoveWithHashcodeRequest $request, + Service $service + ): \Illuminate\Http\JsonResponse { /** @var \App\Models\SingleOptin */ $singleOptin = SingleOptin::query()->where('hashcode', $request->hashcode) ->whereHas('brand', function ($q) use ($service) { diff --git a/app/Http/Controllers/Api/V4/QuestionController.php b/app/Http/Controllers/Api/V4/QuestionController.php index f9345d0..a0fdccf 100644 --- a/app/Http/Controllers/Api/V4/QuestionController.php +++ b/app/Http/Controllers/Api/V4/QuestionController.php @@ -8,8 +8,6 @@ use App\Models\Answer; use App\Models\Customer; use App\Models\Question; use App\Models\Service; -use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Http\Resources\Json\ResourceCollection; class QuestionController extends Controller { @@ -19,7 +17,7 @@ class QuestionController extends Controller * @param \App\Models\Service $service * @return \Illuminate\Http\Resources\Json\ResourceCollection */ - public function index(Service $service): ResourceCollection + public function index(Service $service): \Illuminate\Http\Resources\Json\ResourceCollection { return new QuestionsResource($service->brand->questions); } @@ -31,8 +29,10 @@ class QuestionController extends Controller * @param \App\Models\Customer $customer * @return \Illuminate\Http\Resources\Json\ResourceCollection */ - public function indexWithResponse(Service $service, Customer $customer): ResourceCollection - { + public function indexWithResponse( + Service $service, + Customer $customer + ): \Illuminate\Http\Resources\Json\ResourceCollection { // * Make sure we are accessing a customer that belongs to our service. $this->authorize('customerBelongsToService', $customer); @@ -47,18 +47,22 @@ class QuestionController extends Controller * @param \App\Models\Customer $customer * @return \Illuminate\Http\Resources\Json\JsonResource */ - public function unAnsweredBy(Service $service, Customer $customer): JsonResource + public function unAnsweredBy(Service $service, Customer $customer): \Illuminate\Http\Resources\Json\JsonResource { // * Make sure we are accessing a customer that belongs to our service. $this->authorize('customerBelongsToService', $customer); - $questions = Question::query()->whereHas('brand', function ($q) use ($service) { - $q->where('id', $service->brand->id); - })->whereDoesntHave('answers', function ($q) use ($customer) { - $q->whereIn('id', Answer::query()->whereHas('customers', function ($q) use ($customer) { - $q->where('id', $customer->id); - })->pluck('id')); - })->with('answers')->get(); + $questions = Question::query() + ->whereHas('brand', function ($q) use ($service) { + $q->where('id', $service->brand->id); + }) + ->whereDoesntHave('answers', function ($q) use ($customer) { + $q->whereIn('id', Answer::query()->whereHas('customers', function ($q) use ($customer) { + $q->where('id', $customer->id); + })->pluck('id')); + }) + ->with('answers') + ->get(); return new QuestionsResource($questions); } } diff --git a/app/Http/Controllers/Api/V4/StatsController.php b/app/Http/Controllers/Api/V4/StatsController.php index c741c8e..3674086 100644 --- a/app/Http/Controllers/Api/V4/StatsController.php +++ b/app/Http/Controllers/Api/V4/StatsController.php @@ -8,10 +8,9 @@ use App\Http\Requests\Api\V4\GetQuestionStatsRequest; use App\Http\Resources\Api\V4\BrandStatsResource; use App\Http\Resources\Api\V4\QuestionStatsResource; use App\Models\BrandStat; -use App\Models\CustomerAnswer; +use App\Models\ModelAnswer; use App\Models\Question; use App\Models\Service; -use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -24,8 +23,10 @@ class StatsController extends Controller * @param \App\Models\Service $service * @return \Illuminate\Http\Resources\Json\ResourceCollection */ - public function brandStats(GetBrandStatsRequest $request, Service $service): ResourceCollection - { + public function brandStats( + GetBrandStatsRequest $request, + Service $service + ): \Illuminate\Http\Resources\Json\ResourceCollection { $stats = BrandStat::query()->whereDate('created_at', '>=', $request->start) ->whereDate('created_at', '<=', $request->end) ->whereBrandId($service->brand->id)->get(); @@ -39,8 +40,10 @@ class StatsController extends Controller * @param \App\Models\Question $question * @return \App\Http\Resources\Api\V4\QuestionStatsResource */ - public function questionStats(GetQuestionStatsRequest $request, Question $question): QuestionStatsResource - { + public function questionStats( + GetQuestionStatsRequest $request, + Question $question + ): \App\Http\Resources\Api\V4\QuestionStatsResource { // * Make sure we are accessing a customer that belongs to our service. $this->authorize('questionBelongsToService', $question); @@ -62,18 +65,22 @@ class StatsController extends Controller * @param \Illuminate\Support\Carbon $end * @return \Illuminate\Support\Collection */ - private function answersByDays(Collection $answers, Carbon $start, Carbon $end): Collection + private function answersByDays(Collection $answers, Carbon $start, Carbon $end): \Illuminate\Support\Collection { // * Totalize by date all answers. - $answersByDays = CustomerAnswer::query()->whereDate('created_at', '>=', $start) + $answersByDays = ModelAnswer::query() + ->whereDate('created_at', '>=', $start) ->whereDate('created_at', '<=', $end) ->whereIn('answer_id', $answers->pluck('id')) - ->get()->map(function ($custAnswer) { + ->get() + ->map(function ($custAnswer) { return [ 'answer_id' => $custAnswer->answer_id, 'created_at' => $custAnswer->created_at->isoFormat('YYYY-MM-DD'), ]; - })->groupBy(['created_at', 'answer_id'])->map(function ($day) { + }) + ->groupBy(['created_at', 'answer_id']) + ->map(function ($day) { return $day->map(function ($custAnswers) { return $custAnswers->count(); }); diff --git a/app/Http/Controllers/Bo/QuestionController.php b/app/Http/Controllers/Bo/QuestionController.php index 3499523..81d6e49 100644 --- a/app/Http/Controllers/Bo/QuestionController.php +++ b/app/Http/Controllers/Bo/QuestionController.php @@ -7,6 +7,7 @@ use App\Http\Requests\Bo\QuestionExport; use App\Http\Requests\Bo\Questions\StoreQuestionRequest; use App\Http\Requests\Bo\Questions\UpdateQuestionRequest; use App\Models\Brand; +use App\Models\Customer; use App\Models\Question; use Carbon\Carbon; use Illuminate\Http\Request; @@ -202,10 +203,12 @@ class QuestionController extends Controller foreach ($questions as $question) { $answersCounts = DB::query() ->selectRaw('count(*) as total, answer_id') - ->from('customer_answer') + ->from('model_answer') + ->where('model_type', '=', Customer::class) ->whereIn('answer_id', $question->answers->pluck('id')) ->groupBy('answer_id') - ->get()->keyBy('answer_id'); + ->get() + ->keyBy('answer_id'); $results[$question->id] = [ 'question' => $question->question, @@ -218,7 +221,7 @@ class QuestionController extends Controller $answersCounts[$answer->id]->total ?? 0 ]; } - } + } //end foreach $w = Writer::createFromString(''); $w->setDelimiter(';'); diff --git a/app/Http/Requests/Api/V2/SingleOptinRequest.php b/app/Http/Requests/Api/V2/SingleOptinRequest.php index bc8b87f..84aa25e 100644 --- a/app/Http/Requests/Api/V2/SingleOptinRequest.php +++ b/app/Http/Requests/Api/V2/SingleOptinRequest.php @@ -15,8 +15,10 @@ class SingleOptinRequest extends BaseApiRequest public function rules(): array { return [ - 'email' => 'required|email:rfc,dns,spoof,filter', - 'opt_in' => 'required|boolean' + 'email' => 'required|email:rfc,dns,spoof,filter', + 'opt_in' => 'required|boolean', + 'answer_id' => 'sometimes|array|min:1', + 'answer_id.*' => 'sometimes|integer|exists:answers,id', ]; } } diff --git a/app/Http/Requests/Api/V4/SingleOptinRequest.php b/app/Http/Requests/Api/V4/SingleOptinRequest.php index d5aa778..d82e9f7 100644 --- a/app/Http/Requests/Api/V4/SingleOptinRequest.php +++ b/app/Http/Requests/Api/V4/SingleOptinRequest.php @@ -12,8 +12,10 @@ class SingleOptinRequest extends BaseApiRequest public function rules(): array { return [ - 'email' => 'required|email:rfc,dns,spoof,filter', - 'opt_in' => 'required|boolean' + 'email' => 'required|email:rfc,dns,spoof,filter', + 'opt_in' => 'required|boolean', + 'answer_id' => 'sometimes|array|min:1', + 'answer_id.*' => 'sometimes|integer|exists:answers,id', ]; } } diff --git a/app/Http/Resources/Api/V2/QuestionsResource.php b/app/Http/Resources/Api/V2/QuestionsResource.php index e3a774f..2bc43ba 100644 --- a/app/Http/Resources/Api/V2/QuestionsResource.php +++ b/app/Http/Resources/Api/V2/QuestionsResource.php @@ -11,20 +11,20 @@ use Illuminate\Support\Collection; class QuestionsResource extends JsonResourceCollection { /** @var \Illuminate\Support\Collection<\App\Models\CustomerAnswer> */ - protected $userAnwsers = null; + protected $userAnswers = null; /** * Create a new resource instance. * * @param mixed $resource - * @param \Illuminate\Support\Collection<\App\Models\CustomerAnswer> $userAnwsers + * @param \Illuminate\Support\Collection<\App\Models\CustomerAnswer> $userAnswers * @return void * @phpcs:disable Squiz.Commenting.FunctionComment.IncorrectTypeHint */ - public function __construct(mixed $resource, Collection $userAnwsers = null) + public function __construct(mixed $resource, Collection $userAnswers = null) { //phpcs:enable - $this->userAnwsers = $userAnwsers; + $this->userAnswers = $userAnswers; parent::__construct($resource); } @@ -47,9 +47,9 @@ class QuestionsResource extends JsonResourceCollection 'question' => $question->question, 'answers' => new AnswersResource($answers) ]; - if ($this->userAnwsers) { + if ($this->userAnswers) { $question['customerAnswers'] = new AnswersResource($answers->filter(function (Answer $answer) { - return $this->userAnwsers->keyBy('id')->has($answer->id); + return $this->userAnswers->keyBy('id')->has($answer->id); })); } return $question; diff --git a/app/Http/Resources/Api/V4/QuestionsResource.php b/app/Http/Resources/Api/V4/QuestionsResource.php index b56625f..25fe085 100644 --- a/app/Http/Resources/Api/V4/QuestionsResource.php +++ b/app/Http/Resources/Api/V4/QuestionsResource.php @@ -11,20 +11,20 @@ use Illuminate\Support\Collection; class QuestionsResource extends JsonResourceCollection { /** @var \Illuminate\Support\Collection<\App\Models\CustomerAnswer> */ - protected $userAnwsers = null; + protected $userAnswers = null; /** * Create a new resource instance. * * @param mixed $resource - * @param \Illuminate\Support\Collection<\App\Models\CustomerAnswer> $userAnwsers + * @param \Illuminate\Support\Collection<\App\Models\CustomerAnswer> $userAnswers * @return void * @phpcs:disable Squiz.Commenting.FunctionComment.IncorrectTypeHint */ - public function __construct(mixed $resource, Collection $userAnwsers = null) + public function __construct(mixed $resource, Collection $userAnswers = null) { //phpcs:enable - $this->userAnwsers = $userAnwsers; + $this->userAnswers = $userAnswers; parent::__construct($resource); } @@ -47,9 +47,9 @@ class QuestionsResource extends JsonResourceCollection 'question' => $question->question, 'answers' => new AnswersResource($answers) ]; - if ($this->userAnwsers) { + if ($this->userAnswers) { $question['customerAnswers'] = new AnswersResource($answers->filter(function (Answer $answer) { - return $this->userAnwsers->keyBy('id')->has($answer->id); + return $this->userAnswers->keyBy('id')->has($answer->id); })); } return $question; diff --git a/app/Models/Answer.php b/app/Models/Answer.php index 29601ee..2191813 100644 --- a/app/Models/Answer.php +++ b/app/Models/Answer.php @@ -13,8 +13,7 @@ use Illuminate\Database\Eloquent\Model; * @property-read \Illuminate\Support\Carbon $updated_at Updated date. * * @property-read \App\Models\Question $question Question BelongsTo relation. - * @property-read \Illuminate\Database\Eloquent\Collection - * $customers Customer BelongsToMany relation. + * @property-read \Illuminate\Database\Eloquent\Collection $model Morph to relation. */ class Answer extends Model { @@ -42,12 +41,22 @@ class Answer extends Model } /** - * Customer belongsToMany relation. + * Customer morph to relation. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return \Illuminate\Database\Eloquent\Relations\MorphToMany */ - public function customers(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function customers(): \Illuminate\Database\Eloquent\Relations\MorphToMany { - return $this->belongsToMany(Customer::class, 'customer_answer'); + return $this->morphedByMany(Customer::class, 'model', 'model_answer', 'answer_id', 'model_id'); + } + + /** + * Customer morph to relation. + * + * @return \Illuminate\Database\Eloquent\Relations\MorphToMany + */ + public function singleOptins(): \Illuminate\Database\Eloquent\Relations\MorphToMany + { + return $this->morphedByMany(SingleOptin::class, 'model', 'model_answer', 'answer_id', 'model_id'); } } diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 5ffb33e..b03dbd2 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -3,7 +3,6 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; @@ -24,7 +23,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property-read \Illuminate\Database\Eloquent\Collection * $brands Brand BelongsToMany relation. * @property-read \Illuminate\Database\Eloquent\Collection - * $answers Answer BelongsToMany relation. + * $answers Answer morph many relation. */ class Customer extends Model { @@ -86,13 +85,13 @@ class Customer extends Model } /** - * Answers belongsToMany relation. + * Answers morph to many relation. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return \Illuminate\Database\Eloquent\Relations\MorphToMany */ - public function answers(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + public function answers(): \Illuminate\Database\Eloquent\Relations\MorphToMany { - return $this->belongsToMany(Answer::class, 'customer_answer'); + return $this->morphToMany(Answer::class, 'model', 'model_answer', 'model_id', 'answer_id'); } // * METHODS @@ -217,14 +216,17 @@ class Customer extends Model * @param \App\Models\Service $service * @return \Illuminate\Database\Eloquent\Collection */ - public function getAnswers(Service $service): Collection + public function getAnswers(Service $service): \Illuminate\Database\Eloquent\Collection { - return Answer::query()->whereHas('customers', function (Builder $q) { - $q->where('id', $this->id); - })->whereHas('question', function (Builder $q) use ($service) { - $q->whereHas('brand', function (Builder $q) use ($service) { - $q->where('id', $service->brand->id); - }); - })->get(); + return Answer::query() + ->whereHas('customers', function (Builder $q) { + $q->where('id', $this->id); + }) + ->whereHas('question', function (Builder $q) use ($service) { + $q->whereHas('brand', function (Builder $q) use ($service) { + $q->where('id', $service->brand->id); + }); + }) + ->get(); } } diff --git a/app/Models/CustomerAnswer.php b/app/Models/CustomerAnswer.php deleted file mode 100644 index 7bcc05f..0000000 --- a/app/Models/CustomerAnswer.php +++ /dev/null @@ -1,49 +0,0 @@ -belongsTo(Customer::class); - } - - /** - * Customer belongsTo relation. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function answer(): \Illuminate\Database\Eloquent\Relations\BelongsTo - { - return $this->belongsTo(Answer::class); - } -} diff --git a/app/Models/ModelAnswer.php b/app/Models/ModelAnswer.php new file mode 100644 index 0000000..2fa2c69 --- /dev/null +++ b/app/Models/ModelAnswer.php @@ -0,0 +1,66 @@ +morphTo(); + } + + /** + * Relation vers la réponse. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function answer() + { + return $this->belongsTo(Answer::class); + } +} diff --git a/app/Models/Service.php b/app/Models/Service.php index bff09db..b8b6cb0 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -79,6 +79,12 @@ class Service extends Model */ protected static function booted(): void { + static::creating(function (self $model) { + if (config('app.env') !== 'production') { + $model->generateBearerToken(); + $model->generateBearerPassword(); + } + }); static::created(function () { // * Flush Eloquent cache. self::flushQueryCache(); diff --git a/app/Models/SingleOptin.php b/app/Models/SingleOptin.php index 813c0f8..dbd1e38 100644 --- a/app/Models/SingleOptin.php +++ b/app/Models/SingleOptin.php @@ -7,13 +7,14 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; /** - * @property integer $hashcode Hashcode. - * @property integer $brand_id Foreign key Brand. - * @property string $email Email. + * @property integer $hashcode Hashcode. + * @property integer $brand_id Foreign key Brand. + * @property string $email Email. * @property-read \Illuminate\Support\Carbon $created_at Created date. * @property-read \Illuminate\Support\Carbon $updated_at Updated date. - * @property-read \App\Models\Brand $brand Brand BelongsTo relation. + * @property-read \App\Models\Brand $brand Brand BelongsTo relation. + * @property-read \Illuminate\Database\Eloquent\Collection $answers Answer morph many relation. */ class SingleOptin extends Model { @@ -72,4 +73,14 @@ class SingleOptin extends Model { return $this->belongsTo(Brand::class); } + + /** + * Answers morph to many relation. + * + * @return \Illuminate\Database\Eloquent\Relations\MorphToMany + */ + public function answers(): \Illuminate\Database\Eloquent\Relations\MorphToMany + { + return $this->morphToMany(Answer::class, 'model', 'model_answer', 'model_id', 'answer_id'); + } } diff --git a/database/factories/SingleOptinFactory.php b/database/factories/SingleOptinFactory.php new file mode 100644 index 0000000..308b78e --- /dev/null +++ b/database/factories/SingleOptinFactory.php @@ -0,0 +1,32 @@ + Str::uuid(), + 'email' => $this->faker->email(), + 'brand_id' => $this->faker->randomElement(Brand::pluck('id')), + ]; + } +} diff --git a/database/migrations/2025_07_22_170342_add_answer_foreign_key_single_optins_table.php b/database/migrations/2025_07_22_170342_add_answer_foreign_key_single_optins_table.php new file mode 100644 index 0000000..7ee27c7 --- /dev/null +++ b/database/migrations/2025_07_22_170342_add_answer_foreign_key_single_optins_table.php @@ -0,0 +1,66 @@ +dropForeign('customer_answer_answer_id_foreign'); + $table->dropForeign('customer_answer_customer_id_foreign'); + $table->dropIndex('customer_answer_answer_id_foreign'); + $table->dropIndex('customer_answer_customer_id_foreign'); + }); + + Schema::rename("customer_answer", "model_answer"); + + Schema::table('model_answer', function (Blueprint $table) { + $table->renameColumn('customer_id', 'model_id'); + + // Change to string because key of single optin is hashcode. + $table->string('model_id')->change(); + $table->string('model_type')->after('model_id'); + }); + + DB::table('model_answer')->update(['model_type' => 'App\Models\Customer']); + + Schema::table('model_answer', function (Blueprint $table) { + $table->foreign('answer_id') + ->references('id')->on('answers') + ->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::table('model_answer', function (Blueprint $table) { + $table->dropForeign(['answer_id']); + }); + + Schema::table('model_answer', function (Blueprint $table) { + $table->dropColumn('model_type'); + $table->renameColumn('model_id', 'customer_id'); + }); + + Schema::rename("model_answer", "customer_answer"); + + Schema::table('customer_answer', function (Blueprint $table) { + $table->foreign('answer_id')->references('id')->on('answers'); + $table->foreign('customer_id')->references('id')->on('customers'); + }); + } +}; diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php index ca3316c..5183dfd 100644 --- a/database/seeders/CustomerSeeder.php +++ b/database/seeders/CustomerSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Models\Answer; use App\Models\Brand; use App\Models\Customer; use App\Models\CustomerInfo; @@ -15,33 +16,33 @@ class CustomerSeeder extends Seeder * * @return void */ - public function run() + public function run(): void { $hasSetAtLeasOneActivated = false; - Customer::factory(10)->create()->each( - function ($customer) use (&$hasSetAtLeasOneActivated) { - /** @var \App\Models\Brand */ - foreach (Brand::all() as $brand) { - CustomerInfo::factory(1)->make()->each( - function ($customerInfo) use ($customer, $brand) { - $customerInfo->customer()->associate($customer); - $customerInfo->brand()->associate($brand); - $customerInfo->save(); + Customer::factory(10)->create()->each(function ($customer) use (&$hasSetAtLeasOneActivated) { + /** @var \App\Models\Brand */ + foreach (Brand::all() as $brand) { + CustomerInfo::factory(1)->make()->each( + function ($customerInfo) use ($customer, $brand) { + $customerInfo->customer()->associate($customer); + $customerInfo->brand()->associate($brand); + $customerInfo->save(); + } + ); + CustomerLogin::factory(1)->make()->each( + function ($customerLogin) use ($customer, $brand, &$hasSetAtLeasOneActivated) { + $customerLogin->customer()->associate($customer); + $customerLogin->brand()->associate($brand); + if (!$hasSetAtLeasOneActivated) { + $hasSetAtLeasOneActivated = true; + $customerLogin->status = true; } - ); - CustomerLogin::factory(1)->make()->each( - function ($customerLogin) use ($customer, $brand, &$hasSetAtLeasOneActivated) { - $customerLogin->customer()->associate($customer); - $customerLogin->brand()->associate($brand); - if (!$hasSetAtLeasOneActivated) { - $hasSetAtLeasOneActivated = true; - $customerLogin->status = true; - } - $customerLogin->save(); - } - ); - } + $customerLogin->save(); + } + ); } - ); + $answerModels = Answer::inRandomOrder()->take(rand(1, 3))->pluck('id'); + $customer->answers()->attach($answerModels); + }); } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 1ddc4fe..68ec9ee 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,7 +17,8 @@ class DatabaseSeeder extends Seeder BrandSeeder::class, CustomerSeeder::class, BrandStatSeeder::class, - UserSeeder::class + UserSeeder::class, + SingleOptinSeeder::class, ]); } } diff --git a/database/seeders/SingleOptinSeeder.php b/database/seeders/SingleOptinSeeder.php new file mode 100644 index 0000000..3a79bdd --- /dev/null +++ b/database/seeders/SingleOptinSeeder.php @@ -0,0 +1,23 @@ +create()->each(function (SingleOptin $singleOptin) { + $answerModels = Answer::inRandomOrder()->take(rand(1, 3))->pluck('id'); + $singleOptin->answers()->attach($answerModels); + }); + } +} diff --git a/package-lock.json b/package-lock.json index 7481eed..06ac1a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "srcs", + "name": "crm", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/views/bo/pages/answers/index-list.blade.php b/resources/views/bo/pages/answers/index-list.blade.php index d9dbfed..d811cbb 100644 --- a/resources/views/bo/pages/answers/index-list.blade.php +++ b/resources/views/bo/pages/answers/index-list.blade.php @@ -16,8 +16,9 @@ {{ $answerModel->answer }} - {{ $answerModel->updated_at->formatLocalized('%d/%m/%Y à %H:%M') }} + + {{ $answerModel->updated_at->isoFormat('LLLL') }} +
name('apiV2.')->group(function () { // * CUSTOMERS Route::apiResource('customers', V2CustomerController::class, [ - 'except' => 'index', + 'except' => 'index', 'parameters' => ['customers' => 'serviceCustomer'] ]); Route::prefix('customers')->name('customers.')->group(function () { @@ -96,7 +96,7 @@ Route::prefix('v4')->name('apiV4.')->group(function () { // * CUSTOMERS Route::apiResource('customers', V4CustomerController::class, [ - 'except' => 'index', + 'except' => 'index', 'parameters' => ['customers' => 'serviceCustomer'] ]); Route::prefix('customers')->name('customers.')->group(function () { diff --git a/tests/CRM/ApiV2/ApiBase.php b/tests/CRM/ApiV2/ApiBase.php index e11c4fa..c0cd66a 100644 --- a/tests/CRM/ApiV2/ApiBase.php +++ b/tests/CRM/ApiV2/ApiBase.php @@ -85,7 +85,7 @@ class ApiBase extends BaseTestCase * * @param boolean $status The customer status for a brand. * @param boolean $random Randomize the customer to get. - * @return array + * @return array Data in array: customer, service, login, brand, info. */ protected function getCustomer(bool $status = true, bool $random = true): array { diff --git a/tests/CRM/ApiV2/QuestionTest.php b/tests/CRM/ApiV2/QuestionTest.php index 63930eb..e807445 100644 --- a/tests/CRM/ApiV2/QuestionTest.php +++ b/tests/CRM/ApiV2/QuestionTest.php @@ -4,7 +4,8 @@ namespace Tests\CRM\ApiV2; use App\Enums\ApiError; use App\Enums\ApiStatus; -use App\Models\CustomerAnswer; +use App\Models\Customer; +use App\Models\ModelAnswer; use App\Models\Question; use App\Models\Service; @@ -80,16 +81,13 @@ class QuestionTest extends ApiBase $firstAnswer = $questions->first()->answers->first(); $firstAnswer->load('question'); - /** @var \App\Models\CustomerAnswer $custAnswer */ - $custAnswer = CustomerAnswer::whereHas('customer', function ($q) use ($customer) { - $q->whereId($customer->id); - })->whereHas('answer', function ($q) use ($firstAnswer) { - $q->whereId($firstAnswer->id); - })->firstOrNew(); - - $custAnswer->customer()->associate($customer); - $custAnswer->answer()->associate($firstAnswer); - $custAnswer->saveOrFail(); + /** @var \App\Models\ModelAnswer $modelAnswer */ + $modelAnswer = ModelAnswer::firstOrNew([ + 'model_type' => Customer::class, + 'model_id' => $customer->getKey(), + 'answer_id' => $firstAnswer->getKey(), + ]); + $modelAnswer->saveOrFail(); // * Call $response = $this->callApiWithToken($service, 'get', route( @@ -176,16 +174,13 @@ class QuestionTest extends ApiBase $firstAnswer = $questions->first()->answers->first(); $firstAnswer->load('question'); - /** @var \App\Models\CustomerAnswer $custAnswer */ - $custAnswer = CustomerAnswer::whereHas('customer', function ($q) use ($customer) { - $q->whereId($customer->id); - })->whereHas('answer', function ($q) use ($firstAnswer) { - $q->whereId($firstAnswer->id); - })->firstOrNew(); - - $custAnswer->customer()->associate($customer); - $custAnswer->answer()->associate($firstAnswer); - $custAnswer->saveOrFail(); + /** @var \App\Models\ModelAnswer $modelAnswer */ + $modelAnswer = ModelAnswer::firstOrNew([ + 'model_type' => Customer::class, + 'model_id' => $customer->getKey(), + 'answer_id' => $firstAnswer->getKey(), + ]); + $modelAnswer->saveOrFail(); // * Call $response = $this->callApiWithToken($service, 'get', route( @@ -280,11 +275,14 @@ class QuestionTest extends ApiBase $json = $response->decodeResponseJson(); $this->assertApiResponseStatusIs($json, ApiStatus::OK); - $this->assertTrue(CustomerAnswer::whereHas('customer', function ($q) use ($customer) { - $q->whereId($customer->id); - })->whereHas('answer', function ($q) use ($answer) { - $q->whereId($answer->id); - })->exists(), 'Question should have been answered.'); + $this->assertTrue( + ModelAnswer::query()->where([ + ['model_type', Customer::class], + ['model_id', $customer->getKey()], + ['answer_id', $answer->getKey()] + ])->exists(), + 'Question should have been answered.' + ); } /** diff --git a/tests/CRM/ApiV2/SingleOptinTest.php b/tests/CRM/ApiV2/SingleOptinTest.php index 5c8517b..00995dd 100644 --- a/tests/CRM/ApiV2/SingleOptinTest.php +++ b/tests/CRM/ApiV2/SingleOptinTest.php @@ -5,6 +5,8 @@ namespace Tests\CRM\ApiV2; use App\Enums\ApiError; use App\Enums\ApiStatus; use App\Enums\Country; +use App\Models\ModelAnswer; +use App\Models\Question; use App\Models\Service; use App\Models\SingleOptin; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -37,6 +39,43 @@ class SingleOptinTest extends ApiBase ); } + /** + * Test set single optin on with answer associated. + * route: apiV2.optin + * + * @return void + */ + public function testSetSingleOptinOnWithAnswerAssociated(): void + { + $service = Service::firstOrFail(); + + /** @var \Illuminate\Database\Eloquent\Collection $questions */ + $questions = Question::query()->whereHas('brand', function ($q) use ($service) { + $q->whereId($service->brand->getKey()); + })->with('answers')->get()->keyBy('id'); + + // * Make sure a customer answer is set. + $firstAnswer = $questions->first()->answers->first(); + + $response = $this->callApiWithToken($service, 'post', route('apiV2.optin'), [ + 'email' => 'singleoptin@hotmail.fr', + 'opt_in' => 1, + 'answer_id' => [$firstAnswer->getkey()], + ]); + + $singleOptin = SingleOptin::query()->where('email', 'singleoptin@hotmail.fr'); + $json = $response->decodeResponseJson(); + $this->assertApiResponseStatusIs($json, ApiStatus::OK); + $this->assertTrue( + $singleOptin->exists() && ModelAnswer::query()->where([ + ['answer_id', $firstAnswer->getkey()], + ['model_id', !is_null($singleOptin->first()) ? $singleOptin->first()->getKey() : null], + ['model_type', SingleOptin::class], + ])->exists(), + 'Single optin should have been set for email "singleoptin@hotmail.fr"' + ); + } + /** * Test set single optin off. * route: apiV2.optin @@ -87,6 +126,25 @@ class SingleOptinTest extends ApiBase $this->assertEquals(ApiError::singleOptinCustomerExists->label(), $json->json('data.message')); } + /** + * Test set single optin on with anexisting answer associated. + * + * @return void + */ + public function testSingleOptinShouldFailIfAnswerIdDoesntExist(): void + { + $service = Service::query()->firstOrFail(); + $response = $this->callApiWithToken($service, 'post', route('apiV2.optin'), [ + 'email' => 'singleoptin@hotmail.fr', + 'opt_in' => 1, + 'answer_id' => [0], + ]); + + $json = $response->decodeResponseJson(); + $this->assertApiResponseStatusIs($json, ApiStatus::KO); + $this->assertEquals(ApiError::validationFailed->value(), $json->json('data.code')); + } + /** * Test set single optin off using hashcode. * route: apiV2.optout-hashcode diff --git a/tests/CRM/ApiV2/StatsTest.php b/tests/CRM/ApiV2/StatsTest.php index 097e1a3..9f5e70e 100644 --- a/tests/CRM/ApiV2/StatsTest.php +++ b/tests/CRM/ApiV2/StatsTest.php @@ -5,7 +5,7 @@ namespace Tests\CRM\ApiV2; use App\Enums\ApiError; use App\Enums\ApiStatus; use App\Models\Customer; -use App\Models\CustomerAnswer; +use App\Models\ModelAnswer; use App\Models\Question; use App\Models\Service; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -106,11 +106,11 @@ class StatsTest extends ApiBase $end = Carbon::now(); // * Make sure a customer answer is set. - /** @var \App\Models\CustomerAnswer $custAnswer */ - $custAnswer = CustomerAnswer::whereHas('answer', function ($q) use ($answersIds) { - $q->whereId($answersIds->first()); - })->firstOrNew(); - $custAnswer->customer()->associate($custAnswer->customer ?? Customer::query()->firstOrFail()); + /** @var \App\Models\ModelAnswer $custAnswer */ + $custAnswer = ModelAnswer::query() + ->where([['answer_id', $answersIds->first()], ['model_type', Customer::class]]) + ->firstOrNew(); + $custAnswer->model()->associate($custAnswer->model_id ?? Customer::query()->firstOrFail()); $custAnswer->answer()->associate($answersIds->first()); $custAnswer->saveOrFail(); @@ -122,11 +122,8 @@ class StatsTest extends ApiBase $datesIndexes = []; do { - $datesIndexes[$start->isoFormat('YYYY-MM-DD')] = $answersIds - // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed - ->keys()->all(); - - $start = $start->addDay(); + $datesIndexes[$start->isoFormat('YYYY-MM-DD')] = $answersIds->keys()->all(); + $start = $start->addDay(); } while ($start->lessThanOrEqualTo($end)); $json = $response->decodeResponseJson(); diff --git a/tests/CRM/ApiV4/ApiBase.php b/tests/CRM/ApiV4/ApiBase.php index b52ce6c..d0db6ee 100644 --- a/tests/CRM/ApiV4/ApiBase.php +++ b/tests/CRM/ApiV4/ApiBase.php @@ -85,7 +85,7 @@ class ApiBase extends BaseTestCase * * @param boolean $status The customer status for a brand. * @param boolean $random Randomize the customer to get. - * @return array + * @return array Data in array: customer, service, login, brand, info. */ protected function getCustomer(bool $status = true, bool $random = true): array { diff --git a/tests/CRM/ApiV4/QuestionTest.php b/tests/CRM/ApiV4/QuestionTest.php index 3106502..678222c 100644 --- a/tests/CRM/ApiV4/QuestionTest.php +++ b/tests/CRM/ApiV4/QuestionTest.php @@ -4,7 +4,8 @@ namespace Tests\CRM\ApiV4; use App\Enums\ApiError; use App\Enums\ApiStatus; -use App\Models\CustomerAnswer; +use App\Models\Customer; +use App\Models\ModelAnswer; use App\Models\Question; use App\Models\Service; @@ -80,16 +81,13 @@ class QuestionTest extends ApiBase $firstAnswer = $questions->first()->answers->first(); $firstAnswer->load('question'); - /** @var \App\Models\CustomerAnswer $custAnswer */ - $custAnswer = CustomerAnswer::whereHas('customer', function ($q) use ($customer) { - $q->whereId($customer->id); - })->whereHas('answer', function ($q) use ($firstAnswer) { - $q->whereId($firstAnswer->id); - })->firstOrNew(); - - $custAnswer->customer()->associate($customer); - $custAnswer->answer()->associate($firstAnswer); - $custAnswer->saveOrFail(); + /** @var \App\Models\ModelAnswer $modelAnswer */ + $modelAnswer = ModelAnswer::firstOrNew([ + 'model_type' => Customer::class, + 'model_id' => $customer->getKey(), + 'answer_id' => $firstAnswer->getKey(), + ]); + $modelAnswer->saveOrFail(); // * Call $response = $this->callApiWithToken($service, 'get', route( @@ -176,16 +174,13 @@ class QuestionTest extends ApiBase $firstAnswer = $questions->first()->answers->first(); $firstAnswer->load('question'); - /** @var \App\Models\CustomerAnswer $custAnswer */ - $custAnswer = CustomerAnswer::whereHas('customer', function ($q) use ($customer) { - $q->whereId($customer->id); - })->whereHas('answer', function ($q) use ($firstAnswer) { - $q->whereId($firstAnswer->id); - })->firstOrNew(); - - $custAnswer->customer()->associate($customer); - $custAnswer->answer()->associate($firstAnswer); - $custAnswer->saveOrFail(); + /** @var \App\Models\ModelAnswer $modelAnswer */ + $modelAnswer = ModelAnswer::firstOrNew([ + 'model_type' => Customer::class, + 'model_id' => $customer->getKey(), + 'answer_id' => $firstAnswer->getKey(), + ]); + $modelAnswer->saveOrFail(); // * Call $response = $this->callApiWithToken($service, 'get', route( @@ -280,11 +275,14 @@ class QuestionTest extends ApiBase $json = $response->decodeResponseJson(); $this->assertApiResponseStatusIs($json, ApiStatus::OK); - $this->assertTrue(CustomerAnswer::whereHas('customer', function ($q) use ($customer) { - $q->whereId($customer->id); - })->whereHas('answer', function ($q) use ($answer) { - $q->whereId($answer->id); - })->exists(), 'Question should have been answered.'); + $this->assertTrue( + ModelAnswer::query()->where([ + ['model_type', Customer::class], + ['model_id', $customer->getKey()], + ['answer_id', $answer->getKey()] + ])->exists(), + 'Question should have been answered.' + ); } /** diff --git a/tests/CRM/ApiV4/SingleOptinTest.php b/tests/CRM/ApiV4/SingleOptinTest.php index b466bda..fd43ad7 100644 --- a/tests/CRM/ApiV4/SingleOptinTest.php +++ b/tests/CRM/ApiV4/SingleOptinTest.php @@ -5,6 +5,8 @@ namespace Tests\CRM\ApiV4; use App\Enums\ApiError; use App\Enums\ApiStatus; use App\Enums\Country; +use App\Models\ModelAnswer; +use App\Models\Question; use App\Models\Service; use App\Models\SingleOptin; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -37,6 +39,43 @@ class SingleOptinTest extends ApiBase ); } + /** + * Test set single optin on with answer associated. + * route: apiV2.optin + * + * @return void + */ + public function testSetSingleOptinOnWithAnswerAssociated(): void + { + $service = Service::firstOrFail(); + + /** @var \Illuminate\Database\Eloquent\Collection $questions */ + $questions = Question::query()->whereHas('brand', function ($q) use ($service) { + $q->whereId($service->brand->getKey()); + })->with('answers')->get()->keyBy('id'); + + // * Make sure a customer answer is set. + $firstAnswer = $questions->first()->answers->first(); + + $response = $this->callApiWithToken($service, 'post', route('apiV2.optin'), [ + 'email' => 'singleoptin@hotmail.fr', + 'opt_in' => 1, + 'answer_id' => [$firstAnswer->getkey()], + ]); + + $singleOptin = SingleOptin::query()->where('email', 'singleoptin@hotmail.fr'); + $json = $response->decodeResponseJson(); + $this->assertApiResponseStatusIs($json, ApiStatus::OK); + $this->assertTrue( + $singleOptin->exists() && ModelAnswer::query()->where([ + ['answer_id', $firstAnswer->getkey()], + ['model_id', !is_null($singleOptin->first()) ? $singleOptin->first()->getKey() : null], + ['model_type', SingleOptin::class], + ])->exists(), + 'Single optin should have been set for email "singleoptin@hotmail.fr"' + ); + } + /** * Test set single optin off. * route: apiV4.optin @@ -87,6 +126,25 @@ class SingleOptinTest extends ApiBase $this->assertEquals(ApiError::singleOptinCustomerExists->label(), $json->json('data.message')); } + /** + * Test set single optin on with anexisting answer associated. + * + * @return void + */ + public function testSingleOptinShouldFailIfAnswerIdDoesntExist(): void + { + $service = Service::query()->firstOrFail(); + $response = $this->callApiWithToken($service, 'post', route('apiV2.optin'), [ + 'email' => 'singleoptin@hotmail.fr', + 'opt_in' => 1, + 'answer_id' => [0], + ]); + + $json = $response->decodeResponseJson(); + $this->assertApiResponseStatusIs($json, ApiStatus::KO); + $this->assertEquals(ApiError::validationFailed->value(), $json->json('data.code')); + } + /** * Test set single optin off using hashcode. * route: apiV4.optout-hashcode diff --git a/tests/CRM/ApiV4/StatsTest.php b/tests/CRM/ApiV4/StatsTest.php index d2f5c3a..a56190d 100644 --- a/tests/CRM/ApiV4/StatsTest.php +++ b/tests/CRM/ApiV4/StatsTest.php @@ -5,7 +5,7 @@ namespace Tests\CRM\ApiV4; use App\Enums\ApiError; use App\Enums\ApiStatus; use App\Models\Customer; -use App\Models\CustomerAnswer; +use App\Models\ModelAnswer; use App\Models\Question; use App\Models\Service; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -106,11 +106,11 @@ class StatsTest extends ApiBase $end = Carbon::now(); // * Make sure a customer answer is set. - /** @var \App\Models\CustomerAnswer $custAnswer */ - $custAnswer = CustomerAnswer::whereHas('answer', function ($q) use ($answersIds) { - $q->whereId($answersIds->first()); - })->firstOrNew(); - $custAnswer->customer()->associate($custAnswer->customer ?? Customer::query()->firstOrFail()); + /** @var \App\Models\ModelAnswer $custAnswer */ + $custAnswer = ModelAnswer::query() + ->where([['answer_id', $answersIds->first()], ['model_type', Customer::class]]) + ->firstOrNew(); + $custAnswer->model()->associate($custAnswer->model_id ?? Customer::query()->firstOrFail()); $custAnswer->answer()->associate($answersIds->first()); $custAnswer->saveOrFail(); @@ -122,11 +122,8 @@ class StatsTest extends ApiBase $datesIndexes = []; do { - $datesIndexes[$start->isoFormat('YYYY-MM-DD')] = $answersIds - // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassBeforeLastUsed - ->keys()->all(); - - $start = $start->addDay(); + $datesIndexes[$start->isoFormat('YYYY-MM-DD')] = $answersIds->keys()->all(); + $start = $start->addDay(); } while ($start->lessThanOrEqualTo($end)); $json = $response->decodeResponseJson(); diff --git a/tests/Client/CrmClientQuestionTest.php b/tests/Client/CrmClientQuestionTest.php index 32bca48..aaa1ded 100644 --- a/tests/Client/CrmClientQuestionTest.php +++ b/tests/Client/CrmClientQuestionTest.php @@ -2,7 +2,8 @@ namespace Tests\Client; -use App\Models\CustomerAnswer; +use App\Models\Customer; +use App\Models\ModelAnswer; use App\Models\Question as ModelsQuestion; use LDC\LDCCRM\CrmApiV4; use LDC\LDCCRM\Models\Answer; @@ -78,16 +79,13 @@ class CrmClientQuestionTest extends CrmClientTester $firstAnswer = $answeredQuestion->answers->first(); $firstAnswer->load('question'); - /** @var \App\Models\CustomerAnswer $custAnswer */ - $custAnswer = CustomerAnswer::whereHas('customer', function ($q) { - $q->whereId(static::$customer->id); - })->whereHas('answer', function ($q) use ($firstAnswer) { - $q->whereId($firstAnswer->id); - })->firstOrNew(); - - $custAnswer->customer()->associate(static::$customer); - $custAnswer->answer()->associate($firstAnswer); - $custAnswer->saveOrFail(); + /** @var \App\Models\ModelAnswer $modelAnswer */ + $modelAnswer = ModelAnswer::firstOrNew([ + 'model_type' => Customer::class, + 'model_id' => static::$customer->getKey(), + 'answer_id' => $firstAnswer->getKey(), + ]); + $modelAnswer->saveOrFail(); $api = new CrmApiV4(); $customer = $api->getCustomerOrNew(static::$info->hashcode); @@ -145,16 +143,13 @@ class CrmClientQuestionTest extends CrmClientTester $firstAnswer = $answeredQuestion->answers->first(); $firstAnswer->load('question'); - /** @var \App\Models\CustomerAnswer $custAnswer */ - $custAnswer = CustomerAnswer::whereHas('customer', function ($q) { - $q->whereId(static::$customer->id); - })->whereHas('answer', function ($q) use ($firstAnswer) { - $q->whereId($firstAnswer->id); - })->firstOrNew(); - - $custAnswer->customer()->associate(static::$customer); - $custAnswer->answer()->associate($firstAnswer); - $custAnswer->saveOrFail(); + /** @var \App\Models\ModelAnswer $modelAnswer */ + $modelAnswer = ModelAnswer::firstOrNew([ + 'model_type' => Customer::class, + 'model_id' => static::$customer->getKey(), + 'answer_id' => $firstAnswer->getKey(), + ]); + $modelAnswer->saveOrFail(); $api = new CrmApiV4(); $customer = $api->getCustomerOrNew(static::$info->hashcode); From 7cfe36b40f6ee5dab7f0463990b07a59c74ffae4 Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Wed, 23 Jul 2025 17:14:51 +0200 Subject: [PATCH 2/3] feat: :sparkles: add answers filter on single optins --- .../Controllers/Bo/SingleOptinController.php | 42 ++++++--- resources/ts/bo/bo.ts | 2 +- resources/ts/bo/pages/search-bar-brand.ts | 11 --- resources/ts/bo/pages/search-bar.ts | 30 +++++++ .../bo/modules/search-bar-brand.blade.php | 90 ++++++++++++++----- .../views/bo/modules/search-bar.blade.php | 2 +- 6 files changed, 129 insertions(+), 48 deletions(-) delete mode 100644 resources/ts/bo/pages/search-bar-brand.ts create mode 100644 resources/ts/bo/pages/search-bar.ts diff --git a/app/Http/Controllers/Bo/SingleOptinController.php b/app/Http/Controllers/Bo/SingleOptinController.php index abd1b70..f34b86c 100644 --- a/app/Http/Controllers/Bo/SingleOptinController.php +++ b/app/Http/Controllers/Bo/SingleOptinController.php @@ -3,8 +3,9 @@ namespace App\Http\Controllers\Bo; use App\Http\Controllers\Controller; +use App\Models\Answer; +use App\Models\Question; use App\Models\SingleOptin; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Str; @@ -19,14 +20,17 @@ class SingleOptinController extends Controller public function index(Request $request): \Illuminate\Contracts\View\View { /** @var \Illuminate\Database\Eloquent\Builder $query */ - $query = SingleOptin::query(); + $query = SingleOptin::query() + ->when( + $request->filled('brand'), + fn($q) => $q->whereHas('brand', fn($q) => $q->where('id', $request->brand)) + ) + ->when( + $request->filled('answer'), + fn($q) => $q->whereHas('answers', fn($q) => $q->where('id', $request->answer)) + ); - if ($request->brand) { - $query = $query->whereHas('brand', function (Builder $q) use ($request) { - $q->where('id', $request->brand); - }); - } - if ($request->search) { + if ($request->filled('search')) { $this->searchQuery( $query, $request->search, @@ -36,16 +40,28 @@ class SingleOptinController extends Controller } $this->sortQuery($query); - $searchFields = trans('E-mail'); + $query->with('brand'); - $query->with(['brand']); - - /** @var \Illuminate\Pagination\AbstractPaginator */ $singleoptinModels = $this->paginate($query); + $searchFields = trans('E-mail'); - return view('bo.pages.singleoptins.index', compact('singleoptinModels', 'searchFields')); + if ($request->filled('brand')) { + $questionModels = Question::where('brand_id', $request->brand)->get(); + + if ($request->filled('question')) { + $answerModels = Answer::where('question_id', $request->question)->get(); + } + } + + return view('bo.pages.singleoptins.index', [ + 'singleoptinModels' => $singleoptinModels, + 'searchFields' => $searchFields, + 'questionModels' => $questionModels ?? null, + 'answerModels' => $answerModels ?? null, + ]); } + /** * Remove the specified resource from storage. * diff --git a/resources/ts/bo/bo.ts b/resources/ts/bo/bo.ts index 3b1c053..23b50f3 100644 --- a/resources/ts/bo/bo.ts +++ b/resources/ts/bo/bo.ts @@ -16,7 +16,7 @@ import "./base/common"; // CUSTOM import "./pages/customer.delete"; import "./pages/data.delete"; -import "./pages/search-bar-brand"; +import "./pages/search-bar"; import "./pages/service.index"; import "./pages/stats"; import "./pages/statsExport"; diff --git a/resources/ts/bo/pages/search-bar-brand.ts b/resources/ts/bo/pages/search-bar-brand.ts deleted file mode 100644 index 8a9ac51..0000000 --- a/resources/ts/bo/pages/search-bar-brand.ts +++ /dev/null @@ -1,11 +0,0 @@ -const brandSelect = document.getElementById("brandSelect") as HTMLSelectElement; -if (brandSelect) { - brandSelect.addEventListener("change", function () { - if (brandSelect.value !== "0") { - const url = new URL(location.href); - url.searchParams.set("brand", brandSelect.value); - url.searchParams.delete("rst"); - location.assign(url.search); - } - }); -} diff --git a/resources/ts/bo/pages/search-bar.ts b/resources/ts/bo/pages/search-bar.ts new file mode 100644 index 0000000..bffb549 --- /dev/null +++ b/resources/ts/bo/pages/search-bar.ts @@ -0,0 +1,30 @@ +enum FilterKey { + Brand = "brand", + Question = "question", + Answer = "answer", +} + +function setupFilterListener(selectId: string, paramKey: FilterKey): void { + const select = document.getElementById(selectId) as HTMLSelectElement | null; + if (!select) return; + + select.addEventListener("change", () => { + const value = select.value; + if (value !== "0") { + const url = new URL(location.href); + url.searchParams.set(paramKey, value); + url.searchParams.delete("rst"); + location.assign(url.search); + } + }); +} + +const filterMap: Record = { + brandSelect: FilterKey.Brand, + questionSelect: FilterKey.Question, + answerSelect: FilterKey.Answer, +}; + +Object.entries(filterMap).forEach(([selectId, paramKey]) => { + setupFilterListener(selectId, paramKey); +}); diff --git a/resources/views/bo/modules/search-bar-brand.blade.php b/resources/views/bo/modules/search-bar-brand.blade.php index 6fd8f31..e22d980 100644 --- a/resources/views/bo/modules/search-bar-brand.blade.php +++ b/resources/views/bo/modules/search-bar-brand.blade.php @@ -1,26 +1,72 @@ - -
- - + + @foreach (\App\Models\Brand::all() as $brand) + + @endforeach + + + {{-- QUESTION SELECT --}} + @if (request()->routeIs('bo.single_optins.index') && $selectedBrand && $questionModels) + + @endif + + {{-- ANSWER SELECT --}} + @if ($selectedQuestion && $answerModels) + + @endif +
+
+
+ {{-- SEARCH FIELD --}} + + + + {{-- HIDDEN INPUTS --}} + + + @foreach (['brand' => $selectedBrand, 'question' => $selectedQuestion, 'answer' => $selectedAnswer] as $key => $val) + @if ($val) + + @endif @endforeach - -
🔍🠚
- - - - - @if (request()->brand) - - @endif - - + + +
+ diff --git a/resources/views/bo/modules/search-bar.blade.php b/resources/views/bo/modules/search-bar.blade.php index b70a953..3916e17 100644 --- a/resources/views/bo/modules/search-bar.blade.php +++ b/resources/views/bo/modules/search-bar.blade.php @@ -11,7 +11,7 @@
-
+
@if(request()->sort_col) From 6a60f00fbe056664e757fd4c60db2968c8a52083 Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Wed, 23 Jul 2025 17:19:37 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=F0=9F=94=96=20release=20v4.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index fa60577..569c9c3 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "ldc/ldccrm", - "version": "4.0.9", + "version": "4.1.0", "type": "project", "description": "LDC CRM", "license": "MIT",