Merge branch 'update' into 'develop'

chore: 🔖 release v4.1.0

Closes #41

See merge request ldc-web/projets/crm!33
This commit is contained in:
Vincent BRIDIER 2025-07-25 12:54:21 +00:00
commit af090bbef7
39 changed files with 708 additions and 320 deletions

View file

@ -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

View file

@ -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();
});

View file

@ -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) {

View file

@ -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);
}
}

View file

@ -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();
});

View file

@ -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(';');

View file

@ -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.
*

View file

@ -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',
];
}
}

View file

@ -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',
];
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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<App\Models\Customer>
* $customers Customer BelongsToMany relation.
* @property-read \Illuminate\Database\Eloquent\Collection<Illuminate\Database\Eloquent\Model> $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');
}
}

View file

@ -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<App\Models\Brand>
* $brands Brand BelongsToMany relation.
* @property-read \Illuminate\Database\Eloquent\Collection<App\Models\Answer>
* $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();
}
}

View file

@ -1,49 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
/**
* @property integer $answer_id Foreign key Answer.
* @property integer $customer_id Foreign key Customer.
* @property-read \Illuminate\Support\Carbon $created_at Created date.
*
* @property-read \App\Models\Customer $customer Customer BelongsTo relation.
* @property-read \App\Models\Answer $answer Answer BelongsTo relation.
*/
class CustomerAnswer extends Pivot
{
/**
* Inhibit update_at column as we only have
* created_at.
*
* @return null
*/
public function getUpdatedAtColumn()
{
return null;
}
// * RELATIONS
/**
* Customer belongsTo relation.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function customer(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->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);
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property integer $answer_id
* @property integer $model_id
* @property string $model_type
* @property-read \Illuminate\Support\Carbon $created_at
*
* @property-read \App\Models\Answer $answer
* @property-read \Illuminate\Database\Eloquent\Model $model
*/
class ModelAnswer extends Model
{
/**
* Define table name.
*
* @var string
*/
protected $table = 'model_answer';
/**
* The attributes that are mass assignable.
*
* @var string[]
*/
protected $fillable = [
'answer_id',
'model_id',
'model_type',
];
/**
* Inhibit update_at column as we only have
* created_at.
*
* @return null
*/
public function getUpdatedAtColumn(): null
{
return null;
}
/**
* Customer belongsTo relation.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function model(): \Illuminate\Database\Eloquent\Relations\MorphTo
{
return $this->morphTo();
}
/**
* Relation vers la réponse.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function answer()
{
return $this->belongsTo(Answer::class);
}
}

View file

@ -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<App\Models\Answer> $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');
}
}

View file

@ -1,6 +1,6 @@
{
"name": "ldc/ldccrm",
"version": "4.0.9",
"version": "4.1.0",
"type": "project",
"description": "LDC CRM",
"license": "MIT",

View file

@ -0,0 +1,32 @@
<?php
namespace Database\Factories;
use App\Models\Brand;
use App\Models\SingleOptin;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class SingleOptinFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = SingleOptin::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition(): array
{
return [
'hashcode' => Str::uuid(),
'email' => $this->faker->email(),
'brand_id' => $this->faker->randomElement(Brand::pluck('id')),
];
}
}

View file

@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::table('customer_answer', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -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);
});
}
}

View file

@ -17,7 +17,8 @@ class DatabaseSeeder extends Seeder
BrandSeeder::class,
CustomerSeeder::class,
BrandStatSeeder::class,
UserSeeder::class
UserSeeder::class,
SingleOptinSeeder::class,
]);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace Database\Seeders;
use App\Models\Answer;
use App\Models\SingleOptin;
use Illuminate\Database\Seeder;
class SingleOptinSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run(): void
{
SingleOptin::factory(20)->create()->each(function (SingleOptin $singleOptin) {
$answerModels = Answer::inRandomOrder()->take(rand(1, 3))->pluck('id');
$singleOptin->answers()->attach($answerModels);
});
}
}

2
package-lock.json generated
View file

@ -1,5 +1,5 @@
{
"name": "srcs",
"name": "crm",
"lockfileVersion": 3,
"requires": true,
"packages": {

View file

@ -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";

View file

@ -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);
}
});
}

View file

@ -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<string, FilterKey> = {
brandSelect: FilterKey.Brand,
questionSelect: FilterKey.Question,
answerSelect: FilterKey.Answer,
};
Object.entries(filterMap).forEach(([selectId, paramKey]) => {
setupFilterListener(selectId, paramKey);
});

View file

@ -1,26 +1,72 @@
<form action="{{ request()->fullUrlWithQuery(['rst' => null]) }}">
<div class="input-group mb-3">
<label class="input-group-text" for="brandSelect">{{ __('Filter par Marque') }}</label>
<select class="form-select" id="brandSelect">
<option value="0" @if (!request()->brand) selected @endif>{{ __('Sélectionnez une marque..') }}</option>
@foreach (\App\Models\Brand::all() as $brand)
<option value="{{ $brand->id }}" @if (request()->brand == "$brand->id") selected @endif>{{ $brand->name }}</option>
@php
$selectedBrand = request()->brand;
$selectedQuestion = request()->question;
$selectedAnswer = request()->answer;
$search = request()->search;
$sortCol = request()->sort_col;
$sortWay = request()->sort_way;
@endphp
<form action="{{ request()->fullUrlWithQuery(['rst' => null]) }}" class="row gx-2">
<div class="col-12 col-lg-6">
{{-- BRAND SELECT --}}
<select class="form-select mb-1" id="brandSelect">
<option value="0" @selected(!$selectedBrand)>{{ __('Sélectionnez une marque...') }}</option>
@foreach (\App\Models\Brand::all() as $brand)
<option value="{{ $brand->getKey() }}" @selected($selectedBrand == $brand->getKey())>
{{ $brand->name }}
</option>
@endforeach
</select>
{{-- QUESTION SELECT --}}
@if (request()->routeIs('bo.single_optins.index') && $selectedBrand && $questionModels)
<select class="form-select mb-1" id="questionSelect">
<option value="0" @selected(!$selectedQuestion)>{{ __('Sélectionnez une question...') }}</option>
@foreach ($questionModels as $question)
<option value="{{ $question->getKey() }}" @selected($selectedQuestion == $question->getKey())>
{{ $question->question }}
</option>
@endforeach
</select>
@endif
{{-- ANSWER SELECT --}}
@if ($selectedQuestion && $answerModels)
<select class="form-select mb-1" id="answerSelect">
<option value="0" @selected(!$selectedAnswer)>{{ __('Sélectionnez une réponse...') }}</option>
@foreach ($answerModels as $answer)
<option value="{{ $answer->getKey() }}" @selected($selectedAnswer == $answer->getKey())>
{{ $answer->answer }}
</option>
@endforeach
</select>
@endif
</div>
<div class="col-12 col-lg-6 d-flex justify-content-center align-items-start">
<div class="input-group">
{{-- SEARCH FIELD --}}
<label class="input-group-text" for="searchField">{{ $searchFields }}</label>
<input class="form-control" id="searchField" name="search" type="search" value="{{ $search }}"
placeholder="Recherche.." />
{{-- HIDDEN INPUTS --}}
<input type="hidden" name="sort_col" type="text" value="{{ $sortCol }}">
<input type="hidden" name="sort_way" type="text" value="{{ $sortWay }}">
@foreach (['brand' => $selectedBrand, 'question' => $selectedQuestion, 'answer' => $selectedAnswer] as $key => $val)
@if ($val)
<input type="hidden" name="{{ $key }}" type="text" value="{{ $val }}">
@endif
@endforeach
</select>
<div class="input-group-text bg-warning text-bg-warning">🔍🠚</div>
<label class="input-group-text" for="searchField">{{ $searchFields }}</label>
<input class="form-control" id="searchField" name="search" type="search" value="{{ request()->search }}"
placeholder="Recherche.." />
<input class="d-none" name="sort_col" type="text" value="{{ request()->sort_col }}" />
<input class="d-none" name="sort_way" type="text" value="{{ request()->sort_way }}" />
@if (request()->brand)
<input class="d-none" name="brand" type="hidden" value="{{ request()->brand }}" />
@endif
<button class="btn btn-primary" data-bs-toggle="tooltip" type="submit" title="{{ __('Lancer la recherche') }}">
<i class="fas fa-search"></i>
</button>
<a class="btn btn-danger" data-bs-toggle="tooltip"
href="{{ request()->fullUrlWithQuery(['rst' => 'true', 'search' => null, 'brand' => null]) }}"
{{-- ACTION BUTTONS --}}
<button class="btn btn-primary" data-bs-toggle="tooltip" type="submit"
title="{{ __('Lancer la recherche') }}">
<i class="fas fa-search"></i>
</button>
</div>
<a class="btn btn-danger ms-2" data-bs-toggle="tooltip"
href="{{ request()->fullUrlWithQuery(['rst' => 'true', 'search' => null, 'brand' => null, 'question' => null, 'answer' => null]) }}"
title="{{ __('Supprimer la recherche') }}">
<i class="fas fa-backspace"></i>
</a>

View file

@ -11,7 +11,7 @@
<div class="row mb-3">
<div class="col-12">
<form id="searchForm" action="/{{ request()->path() }}">
<div class="input-group pt-3">
<div class="input-group">
<label class="input-group-text" for="searchField">{{ $searchFields }}</label>
@if(request()->sort_col)
<input class="d-none" type="text" name="sort_col" value="{{ request()->sort_col }}">

View file

@ -16,8 +16,9 @@
<tr>
<td class="text-center align-middle">{{ $answerModel->answer }}</td>
<td class="text-center align-middle">
<span
class="badge bg-warning text-bg-warning text-wrap">{{ $answerModel->updated_at->formatLocalized('%d/%m/%Y à %H:%M') }}</span>
<span class="badge bg-warning text-bg-warning text-wrap">
{{ $answerModel->updated_at->isoFormat('LLLL') }}
</span>
</td>
<td class="text-end align-middle">
<form class="btn-group" id="dataDelete"

View file

@ -41,7 +41,7 @@ Route::prefix('v2')->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 () {

View file

@ -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
{

View file

@ -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.'
);
}
/**

View file

@ -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

View file

@ -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();

View file

@ -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
{

View file

@ -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.'
);
}
/**

View file

@ -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

View file

@ -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();

View file

@ -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);