add: full multi-tenancy control

This commit is contained in:
Cauê Faleiros
2026-02-02 15:31:15 -03:00
commit c6ec92802b
1711 changed files with 258106 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
{
"name": "krayin/laravel-attribute",
"license": "MIT",
"authors": [
{
"name": "Jitendra Singh",
"email": "jitendra@webkul.com"
}
],
"require": {
"krayin/laravel-core": "^1.0"
},
"autoload": {
"psr-4": {
"Webkul\\Attribute\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Webkul\\Attribute\\Providers\\AttributeServiceProvider"
],
"aliases": {}
}
},
"minimum-stability": "dev"
}

View File

@@ -0,0 +1,5 @@
<?php
return [
];

View File

@@ -0,0 +1,5 @@
<?php
namespace Webkul\Attribute\Contracts;
interface Attribute {}

View File

@@ -0,0 +1,5 @@
<?php
namespace Webkul\Attribute\Contracts;
interface AttributeOption {}

View File

@@ -0,0 +1,5 @@
<?php
namespace Webkul\Attribute\Contracts;
interface AttributeValue {}

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('attributes', function (Blueprint $table) {
$table->increments('id');
$table->string('code');
$table->string('name');
$table->string('type');
$table->string('lookup_type')->nullable();
$table->string('entity_type');
$table->integer('sort_order')->nullable();
$table->string('validation')->nullable();
$table->boolean('is_required')->default(0);
$table->boolean('is_unique')->default(0);
$table->boolean('quick_add')->default(0);
$table->boolean('is_user_defined')->default(1);
$table->unique(['code', 'entity_type']);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('attributes');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('attribute_options', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->nullable();
$table->integer('sort_order')->nullable();
$table->integer('attribute_id')->unsigned();
$table->foreign('attribute_id')->references('id')->on('attributes')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('attribute_options');
}
};

View File

@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('attribute_values', function (Blueprint $table) {
$table->increments('id');
$table->string('entity_type')->default('leads');
$table->text('text_value')->nullable();
$table->boolean('boolean_value')->nullable();
$table->integer('integer_value')->nullable();
$table->double('float_value')->nullable();
$table->datetime('datetime_value')->nullable();
$table->date('date_value')->nullable();
$table->json('json_value')->nullable();
$table->integer('entity_id')->unsigned();
$table->integer('attribute_id')->unsigned();
$table->foreign('attribute_id')->references('id')->on('attributes')->onDelete('cascade');
$table->unique(['entity_type', 'entity_id', 'attribute_id'], 'entity_type_attribute_value_index_unique');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('attribute_values');
}
};

View File

@@ -0,0 +1,39 @@
<?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.
*/
public function up(): void
{
Schema::table('attribute_values', function (Blueprint $table) {
$table->string('unique_id')->nullable();
});
$tablePrefix = DB::getTablePrefix();
DB::statement('UPDATE '.$tablePrefix."attribute_values SET unique_id = CONCAT(entity_id, '|', attribute_id)");
Schema::table('attribute_values', function (Blueprint $table) {
$table->unique('unique_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('attribute_values', function (Blueprint $table) {
$table->dropUnique(['unique_id']);
$table->dropColumn('unique_id');
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
namespace Webkul\Attribute\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Attribute\Contracts\Attribute as AttributeContract;
class Attribute extends Model implements AttributeContract
{
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'code',
'name',
'type',
'entity_type',
'lookup_type',
'is_required',
'is_unique',
'quick_add',
'validation',
'is_user_defined',
];
/**
* Get the options.
*/
public function options()
{
return $this->hasMany(AttributeOptionProxy::modelClass());
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Webkul\Attribute\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Attribute\Contracts\AttributeOption as AttributeOptionContract;
class AttributeOption extends Model implements AttributeOptionContract
{
public $timestamps = false;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'sort_order',
'attribute_id',
];
/**
* Get the attribute that owns the attribute option.
*/
public function attribute()
{
return $this->belongsTo(AttributeProxy::modelClass());
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Webkul\Attribute\Models;
use Konekt\Concord\Proxies\ModelProxy;
class AttributeOptionProxy extends ModelProxy {}

View File

@@ -0,0 +1,7 @@
<?php
namespace Webkul\Attribute\Models;
use Konekt\Concord\Proxies\ModelProxy;
class AttributeProxy extends ModelProxy {}

View File

@@ -0,0 +1,85 @@
<?php
namespace Webkul\Attribute\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Activity\Traits\LogsActivity;
use Webkul\Attribute\Contracts\AttributeValue as AttributeValueContract;
class AttributeValue extends Model implements AttributeValueContract
{
use LogsActivity;
/**
* Disable the default timestamps.
*
* @var bool
*/
public $timestamps = false;
/**
* Cast the attributes to their respective types.
*
* @var array
*/
protected $casts = [
'json_value' => 'array',
];
/**
* The attributes that are fillable for the model.
*
* @var array
*/
protected $fillable = [
'attribute_id',
'text_value',
'boolean_value',
'integer_value',
'float_value',
'datetime_value',
'date_value',
'json_value',
'entity_id',
'entity_type',
];
/**
* The attributes that are used for logging activity.
*
* @var array
*/
public static $attributeTypeFields = [
'text' => 'text_value',
'textarea' => 'text_value',
'price' => 'float_value',
'boolean' => 'boolean_value',
'select' => 'integer_value',
'multiselect' => 'text_value',
'checkbox' => 'text_value',
'email' => 'json_value',
'address' => 'json_value',
'phone' => 'json_value',
'lookup' => 'integer_value',
'datetime' => 'datetime_value',
'date' => 'date_value',
'file' => 'text_value',
'image' => 'text_value',
];
/**
* Get the attribute that owns the attribute value.
*/
public function attribute()
{
return $this->belongsTo(AttributeProxy::modelClass());
}
/**
* Get the parent entity model (leads, products, persons or organizations).
*/
public function entity()
{
return $this->morphTo();
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Webkul\Attribute\Models;
use Konekt\Concord\Proxies\ModelProxy;
class AttributeValueProxy extends ModelProxy {}

View File

@@ -0,0 +1,41 @@
<?php
namespace Webkul\Attribute\Providers;
use Illuminate\Routing\Router;
use Illuminate\Support\ServiceProvider;
class AttributeServiceProvider extends ServiceProvider
{
/**
* Bootstrap services.
*
* @return void
*/
public function boot(Router $router)
{
$this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
}
/**
* Register services.
*
* @return void
*/
public function register()
{
$this->registerConfig();
}
/**
* Register package config.
*
* @return void
*/
protected function registerConfig()
{
$this->mergeConfigFrom(
dirname(__DIR__).'/Config/attribute_lookups.php', 'attribute_lookups'
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Webkul\Attribute\Providers;
use Webkul\Core\Providers\BaseModuleServiceProvider;
class ModuleServiceProvider extends BaseModuleServiceProvider
{
protected $models = [
\Webkul\Attribute\Models\Attribute::class,
\Webkul\Attribute\Models\AttributeOption::class,
\Webkul\Attribute\Models\AttributeValue::class,
];
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Webkul\Attribute\Repositories;
use Webkul\Core\Eloquent\Repository;
class AttributeOptionRepository extends Repository
{
/**
* Specify Model class name
*
* @return mixed
*/
public function model()
{
return 'Webkul\Attribute\Contracts\AttributeOption';
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace Webkul\Attribute\Repositories;
use Illuminate\Container\Container;
use Illuminate\Support\Str;
use Webkul\Core\Eloquent\Repository;
class AttributeRepository extends Repository
{
/**
* Create a new repository instance.
*
* @return void
*/
public function __construct(
protected AttributeOptionRepository $attributeOptionRepository,
Container $container
) {
parent::__construct($container);
}
/**
* Specify Model class name
*
* @return mixed
*/
public function model()
{
return 'Webkul\Attribute\Contracts\Attribute';
}
/**
* @return \Webkul\Attribute\Contracts\Attribute
*/
public function create(array $data)
{
$options = isset($data['options']) ? $data['options'] : [];
$attribute = $this->model->create($data);
if (in_array($attribute->type, ['select', 'multiselect', 'checkbox']) && count($options)) {
$sortOrder = 1;
foreach ($options as $optionInputs) {
$this->attributeOptionRepository->create(array_merge([
'attribute_id' => $attribute->id,
'sort_order' => $sortOrder++,
], $optionInputs));
}
}
return $attribute;
}
/**
* @param int $id
* @param string $attribute
* @return \Webkul\Attribute\Contracts\Attribute
*/
public function update(array $data, $id, $attribute = 'id')
{
$attribute = $this->find($id);
$attribute->update($data);
if (! in_array($attribute->type, ['select', 'multiselect', 'checkbox'])) {
return $attribute;
}
if (! isset($data['options'])) {
return $attribute;
}
foreach ($data['options'] as $optionId => $optionInputs) {
$isNew = $optionInputs['isNew'] == 'true';
if ($isNew) {
$this->attributeOptionRepository->create(array_merge([
'attribute_id' => $attribute->id,
], $optionInputs));
} else {
$isDelete = $optionInputs['isDelete'] == 'true';
if ($isDelete) {
$this->attributeOptionRepository->delete($optionId);
} else {
$this->attributeOptionRepository->update($optionInputs, $optionId);
}
}
}
return $attribute;
}
/**
* @param string $code
* @return \Webkul\Attribute\Contracts\Attribute
*/
public function getAttributeByCode($code)
{
static $attributes = [];
if (array_key_exists($code, $attributes)) {
return $attributes[$code];
}
return $attributes[$code] = $this->findOneByField('code', $code);
}
/**
* @param int $lookup
* @param string $query
* @param array $columns
* @return array
*/
public function getLookUpOptions($lookup, $query = '', $columns = [])
{
$lookup = config('attribute_lookups.'.$lookup);
if (! count($columns)) {
$columns = [($lookup['value_column'] ?? 'id').' as id', ($lookup['label_column'] ?? 'name').' as name'];
}
if (Str::contains($lookup['repository'], 'UserRepository')) {
$userRepository = app($lookup['repository'])->where('status', 1);
$currentUser = auth()->guard('user')->user();
if ($currentUser?->view_permission === 'group') {
$query = urldecode($query);
$userIds = bouncer()->getAuthorizedUserIds();
return $userRepository
->when(! empty($userIds), fn ($queryBuilder) => $queryBuilder->whereIn('users.id', $userIds))
->when(! empty($query), fn ($queryBuilder) => $queryBuilder->where('users.name', 'like', "%{$query}%"))
->get();
} elseif ($currentUser?->view_permission === 'individual') {
return $userRepository->where('users.id', $currentUser->id);
}
return $userRepository->where('users.name', 'like', '%'.urldecode($query).'%')->get();
}
return app($lookup['repository'])->findWhere([
[$lookup['label_column'] ?? 'name', 'like', '%'.urldecode($query).'%'],
], $columns);
}
/**
* @param string $lookup
* @param int|array $entityId
* @param array $columns
* @return mixed
*/
public function getLookUpEntity($lookup, $entityId = null, $columns = [])
{
if (! $entityId) {
return;
}
$lookup = config('attribute_lookups.'.$lookup);
if (! count($columns)) {
$columns = [($lookup['value_column'] ?? 'id').' as id', ($lookup['label_column'] ?? 'name').' as name'];
}
if (is_array($entityId)) {
return app($lookup['repository'])->findWhereIn(
'id',
$entityId,
$columns
);
} else {
return app($lookup['repository'])->find($entityId, $columns);
}
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace Webkul\Attribute\Repositories;
use Carbon\Carbon;
use Illuminate\Container\Container;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Webkul\Attribute\Contracts\AttributeValue;
use Webkul\Core\Eloquent\Repository;
class AttributeValueRepository extends Repository
{
/**
* Create a new repository instance.
*
* @return void
*/
public function __construct(
protected AttributeRepository $attributeRepository,
Container $container
) {
parent::__construct($container);
}
/**
* Specify model class name.
*
* @return mixed
*/
public function model()
{
return AttributeValue::class;
}
/**
* Save attribute value.
*/
public function save(array $data, $attributes = []): void
{
if (empty($attributes)) {
$conditions = ['entity_type' => $data['entity_type']];
if (isset($data['quick_add'])) {
$conditions['quick_add'] = 1;
}
$attributes = $this->attributeRepository->where($conditions)->get();
}
foreach ($attributes as $attribute) {
$typeColumn = $this->model::$attributeTypeFields[$attribute->type];
if ($attribute->type === 'boolean') {
$data[$attribute->code] = isset($data[$attribute->code]) && $data[$attribute->code] ? 1 : 0;
}
if (! array_key_exists($attribute->code, $data)) {
continue;
}
if ($attribute->type === 'price'
&& isset($data[$attribute->code])
&& $data[$attribute->code] === ''
) {
$data[$attribute->code] = null;
}
if ($attribute->type === 'date' && $data[$attribute->code] === '') {
$data[$attribute->code] = null;
}
if ($attribute->type === 'multiselect' || $attribute->type === 'checkbox') {
$data[$attribute->code] = implode(',', $data[$attribute->code]);
}
if ($attribute->type === 'email' || $attribute->type === 'phone') {
$data[$attribute->code] = $this->sanitizeEmailAndPhone($data[$attribute->code]);
}
if ($attribute->type === 'image' || $attribute->type === 'file') {
$data[$attribute->code] = $data[$attribute->code] instanceof UploadedFile
? $data[$attribute->code]->store($data['entity_type'].'/'.$data['entity_id'])
: null;
}
$attributeValue = $this->findOneWhere([
'entity_type' => $data['entity_type'],
'entity_id' => $data['entity_id'],
'attribute_id' => $attribute->id,
]);
if (! $attributeValue) {
$this->create([
'entity_type' => $data['entity_type'],
'entity_id' => $data['entity_id'],
'attribute_id' => $attribute->id,
$typeColumn => $data[$attribute->code],
]);
} else {
$this->update([
$typeColumn => $data[$attribute->code],
], $attributeValue->id);
if ($attribute->type == 'image' || $attribute->type == 'file') {
if ($attributeValue->text_value) {
Storage::delete($attributeValue->text_value);
}
}
}
}
}
/**
* Is value unique.
*
* @param int $entityId
* @param string $entityType
* @param \Webkul\Attribute\Contracts\Attribute $attribute
* @param string $value
* @return bool
*/
public function isValueUnique($entityId, $entityType, $attribute, $value)
{
$query = $this->resetScope()->model
->where('attribute_id', $attribute->id)
->where('entity_type', $entityType)
->where('entity_id', '!=', $entityId);
/**
* If the attribute type is email or phone, check the JSON value.
*/
if (in_array($attribute->type, ['email', 'phone'])) {
$query->whereJsonContains($this->model::$attributeTypeFields[$attribute->type], [['value' => $value]]);
} else {
$query->where($this->model::$attributeTypeFields[$attribute->type], $value);
}
return $query->get()->count() ? false : true;
}
/**
* Removed null values from email and phone fields.
*
* @param array $data
* @return array
*/
public function sanitizeEmailAndPhone($data)
{
foreach ($data as $key => $row) {
if (is_null($row['value'])) {
unset($data[$key]);
}
}
return $data;
}
/**
* Replace placeholders with values
*/
public function getAttributeLabel(mixed $value, mixed $attribute)
{
switch ($attribute?->type) {
case 'price':
$label = core()->formatBasePrice($value);
break;
case 'boolean':
$label = $value ? 'Yes' : 'No';
break;
case 'select':
case 'radio':
case 'lookup':
if ($attribute->lookup_type) {
$option = $this->attributeRepository->getLookUpEntity($attribute->lookup_type, $value);
} else {
$option = $attribute->options->where('id', $value)->first();
}
$label = $option?->name;
break;
case 'multiselect':
case 'checkbox':
if ($attribute->lookup_type) {
$options = $this->attributeRepository->getLookUpEntity($attribute->lookup_type, explode(',', $value));
} else {
$options = $attribute->options->whereIn('id', explode(',', $value));
}
$optionsLabels = [];
foreach ($options as $key => $option) {
$optionsLabels[] = $option->name;
}
$label = implode(', ', $optionsLabels);
break;
case 'email':
case 'phone':
if (! is_array($value)) {
break;
}
$optionsLabels = [];
foreach ($value as $item) {
$optionsLabels[] = $item['value'].' ('.$item['label'].')';
}
$label = implode(', ', $optionsLabels);
break;
case 'address':
if (
! $value
|| ! count(array_filter($value))
) {
break;
}
$label = $value['address'].'<br>'
.$value['postcode'].' '.$value['city'].'<br>'
.core()->state_name($value['state']).'<br>'
.core()->country_name($value['country']).'<br>';
break;
case 'date':
if ($value) {
$label = Carbon::parse($value)->format('D M d, Y');
} else {
$label = null;
}
break;
case 'datetime':
if ($value) {
$label = Carbon::parse($value)->format('D M d, Y H:i A');
} else {
$label = null;
}
break;
default:
if ($value instanceof Carbon) {
$label = $value->format('D M d, Y H:i A');
} else {
$label = $value;
}
break;
}
return $label ?? null;
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace Webkul\Attribute\Traits;
use Webkul\Attribute\Models\AttributeValueProxy;
use Webkul\Attribute\Repositories\AttributeRepository;
trait CustomAttribute
{
/**
* @var array
*/
public static $attributeTypeFields = [
'text' => 'text_value',
'textarea' => 'text_value',
'price' => 'float_value',
'boolean' => 'boolean_value',
'select' => 'integer_value',
'multiselect' => 'text_value',
'checkbox' => 'text_value',
'email' => 'json_value',
'address' => 'json_value',
'phone' => 'json_value',
'lookup' => 'integer_value',
'datetime' => 'datetime_value',
'date' => 'date_value',
'file' => 'text_value',
'image' => 'text_value',
];
/**
* Get the attribute values that owns the entity.
*/
public function attribute_values()
{
return $this->morphMany(AttributeValueProxy::modelClass(), 'entity');
}
/**
* Get an attribute from the model.
*
* @param string $key
* @return mixed
*/
public function getAttribute($key)
{
if (! method_exists(static::class, $key) && ! isset($this->attributes[$key])) {
if (isset($this->id)) {
$this->attributes[$key] = '';
$attribute = app(AttributeRepository::class)->getAttributeByCode($key);
$this->attributes[$key] = $this->getCustomAttributeValue($attribute);
return $this->getAttributeValue($key);
}
}
return parent::getAttribute($key);
}
/**
* @return array
*/
public function attributesToArray()
{
$attributes = parent::attributesToArray();
$hiddenAttributes = $this->getHidden();
if (isset($this->id)) {
$customAttributes = $this->getCustomAttributes();
foreach ($customAttributes as $attribute) {
if (in_array($attribute->code, $hiddenAttributes) && isset($this->attributes[$attribute->code])) {
continue;
}
$attributes[$attribute->code] = $this->getCustomAttributeValue($attribute);
}
}
return $attributes;
}
/**
* Check in loaded family attributes.
*
* @return object
*/
public function getCustomAttributes()
{
static $attributes;
if ($attributes) {
return $attributes;
}
return $attributes = app(AttributeRepository::class)->where('entity_type', $this->getTable())->get();
}
/**
* Get an product attribute value.
*
* @return mixed
*/
public function getCustomAttributeValue($attribute)
{
if (! $attribute) {
return;
}
$attributeValue = $this->attribute_values->where('attribute_id', $attribute->id)->first();
return $attributeValue[self::$attributeTypeFields[$attribute->type]] ?? null;
}
/**
* Create a new instance of the given model.
*
* @param array $attributes
* @return Collection
*/
public function getLookUpAttributes($attributes)
{
$attributes = app(AttributeRepository::class)->scopeQuery(function ($query) use ($attributes) {
return $query->distinct()
->where('type', 'lookup')
->where('entity_type', request('entity_type'))
->whereIn('code', array_keys($attributes, '', false));
})->get();
return $attributes;
}
/**
* Create a new instance of the given model.
*
* @param array $attributes
* @param bool $exists
* @return static
*/
public function newInstance($attributes = [], $exists = false)
{
// $attributes = $this->getLookUpAttributes($attributes);
// Play with data here
return parent::newInstance($attributes, $exists);
}
/**
* Fill the model with an array of attributes.
*
* @return $this
*
* @throws \Illuminate\Database\Eloquent\MassAssignmentException
*/
public function fill(array $attributes)
{
// Play with data here
return parent::fill($attributes);
}
// Delete model's attribute values
public static function boot()
{
parent::boot();
static::deleting(function ($entity) {
$entity->attribute_values()->delete();
});
}
}