diff --git a/app/Casts/AsArrayOfInput.php b/app/Casts/AsArrayOfInput.php new file mode 100644 index 0000000..a957b5b --- /dev/null +++ b/app/Casts/AsArrayOfInput.php @@ -0,0 +1,46 @@ + + * @phpcs:disable Squiz.Commenting.FunctionComment.TypeHintMissing + */ + public function get($model, string $key, $value, array $attributes): array + { + // phpcs:enable + return \collect(\json_decode($value, true))->map(function ($input) use ($model, $key, $attributes) { + $inputCast = new AsInput(); + return $inputCast->get($model, $key, \json_encode($input), $attributes); + })->all(); + } + + /** + * Prepare the given value for storage. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param \App\Lib\Inputs\Definition\Input $value + * @param array $attributes + * @return string Json encoded array<\App\Lib\Inputs\Definition\Input> + * @phpcs:disable Squiz.Commenting.FunctionComment.TypeHintMissing + */ + public function set($model, string $key, $value, array $attributes) + { + // phpcs:enable + return \json_encode(\collect($value)->map(function ($input) use ($model, $key, $attributes) { + $inputCast = new AsInput(); + return \json_decode($inputCast->set($model, $key, $input, $attributes)); + })->all()); + } +} diff --git a/app/Casts/AsInput.php b/app/Casts/AsInput.php new file mode 100644 index 0000000..0a4c6c5 --- /dev/null +++ b/app/Casts/AsInput.php @@ -0,0 +1,69 @@ + $data */ + $inputData = \json_decode($value, true); + $inputClass = InputTypeEnum::make($inputData['type'])->inputClass; + return $inputClass::from(InputData::make(...\array_values($inputData)), $inputData['required'] ?? false); + } + + /** + * Prepare the given value for storage. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param \App\Lib\Inputs\Definition\Input $value + * @param array $attributes + * @return string Json encoded \App\Lib\Inputs\Definition\Input + * @phpcs:disable Squiz.Commenting.FunctionComment.TypeHintMissing + * @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceBeforeLastUsed + * @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInImplementedInterfaceAfterLastUsed + */ + public function set($model, string $key, $value, array $attributes) + { + // phpcs:enable + $this->assertIsInput($value); + /** @var \App\Lib\Inputs\Definition\Input $value */ + return $value->toJson(); + } + + /** + * Assert is instance of \App\Lib\Inputs\Definition\Input + * @param mixed $value + * @return void + * @throws \RuntimeException If the value is not an input. + */ + private function assertIsInput($value): void + { + if (!\is_object($value) or !\in_array(Input::class, \class_parents($value))) { + $value = \json_encode($value); + throw new \RuntimeException( + __CLASS__ . ' needs value to be array of \'' . Input::class . "' given is {$value}" + ); + } + } +} diff --git a/app/Enums/Formularies/InputTypeEnum.php b/app/Enums/Formularies/InputTypeEnum.php index 771ad97..6ab3d1b 100644 --- a/app/Enums/Formularies/InputTypeEnum.php +++ b/app/Enums/Formularies/InputTypeEnum.php @@ -2,17 +2,28 @@ namespace App\Enums\Formularies; +use App\Lib\Inputs\InputCheckbox; +use App\Lib\Inputs\InputEmail; +use App\Lib\Inputs\InputPhone; +use App\Lib\Inputs\InputRadio; +use App\Lib\Inputs\InputSelect; +use App\Lib\Inputs\InputSelectMultiple; +use App\Lib\Inputs\InputText; use Kwaadpepper\Enum\BaseEnumRoutable; /** * This defines a data type. * + * @property-read integer value + * @property-read string label + * @property-read string inputClass InputType class. + * * @method static self text() input text * @method static self email() input email * @method static self phone() input phone * @method static self radio() input radio * @method static self select() input select - * @method static self multipleselect() input multipleselect + * @method static self selectMultiple() input selectMultiple * @method static self checkbox() input checkbox */ final class InputTypeEnum extends BaseEnumRoutable @@ -29,7 +40,7 @@ final class InputTypeEnum extends BaseEnumRoutable 'phone' => 3, 'radio' => 4, 'select' => 5, - 'multipleselect' => 6, + 'selectMultiple' => 6, 'checkbox' => 7, ]; } @@ -46,8 +57,39 @@ final class InputTypeEnum extends BaseEnumRoutable 'phone' => __('téléphone'), 'radio' => __('radio'), 'select' => __('liste déroulante'), - 'multipleselect' => __('liste déroulante à choix multiple'), + 'selectMultiple' => __('liste déroulante à choix multiple'), 'checkbox' => __('case à cocher'), ]; } + + /** + * Enum input Types + * + * @return array + */ + protected static function inputTypes(): array + { + return [ + 'text' => InputText::class, + 'email' => InputEmail::class, + 'phone' => InputPhone::class, + 'radio' => InputRadio::class, + 'select' => InputSelect::class, + 'selectMultiple' => InputSelectMultiple::class, + 'checkbox' => InputCheckbox::class, + ]; + } + + /** + * @param string $attribute + * @return integer|string + * @throws \Kwaadpepper\Enum\Exceptions\UnknownEnumProperty If the prop cannot be found. + */ + public function __get(string $attribute) + { + if ($attribute === 'inputClass') { + return $this->inputTypes()[$this->getDefinition()]; + } + return parent::__get($attribute); + } } diff --git a/app/Http/Controllers/API/SyncController.php b/app/Http/Controllers/API/SyncController.php index cda8686..04be150 100644 --- a/app/Http/Controllers/API/SyncController.php +++ b/app/Http/Controllers/API/SyncController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers\API; use App\Http\Controllers\Controller; +use App\Http\Requests\Api\Contacts\DestroyContactsRequest; +use App\Http\Requests\Api\Contacts\UpsertContactsRequest; use App\Http\Resources\AppUserResource; use App\Http\Resources\ContactResource; use App\Http\Resources\DirectoryResource; @@ -18,7 +20,9 @@ use App\Models\Formulary; use App\Models\Setting; use Google\Cloud\Vision\V1\ImageAnnotatorClient; use GuzzleHttp\Exception\ClientException; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\URL; @@ -35,17 +39,67 @@ class SyncController extends Controller return AppUserResource::collection(AppUser::where('status', 1)->get()); } + // *** --- START CONTACTS --- + /** - * @param \Illuminate\Http\Request $request - * @return \App\Http\Resources\ContactsCollection + * Get Contacts + * + * @param \App\Models\AppUser $app_user + * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection */ - public function getContacts(Request $request) + public function getContacts(AppUser $app_user) { - $userId = $request->userId; - $this->syncContacts($request->contacts, $userId); - return ContactResource::collection(Contact::where('app_user_id', $userId)->get()); + return ContactResource::collection( + Contact::query()->whereHas('appUser', function (Builder $q) use ($app_user) { + $q->where('id', $app_user->id); + })->get() + ); } + /** + * Upsert contacts + * + * @param \App\Http\Requests\Api\Contacts\UpsertContactsRequest $request + * @param \App\Models\AppUser $app_user + * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection + */ + public function upsertContact(UpsertContactsRequest $request, AppUser $app_user) + { + $contactQuery = Contact::query()->whereHas('appUser', function (Builder $q) use ($app_user) { + $q->where('id', $app_user->id); + }); + + \collect($request->contacts)->each(function (array $contact) use ($contactQuery) { + Arr::set($contact, 'data', Arr::get($contact, 'dataParsed')); + /** @var \App\Models\Contact */ + $model = $contactQuery->where('id', Arr::get($contact, 'id'))->firstOrNew(); + $model->fill($contact); + $model->saveOrFail(); + }); + + // * HTTP 200 is ok. + return \response()->json(); + } + + /** + * Destroy contacts + * + * @param \App\Http\Requests\Api\Contacts\DestroyContactsRequest $request + * @param \App\Models\AppUser $app_user + * @return \Illuminate\Http\Resources\Json + */ + public function destroyContacts(DestroyContactsRequest $request, AppUser $app_user) + { + Contact::query()->whereIn('id', $request->contacts)->whereHas('appUser', function (Builder $q) use ($app_user) { + $q->where('id', $app_user->id); + })->delete(); + + // * HTTP 200 is ok. + return \response()->json(); + } + + // *** --- END CONTACTS --- + /** * @param \Illuminate\Http\Request $request * @return \App\Http\Resources\DirectoriesCollection @@ -162,76 +216,6 @@ class SyncController extends Controller return 'OK'; } - /** - * @param mixed $contacts - * @param mixed $userId - * @return void - */ - private function syncContacts($contacts, $userId) - { - $contacts = json_decode(json_encode($contacts), false); - foreach ($contacts as $contact) { - if ($contact->to_delete === true) { - $tmp = Contact::where([ - ['id', '=', $contact->uniq_id], - ['app_user_id', '=', $contact->app_user_id] - ])->first(); - if ($tmp) { - $tmp->delete(); - } - } else { - if ($contact->app_user_id != $userId) { - $tmp = Contact::where([ - ['id', '=', $contact->uniq_id], - ['app_user_id', '=', $userId] - ])->first(); - if ($tmp) { - $tmp->delete(); - } - } - $tmp = Contact::where([ - ['id', '=', $contact->uniq_id], - ['app_user_id', '=', $contact->app_user_id] - ])->first(); - if ($tmp) { - $tmp->email = $contact->email; - $tmp->type = $contact->type; - $tmp->other = $contact->other; - $tmp->select1 = $contact->select1; - $tmp->select2 = $contact->select2; - $tmp->lastname = $contact->lastname; - $tmp->firstname = $contact->firstname; - $tmp->company = $contact->company; - $tmp->address = $contact->address; - $tmp->phone = empty($contact->phone) ? "" : $contact->phone; - $tmp->products = $contact->products; - $tmp->notes = $contact->notes; - $tmp->visits = $contact->visits; - $tmp->app_user_id = $contact->app_user_id; - $tmp->save(); - } else { - $tmp = new Contact(); - $tmp->id = $contact->uniq_id; - $tmp->email = $contact->email; - $tmp->type = $contact->type; - $tmp->other = $contact->other; - $tmp->select1 = $contact->select1; - $tmp->select2 = $contact->select2; - $tmp->lastname = $contact->lastname; - $tmp->firstname = $contact->firstname; - $tmp->company = $contact->company; - $tmp->address = $contact->address; - $tmp->phone = empty($contact->phone) ? "" : $contact->phone; - $tmp->products = json_encode($contact->products); - $tmp->notes = $contact->notes; - $tmp->visits = $contact->visits; - $tmp->app_user_id = $contact->app_user_id; - $tmp->save(); - } //end if - } //end if - } //end foreach - } - /** * @param \Illuminate\Http\Request $request * @return void diff --git a/app/Http/Controllers/Bo/AppUserController.php b/app/Http/Controllers/Bo/AppUserController.php index 9fcd107..5a7a04f 100644 --- a/app/Http/Controllers/Bo/AppUserController.php +++ b/app/Http/Controllers/Bo/AppUserController.php @@ -49,6 +49,7 @@ class AppUserController extends Controller /** @var integer $pagination Items per page */ if ($request->pagination) { + // FIXME: Add pagination validation. Cache::forever('pagination', intval($request->pagination)); } $app_users = $app_users->paginate(intval(Cache::get('pagination')) ?? config('pagination.default')); @@ -64,7 +65,7 @@ class AppUserController extends Controller public function create() { $directories = Directory::renderDirectories(Directory::where('needAuthorization', 1) - ->orderBy('name', 'ASC')->get()); + ->orderBy('name', 'ASC')->get()); return view('bo.pages.app-users.create', compact('directories')); } @@ -109,7 +110,7 @@ class AppUserController extends Controller public function edit(AppUser $app_user) { $directories = Directory::renderDirectories(Directory::where('needAuthorization', 1) - ->orderBy('name', 'ASC')->get()); + ->orderBy('name', 'ASC')->get()); $app_user->directories = Directory::renderDirectories($app_user->directories); return view('bo.pages.app-users.edit', [ diff --git a/app/Http/Controllers/Bo/ContactController.php b/app/Http/Controllers/Bo/ContactController.php new file mode 100644 index 0000000..dda14da --- /dev/null +++ b/app/Http/Controllers/Bo/ContactController.php @@ -0,0 +1,60 @@ +search) { + $this->searchQuery( + $contacts, + $request->search, + null, + 'firstname', + 'lastname', + 'email', + ); + } + $searchFields = \implode(',', [trans('Nom'), trans('Prénom'), trans('Email')]); + + /** Sort columns with a query */ + $this->sortQuery($contacts); + + /** @var integer $pagination Items per page */ + if ($request->pagination) { + // FIXME: Add pagination validation. + Cache::forever('pagination', intval($request->pagination)); + } + $contactModelList = $contacts->paginate(intval(Cache::get('pagination')) ?? config('pagination.default')); + + return view('bo.pages.contacts.index', compact('contactModelList', 'searchFields')); + } + + /** + * Display the specified resource. + * + * @param \App\Models\Contact $contact + * @return \Illuminate\Http\Response + */ + public function show(Contact $contact) + { + return \view('bo.pages.contacts.show', ['contactModel' => $contact]); + } +} diff --git a/app/Http/Controllers/Bo/DashboardController.php b/app/Http/Controllers/Bo/DashboardController.php index 6b04e7e..18f5d26 100644 --- a/app/Http/Controllers/Bo/DashboardController.php +++ b/app/Http/Controllers/Bo/DashboardController.php @@ -19,48 +19,4 @@ class DashboardController extends Controller { return view('bo.pages.dashboard'); } - - /** - * Récupérer les contacts (fichier .csv) et les fichiers. - * - * @return \Illuminate\Http\Response|\Illuminate\Contracts\Routing\ResponseFactory - */ - public function exportContact() - { - $csv_file = 'export-contacts.csv'; - $csvExporter = new \Laracsv\Export(); - $contacts = Contact::all(); - - $csvExporter->beforeEach(function ($contact) { - $contact->app_user_id = $contact->firstname . " " . $contact->lastname; - $contact->phone = "$contact->phone"; - }); - - $csvExporter->getWriter()->setDelimiter(';'); - $csvExporter->getWriter()->setEnclosure('"'); - $csvExporter->getWriter()->setOutputBOM(\League\Csv\Reader::BOM_UTF8); - $csvExporter->build($contacts, [ - 'app_user_id' => 'Propriétaire', - 'email' => 'Email', - 'type' => 'Type du contact', - 'other' => 'Type autre', - 'select1' => 'Select n°1', - 'select2' => 'Select n°2', - 'name' => 'Nom', - 'firstname' => 'Prénom', - 'company' => 'Entreprise', - 'address' => 'Adresse', - 'phone' => 'Téléphone', - 'notes' => 'Notes', - 'visits' => 'Visites', - ]); - - Storage::disk('public')->put($csv_file, $csvExporter->getWriter()->getContent()); - - return response()->download( - storage_path('app/public/') . "$csv_file", - 'contacts.csv', - ['Content-Type' => 'text/csv; charset=utf-8'] - )->deleteFileAfterSend(); - } } diff --git a/app/Http/Controllers/Bo/DocumentController.php b/app/Http/Controllers/Bo/DocumentController.php index 9129547..3c300cd 100644 --- a/app/Http/Controllers/Bo/DocumentController.php +++ b/app/Http/Controllers/Bo/DocumentController.php @@ -59,6 +59,7 @@ class DocumentController extends Controller /** @var integer $pagination Items per page */ if ($request->pagination) { + // FIXME: Add pagination validation. Cache::forever('pagination', intval($request->pagination)); } $documents = $documents->paginate(intval(Cache::get('pagination')) ?? config('pagination.default')); diff --git a/app/Http/Controllers/Bo/FormularyController.php b/app/Http/Controllers/Bo/FormularyController.php index 042401e..e447950 100644 --- a/app/Http/Controllers/Bo/FormularyController.php +++ b/app/Http/Controllers/Bo/FormularyController.php @@ -44,6 +44,7 @@ class FormularyController extends Controller /** @var integer $pagination Items per page */ if ($request->pagination) { + // FIXME: Add pagination validation. Cache::forever('pagination', intval($request->pagination)); } $formulariesModel = $formularies->paginate(intval(Cache::get('pagination')) ?? config('pagination.default')); @@ -100,10 +101,10 @@ class FormularyController extends Controller { if ($formulary->status->value === InputStatusEnum::system()->value) { return redirect()->route('bo.formularies.index') - ->with('error', trans( - 'Administration du champs :name impossible car il est vérouillé !', - ['name' => $formulary->name] - )); + ->with('error', trans( + 'Administration du champs :name impossible car il est vérouillé !', + ['name' => $formulary->name] + )); } else { return view('bo.pages.formularies.edit', [ 'formularyModel' => $formulary diff --git a/app/Http/Requests/Api/Contacts/DestroyContactsRequest.php b/app/Http/Requests/Api/Contacts/DestroyContactsRequest.php new file mode 100644 index 0000000..efae338 --- /dev/null +++ b/app/Http/Requests/Api/Contacts/DestroyContactsRequest.php @@ -0,0 +1,44 @@ +merge([ + 'contacts' => \collect($this->ids)->filter()->all() + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'contacts' => 'required|array', + 'contacts.*' => 'required|uuid', + ]; + } +} diff --git a/app/Http/Requests/Api/Contacts/UpsertContactsRequest.php b/app/Http/Requests/Api/Contacts/UpsertContactsRequest.php new file mode 100644 index 0000000..d74a1d9 --- /dev/null +++ b/app/Http/Requests/Api/Contacts/UpsertContactsRequest.php @@ -0,0 +1,132 @@ +merge([ + 'contacts' => \collect($this->contacts)->filter() + ->map(function ($contact) { + $contact = \collect($contact); + if ($contact->has('data')) { + $contact->put('data', \collect($contact->get('data'))->all()); + $contact->put('dataParsed', \collect($contact->get('data')) + ->map(function ($value, $index) { + return \call_user_func([$this, 'filterDataLine'], $value, $index); + }) + ->filter()->all()); + } + if ($contact->has('visits')) { + $contact->put('visits', \collect($contact->get('visits'))->filter() + ->map(function ($visit) { + if (!\is_array($visit)) { + return $visit; + } + $visit = \collect($visit); + $visit->put('note', $visit->get('note') ?? ''); + return $visit->all(); + })->all()); + } + return $contact->all(); + })->all() + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'contacts' => 'present|array', + 'contacts.*' => 'array', + 'contacts.*.id' => 'nullable|uuid|exists:contacts,id', + 'contacts.*.name' => 'required|string|max:255', + 'contacts.*.firstname' => 'required|string|max:255', + // * Dot not validate email here (soft validate), if it does not pass, app will be blocked. + 'contacts.*.email' => 'required|string|max:255', + 'contacts.*.phone' => 'required|string|max:255', + + // * DATA + 'contacts.*.data' => 'present|array', + 'contacts.*.dataParsed' => 'present', + 'contacts.*.data.*' => 'array', + 'contacts.*.data.*.title' => 'present|string|min:1', + 'contacts.*.data.*.type' => ['present', new EnumIsValidRule(InputTypeEnum::class)], + 'contacts.*.data.*.value' => 'present', + 'contacts.*.data.*.required' => 'present|boolean', + + 'contacts.*.notes' => 'nullable|string', + 'contacts.*.visits' => 'present|array', + 'contacts.*.visits.*' => 'required|array', + 'contacts.*.visits.*.title' => 'present|string', + 'contacts.*.visits.*.note' => 'present|string', + ]; + } + + /** + * Handle a line of data (input) + * + * @param array $data + * @param mixed $index + * @return mixed + * @throws \Illuminate\Validation\ValidationException In case of parse failure. + */ + private function filterDataLine(array $data, $index) + { + $data = \collect($data); + try { + $title = $data->get('title') ? \strval($data->get('title')) : null; + if (\is_null($title)) { + throw ValidationException::withMessages([ + 'error' => "wrong title on data index '{$index}'" + ]); + } + $type = InputTypeEnum::make($data->get('type') ? $data->get('type') : null); + $value = $data['value'] ?? null; + $required = $data->get('required') ? true : false; + $inputClass = $type->inputClass; + return $inputClass::from(InputData::make( + $title, + $type, + $value, + $required + ), $required); + } catch (\TypeError | \BadMethodCallException $e) { + throw ValidationException::withMessages([ + 'error' => "wrong type on data index '{$index}'" + ]); + } catch (\RuntimeException $e) { + $message = $e->getMessage(); + throw ValidationException::withMessages([ + 'error' => "wrong data on index '{$index}' '{$message}'" + ]); + } //end try + } +} diff --git a/app/Http/Requests/Bo/Formularies/StoreFormularyRequest.php b/app/Http/Requests/Bo/Formularies/StoreFormularyRequest.php index 4c999d3..11aa04f 100644 --- a/app/Http/Requests/Bo/Formularies/StoreFormularyRequest.php +++ b/app/Http/Requests/Bo/Formularies/StoreFormularyRequest.php @@ -39,6 +39,7 @@ class StoreFormularyRequest extends FormRequest */ public function rules() { + // FIXME: details has to be validated. $rules = [ 'type' => ['required', new EnumIsValidRule(InputTypeEnum::class)], 'placeholder' => 'present|array', diff --git a/app/Http/Resources/ContactResource.php b/app/Http/Resources/ContactResource.php index a0e0445..d191eb5 100644 --- a/app/Http/Resources/ContactResource.php +++ b/app/Http/Resources/ContactResource.php @@ -17,25 +17,20 @@ class ContactResource extends JsonResource public function toArray($request) { //phpcs:enable + /** @var \App\Models\Contact */ + $contact = $this; return [ - 'id' => $this->id, - 'email' => $this->email, - 'type' => $this->type, - 'other' => $this->other, - 'select1' => $this->select1, - 'select2' => $this->select2, - 'name' => $this->lastname, - 'firstname' => $this->firstname, - 'company' => $this->company, - 'address' => $this->address, - 'phone' => $this->phone, - 'products' => $this->products, - 'card_url' => $this->card_url, - 'notes' => $this->notes, - 'visits' => $this->visits, - 'user_id' => $this->app_user_id, - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, + 'id' => $contact->id, + 'name' => $contact->lastname, + 'firstname' => $contact->firstname, + 'email' => $contact->email, + 'phone' => $contact->phone, + 'data' => $contact->data, + 'notes' => $contact->notes, + 'visits' => $contact->visits, + 'app_user_id' => $contact->app_user_id, + 'created_at' => $contact->created_at, + 'updated_at' => $contact->updated_at, ]; } } diff --git a/app/Lib/Inputs/Definition/Input.php b/app/Lib/Inputs/Definition/Input.php new file mode 100644 index 0000000..a8923b7 --- /dev/null +++ b/app/Lib/Inputs/Definition/Input.php @@ -0,0 +1,141 @@ +value, $required); + $this->data = $data; + } + + /** + * Is this input required ? + * + * @return boolean + */ + public function isRequired(): bool + { + return $this->data->required; + } + + /** + * Parse DB line to Object + * + * ! Need to validate inputType + * + * @param \App\Lib\Inputs\Definition\InputData $data + * @param boolean $required + * @return static + * @throws \RuntimeException If the input type is not recognized, or data is invalid. + */ + public static function from(InputData $data, bool $required = true) + { + $inputTypeDataClass = static::class; + try { + $type = \is_numeric($data->type) ? intval($data->type) : strval($data->type); + return new static(InputData::make($data->title, $type, $data->value, $required), $required); + } catch (\BadMethodCallException $e) { + throw new \RuntimeException("Could not instanciate {$type}"); + } catch (\Illuminate\Validation\ValidationException $e) { + $encodedData = \json_encode($data); + throw new \RuntimeException("Data does not comply with {$inputTypeDataClass}, given {$encodedData}"); + } + } + + /** + * Parse DB line to Object + * + * ! Need to validate inputType + * + * @param string $title The line placeholder. + * @param string|array $data The line with data. + * @param boolean $required The line is required. + * @return static + * @throws \RuntimeException If the input type is not recognized, or data is invalid. + */ + public static function make(string $title, $data, bool $required = true) + { + return static::from( + InputData::make( + $title, + static::getType()->value, + $data, + $required + ), + $required + ); + } + + /** + * Validate data using getValidator instance + * + * @param string|array $data The line with data. + * @param boolean $required The line is required. + * @return void + * @throws \Illuminate\Validation\ValidationException If the data is invalid. + */ + public static function validateData($data, bool $required): void + { + static::getValidator($data, $required)->validate(); + } + + // * TRANSFORMERS + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray() + { + return (array)$this->data; + } + + /** + * Convert the object to its JSON representation. + * + * @param integer $options + * @return string + * @phpcs:disable Squiz.Commenting.FunctionComment.ScalarTypeHintMissing + */ + public function toJson($options = 0) + { + // phpcs:enable + return \json_encode($this->jsonSerialize(), $options); + } + + /** + * Convert the object into something JSON serializable. + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->toArray(); + } +} diff --git a/app/Lib/Inputs/Definition/InputData.php b/app/Lib/Inputs/Definition/InputData.php new file mode 100644 index 0000000..054aad5 --- /dev/null +++ b/app/Lib/Inputs/Definition/InputData.php @@ -0,0 +1,93 @@ + $value The input value, either a string or an array + * with the value index and label + * (for Multiselect and checkboxes) + * @property-read boolean $required The input is required ? + * @package App\Lib\Inputs + */ +class InputData extends \stdClass +{ + /** + * Get property + * + * @param string $name + * @return mixed + * @throws \TypeError If prop is undefined. + */ + public function __get(string $name): mixed + { + if (in_array($name, ['title', 'type', 'value', 'required'])) { + return $this->{$name}; + } + throw new \TypeError("undefined property {$name}"); + } + + /** + * Make and instance + * + * @param string $title + * @param \App\Enums\Formularies\InputTypeEnum|integer $type + * @param string|array $data + * @param boolean $required + * @return \App\Lib\Inputs\Definition\InputData + * @throws \RuntimeException If $type or $data is wrong. + */ + public static function make(string $title, $type, $data, bool $required): self + { + $ipTypeData = new self(); + $ipTypeData->title = $title; + $ipTypeData->type = (\is_object($type) and \get_class($type) === InputTypeEnum::class) ? + $type->value : $type; + $ipTypeData->value = $data; + $ipTypeData->required = $required; + self::assertIsInputTypeData($ipTypeData); + return $ipTypeData; + } + + /** + * Confirm $data is of type InputTypeData + * + * @param \App\Lib\Inputs\Definition\InputData $data + * @return void + * @throws \RuntimeException If $data is wrong. + */ + public static function assertIsInputTypeData(InputData $data) + { + $title = $data->title; + $type = $data->type; + $value = $data->value; + $required = $data->required; + + if (!\is_string($title)) { + throw new \RuntimeException("InputTypeData 'title' should be a string"); + } + if (!\is_integer($type)) { + throw new \RuntimeException("InputTypeData 'type' should be an integer"); + } + if (!\is_bool($required)) { + throw new \RuntimeException("InputTypeData 'required' should be a boolean"); + } + + try { + $type = InputTypeEnum::make($type); + } catch (\BadMethodCallException $e) { + throw new \RuntimeException($e->getMessage()); + } + + if (!\is_null($value) && !\is_string($value) && !\is_array($value)) { + $type = \gettype($value); + $value = \json_encode($value); + throw new \RuntimeException( + "InputTypeData 'value' should be string or array given '{$value}' of type '{$type}'" + ); + } + } +} diff --git a/app/Lib/Inputs/Definition/InputInterface.php b/app/Lib/Inputs/Definition/InputInterface.php new file mode 100644 index 0000000..6717454 --- /dev/null +++ b/app/Lib/Inputs/Definition/InputInterface.php @@ -0,0 +1,82 @@ + + * @throws \Illuminate\Validation\ValidationException If the data is invalid. + */ + public static function validateData($data, bool $required); +} diff --git a/app/Lib/Inputs/InputCheckbox.php b/app/Lib/Inputs/InputCheckbox.php new file mode 100644 index 0000000..32ec27e --- /dev/null +++ b/app/Lib/Inputs/InputCheckbox.php @@ -0,0 +1,44 @@ + $data], + [ + 'value' => 'array' . ($required ? '|min:1' : ''), + 'value.*' => 'required|string|max:255' + ] + ); + } +} diff --git a/app/Lib/Inputs/InputEmail.php b/app/Lib/Inputs/InputEmail.php new file mode 100644 index 0000000..3049b79 --- /dev/null +++ b/app/Lib/Inputs/InputEmail.php @@ -0,0 +1,41 @@ + $data], + ['value' => ($required ? 'required' : 'nullable') . '|string|email:rfc,spoof|max:255'] + ); + } +} diff --git a/app/Lib/Inputs/InputPhone.php b/app/Lib/Inputs/InputPhone.php new file mode 100644 index 0000000..e41456a --- /dev/null +++ b/app/Lib/Inputs/InputPhone.php @@ -0,0 +1,41 @@ + $data], + ['value' => ($required ? 'required' : 'nullable') . '|string|max:255'] + ); + } +} diff --git a/app/Lib/Inputs/InputRadio.php b/app/Lib/Inputs/InputRadio.php new file mode 100644 index 0000000..0375437 --- /dev/null +++ b/app/Lib/Inputs/InputRadio.php @@ -0,0 +1,41 @@ + $data], + ['value' => ($required ? 'required' : 'nullable') . '|string|max:255'] + ); + } +} diff --git a/app/Lib/Inputs/InputSelect.php b/app/Lib/Inputs/InputSelect.php new file mode 100644 index 0000000..757a83f --- /dev/null +++ b/app/Lib/Inputs/InputSelect.php @@ -0,0 +1,41 @@ + $data], + ['value' => ($required ? 'required' : 'nullable') . '|string|max:255'] + ); + } +} diff --git a/app/Lib/Inputs/InputSelectMultiple.php b/app/Lib/Inputs/InputSelectMultiple.php new file mode 100644 index 0000000..f6a4ee8 --- /dev/null +++ b/app/Lib/Inputs/InputSelectMultiple.php @@ -0,0 +1,22 @@ + $data], + ['value' => ($required ? 'required' : 'nullable') . '|string|max:255'] + ); + } +} diff --git a/app/Models/Contact.php b/app/Models/Contact.php index f529877..5a420ca 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -2,30 +2,31 @@ namespace App\Models; +use App\Casts\AsArrayOfInput; +use App\Lib\Inputs\InputEmail; +use App\Lib\Inputs\InputPhone; +use App\Lib\Inputs\InputText; +use App\Traits\Models\HasUuidKey; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Kwaadpepper\Enum\Traits\CastsEnums; /** * @property integer $app_user_id - * @property integer $id + * @property string $id * @property string $email - * @property string $type - * @property string $other - * @property string $select1 - * @property string $select2 - * @property string $name + * @property string $lastname * @property string $firstname - * @property string $company - * @property string $address * @property string $phone - * @property string $products - * @property string $card_url + * @property array<\App\Lib\Inputs\Definition\Input> $data * @property string $notes * @property string $visits */ class Contact extends Model { use CastsEnums; + use HasFactory; + use HasUuidKey; /** * fillable @@ -34,21 +35,24 @@ class Contact extends Model protected $fillable = [ 'app_user_id', 'email', - 'type', - 'other', - 'select1', - 'select2', - 'name', + 'lastname', 'firstname', - 'company', - 'address', + 'data', 'phone', - 'products', - 'card_url', 'notes', 'visits', ]; + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'data' => AsArrayOfInput::class, + 'visits' => 'array' + ]; + // * RELATIONSHIPS /** @@ -58,6 +62,40 @@ class Contact extends Model */ public function appUser() { - return $this->belongsTo(AppUser::class, 'id', 'app_user_id'); + return $this->belongsTo(AppUser::class, 'app_user_id', 'id'); + } + + // * METHODS + + /** + * Get all inputs + * + * @return array + * @throws \RuntimeException This should not happen since data was validated on contact insertion. + * ! It may happen if this model does not have an email. + * ! It may only happen if input validation rules are changed. + */ + public function allInputs(): array + { + return $this->mandatoryInputs() + $this->data; + } + + /** + * Get mandatory inputs + * + * @return array<\App\Lib\Inputs\Definition\Input> + * @throws \RuntimeException If this model does have an email. + */ + public function mandatoryInputs(): array + { + // * firsname + $data[] = InputText::make('Prénom', $this->firstname, false); + // * lastname + $data[] = InputText::make('Nom', $this->lastname, false); + // * email + $data[] = InputEmail::make('E-mail', $this->email, true); + // * phone + $data[] = InputPhone::make('Téléphone', $this->phone, false); + return $data; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5e10ac8..7fb62ff 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -18,7 +18,7 @@ class AppServiceProvider extends ServiceProvider public function boot() { // * HTTPS FIX - URL::forceScheme('https'); + // URL::forceScheme('http'); // * HA PROXY FIX App::singleton('clientIp', function () { diff --git a/app/Traits/Models/.gitkeep b/app/Traits/Models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/app/Traits/Models/HasUuidKey.php b/app/Traits/Models/HasUuidKey.php new file mode 100644 index 0000000..e76a4eb --- /dev/null +++ b/app/Traits/Models/HasUuidKey.php @@ -0,0 +1,43 @@ +{$model->getKeyName()})) { + $model->{$model->getKeyName()} = Str::uuid()->toString(); + } + }); + } + + /** + * Get the value indicating whether the IDs are incrementing. + * + * @return boolean + */ + public function getIncrementing() + { + return false; + } + + /** + * Get the auto-incrementing key type. + * + * @return string + */ + public function getKeyType() + { + return 'string'; + } +} diff --git a/config/session.php b/config/session.php index b096861..e057ebe 100644 --- a/config/session.php +++ b/config/session.php @@ -196,6 +196,6 @@ return [ | */ - 'same_site' => 'lax', + 'same_site' => null, ]; diff --git a/database/factories/ContactFactory.php b/database/factories/ContactFactory.php new file mode 100644 index 0000000..c816ffe --- /dev/null +++ b/database/factories/ContactFactory.php @@ -0,0 +1,81 @@ + */ + $formularies = $this->factoryForModel(Formulary::class)->count($this->faker->numberBetween(3, 9))->make(); + return [ + 'email' => $this->faker->unique()->email(), + 'lastname' => $this->faker->boolean() ? $this->faker->unique()->lastName() : '', + 'firstname' => $this->faker->boolean() ? $this->faker->unique()->firstName() : '', + 'phone' => $this->faker->boolean() ? $this->faker->unique()->phoneNumber() : '', + 'notes' => $this->faker->boolean() ? $this->faker->text() : null, + 'visits' => \collect(range(0, $this->faker->numberBetween(1, 5)))->mapWithKeys(function () { + return [ + 'title' => 'Visite du ' . $this->faker->dateTime()->format('D/M/Y H:i'), + 'note' => $this->faker->text() + ]; + }), + 'data' => $formularies->map(function (Formulary $formulary) { + $inputClass = $formulary->type->inputClass; + $details = \collect($formulary->details)->map(function (array $details) { + return $details['fr']; + }); + + $data = $this->faker->words(3, true); + + if ($formulary->type->equals(InputTypeEnum::email())) { + $data = $this->faker->unique()->email(); + } + if ($formulary->type->equals(InputTypeEnum::phone())) { + $data = $this->faker->phoneNumber(); + } + if ( + in_array($formulary->type->value, [ + InputTypeEnum::selectMultiple()->value, + InputTypeEnum::checkbox()->value + ]) + ) { + $data = \collect($details->random(min($details->count(), 2)))->all(); + } + if ( + in_array($formulary->type->value, [ + InputTypeEnum::select()->value, + InputTypeEnum::radio()->value + ]) + ) { + $data = $details->random(); + } + + return $inputClass::from(InputData::make( + $formulary->placeholder['fr'], + $formulary->type, + $data, + $formulary->required + ), $formulary->required); + }) + ]; + } +} diff --git a/database/factories/FormularyFactory.php b/database/factories/FormularyFactory.php index 7d40a93..914eb6d 100644 --- a/database/factories/FormularyFactory.php +++ b/database/factories/FormularyFactory.php @@ -22,7 +22,7 @@ class FormularyFactory extends Factory $details = null; if ( \in_array($type, [ - InputTypeEnum::multipleselect(), InputTypeEnum::select(), + InputTypeEnum::selectMultiple(), InputTypeEnum::select(), InputTypeEnum::radio(), InputTypeEnum::checkbox() ]) ) { diff --git a/database/migrations/2023_08_25_093125_upgrade_contacts_table.php b/database/migrations/2023_08_25_093125_upgrade_contacts_table.php new file mode 100644 index 0000000..1819035 --- /dev/null +++ b/database/migrations/2023_08_25_093125_upgrade_contacts_table.php @@ -0,0 +1,272 @@ +get(); + /** + * Keep columns, id, notes, visits, app_user_id + */ + Schema::table('contacts', function (Blueprint $table) { + $table->dropColumn('type'); + $table->dropColumn('other'); + $table->dropColumn('select1'); + $table->dropColumn('select2'); + $table->dropColumn('company'); + $table->dropColumn('address'); + $table->dropColumn('products'); + // * Just forget this column, dont migrate. + $table->dropColumn('card_url'); + $table->json('data')->after('phone') + ->comment('Array of \App\Lib\Inputs\Definition\InputData'); + }); + + $oldContacts->each(function (object $table) { + $contact = Contact::query()->where('id', $table->id)->firstOrFail(); + + // ! see this->getDataset. + $contact->data = $this->getInputs($table); + $contact->saveQuietly(); + }); + }); + } + + /** + * Reverse the migrations. + * + * @return void + * @throws \RuntimeException Wont undo that. + */ + public function down() + { + throw new \RuntimeException('Wont undo that'); + } + + /** + * Map $table to an array of inputs + * + * @param object $table + * @return array<\App\Lib\Inputs\Definition\Input> + */ + private function getInputs(object $table): array + { + /** + * Migrate other columns type, other, select1, select2, company, address, products + */ + + // * type + $data[] = InputSelect::make( + 'Type de client', + ([ + 'client' => 'Client', 'prospect' => 'Prospect', 'other' => 'Autre' + ])[$table->type] + ); + // * other + $data[] = InputText::make('Type de client (autre) :', $table->other, false); + // * select1 + $dataset = $this->getDataset('select1'); + $data[] = InputSelect::make($dataset['title']['fr'], $dataset['values'][$table->select1]['fr'] ?? null, false); + // * select2 + $dataset = $this->getDataset('select2'); + $data[] = InputSelect::make($dataset['title']['fr'], $dataset['values'][$table->select2]['fr'] ?? null, false); + // * company + $data[] = InputText::make('Société', $table->company, false); + // * address + $data[] = InputText::make('Adresse', $table->address, false); + // * products + $dataset = collect($this->getDataset('products'))->map(function (array $prodLangs) { + return $prodLangs['fr']; + })->all(); + $data[] = InputCheckbox::make( + 'Produits', + collect(json_decode($table->products, true)) + ->map(function ($hasProduct, int $index) use ($dataset) { + return $hasProduct ? $dataset[$index] : null; + })->filter()->values()->all(), + false + ); + return $data; + } + + /** + * Get App RHD fields + * + * ! This should be customized for the APP, here done for : APP_RHD + * ! CUSTOMIZE ME en fonction de l'app ! + * + * @param string $dataSet + * @return array + * @throws \Error IF dataset is unkown. + */ + private function getDataset(string $dataSet): array + { + $json = ''; + switch ($dataSet) { + case 'select1': + $json = '{ + "title": { + "fr": "Travaille avec", + "en": "Work with", + "de": "Arbeit mit" + }, + "values": { + "pole-sable": { + "fr": "P\u00f4le Sabl\u00e9", + "en": "P\u00f4le Sabl\u00e9", + "de": "P\u00f4le Sabl\u00e9" + }, + "snv": { + "fr": "SNV", + "en": "SNV", + "de": "SNV" + }, + "pole-sud-est": { + "fr": "P\u00f4le Sud-Est", + "en": "P\u00f4le Sud-Est", + "de": "P\u00f4le Sud-Est" + }, + "aquitaine": { + "fr": "Aquitaine", + "en": "Aquitaine", + "de": "Aquitaine" + }, + "sbv": { + "fr": "SBV", + "en": "SBV", + "de": "SBV" + }, + "arrive": { + "fr": "Arriv\u00e9", + "en": "Arriv\u00e9", + "de": "Arriv\u00e9" + }, + "plusieurs-filiales": { + "fr": "Plusieurs filiales", + "en": "Several subsidiaries", + "de": "Mehrere Filialen" + }, + "autre": { + "fr": "Autre", + "en": "Other", + "de": "Andere" + } + } + }'; + break; + case 'select2': + $json = '{ + "title": { + "fr": "Type de commerce", + "en": "Type of business", + "de": "Art des Gesch\u00e4fts" + }, + "values": { + "commerce-de-detail": { + "fr": "Commerce de d\u00e9tail", + "en": "Retail trade", + "de": "Einzelhandel" + }, + "restauration-commerciale": { + "fr": "Restauration Commerciale", + "en": "Commercial catering", + "de": "Kommerzielle Restauration" + }, + "restauration-collective": { + "fr": "Restauration Collective", + "en": "Collective catering", + "de": "Kollektiv Restauration" + }, + "grossiste-distributeur-cash": { + "fr": "Grossiste Distributeur Cash", + "en": "Wholesale Distributor Cash", + "de": "Gro\u00dfh\u00e4ndler H\u00e4ndler Cash" + }, + "pai": { + "fr": "PAI", + "en": "PAI", + "de": "PAI" + }, + "home-freezer-center": { + "fr": "Home Freezer Center", + "en": "Home Freezer Center", + "de": "Home Gefrierzentrum" + }, + "autres": { + "fr": "Autre", + "en": "Other", + "de": "Andere" + } + } + }'; + break; + case 'products': + $json = '[ + { + "fr": "Frais", + "en": "Fresh", + "de": "Frisch" + }, + { + "fr": "Surgel\u00e9s", + "en": "Frozen food", + "de": "Tiefk\u00fchlkost" + }, + { + "fr": "Volailles crues", + "en": "Raw poultry", + "de": "Rohes Gefl\u00fcgel" + }, + { + "fr": "\u00c9labor\u00e9s crus", + "en": "Prepared raw", + "de": "Roh ausgearbeitet" + }, + { + "fr": "\u00c9labor\u00e9s cuits", + "en": "Cooked products", + "de": "Gekochte Elaborate" + }, + { + "fr": "Bio et Label Rouge", + "en": "Bio and Label Rouge", + "de": "Bio und Label Rouge" + }, + { + "fr": "BBC", + "en": "BBC", + "de": "BBC" + }, + { + "fr": "Halal", + "en": "Halal", + "de": "Halal" + }, + { + "fr": "Autre", + "en": "Other", + "de": "Andere" + } + ]'; + break; + default: + throw new \Error('unkonwn dataset'); + } //end switch + return json_decode($json, true); + } +} diff --git a/database/seeders/AppUserSeeder.php b/database/seeders/AppUserSeeder.php index d16004c..0743d9f 100644 --- a/database/seeders/AppUserSeeder.php +++ b/database/seeders/AppUserSeeder.php @@ -14,10 +14,10 @@ class AppUserSeeder extends Seeder */ public function run() { - $i = 1; - AppUser::factory(15)->make()->each(function (AppUser $directory) use (&$i) { - $directory->order = $i; - $directory->saveOrFail(); + $i = \intval(AppUser::query()->max('order')); + AppUser::factory(15)->make()->each(function (AppUser $appUser) use (&$i) { + $appUser->order = $i; + $appUser->saveOrFail(); $i = $i + 1; }); } diff --git a/database/seeders/ContactSeeder.php b/database/seeders/ContactSeeder.php new file mode 100644 index 0000000..db4ecce --- /dev/null +++ b/database/seeders/ContactSeeder.php @@ -0,0 +1,30 @@ +getTable())->truncate(); + $appUsers = AppUser::all(); + if (!$appUsers->count()) { + $this->run(AppUser::class); + $appUsers = AppUser::all(); + } + Contact::factory(30)->make()->each(function (Contact $contact) use ($appUsers) { + $contact->appUser()->associate($appUsers->random()); + $contact->saveOrFail(); + }); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d44b251..e7ddd3c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -19,6 +19,7 @@ class DatabaseSeeder extends Seeder SettingSeeder::class, TagSeeder::class, FormularySeeder::class, + ContactSeeder::class, ]); } } diff --git a/database/seeders/FormularySeeder.php b/database/seeders/FormularySeeder.php index a22988c..dd4fc23 100644 --- a/database/seeders/FormularySeeder.php +++ b/database/seeders/FormularySeeder.php @@ -15,8 +15,8 @@ class FormularySeeder extends Seeder */ public function run() { - DB::table('formularies')->truncate(); + DB::table((new Formulary())->getTable())->truncate(); $this->call(MandatoryFormularySeeder::class); - Formulary::factory(14)->create(); + Formulary::factory(30)->create(); } } diff --git a/resources/lang/en/models.php b/resources/lang/en/models.php index 0b91cd2..0b79389 100644 --- a/resources/lang/en/models.php +++ b/resources/lang/en/models.php @@ -10,5 +10,6 @@ return [ 'settings' => 'Settings', 'tags' => 'Tags', 'formularies' => 'Formularies', + 'contacts' => 'Contacts', ] ]; diff --git a/resources/lang/fr/models.php b/resources/lang/fr/models.php index 0e9b84a..8949a8a 100644 --- a/resources/lang/fr/models.php +++ b/resources/lang/fr/models.php @@ -10,5 +10,6 @@ return [ 'settings' => 'Paramètres', 'tags' => 'Étiquettes', 'formularies' => 'Formulaires', + 'contacts' => 'Contacts', ] ]; diff --git a/resources/sass/bo/vendor/_bootstrap.scss b/resources/sass/bo/vendor/_bootstrap.scss index 604554a..59949e5 100644 --- a/resources/sass/bo/vendor/_bootstrap.scss +++ b/resources/sass/bo/vendor/_bootstrap.scss @@ -39,6 +39,7 @@ @import "~bootstrap/scss/card"; @import "~bootstrap/scss/breadcrumb"; @import "~bootstrap/scss/progress"; +@import "~bootstrap/scss/accordion"; // 6. Optionally include utilities API last to generate classes based on the Sass map in `_utilities.scss` @import "~bootstrap/scss/utilities/api"; diff --git a/resources/views/bo/layouts/nav-menu.blade.php b/resources/views/bo/layouts/nav-menu.blade.php index 8b7a8b4..c2d3a1c 100644 --- a/resources/views/bo/layouts/nav-menu.blade.php +++ b/resources/views/bo/layouts/nav-menu.blade.php @@ -1,58 +1,51 @@ @auth('backend')