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,5 @@
<?php
namespace Webkul\Contact\Contracts;
interface Organization {}

View File

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

View File

@@ -0,0 +1,30 @@
<?php
namespace Webkul\Contact\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Webkul\Contact\Models\Person;
class PersonFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Person::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'name' => $this->faker->name(),
'emails' => [$this->faker->unique()->safeEmail()],
'contact_numbers' => [$this->faker->randomNumber(9)],
];
}
}

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('organizations', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->json('address')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('organizations');
}
};

View File

@@ -0,0 +1,37 @@
<?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('persons', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->json('emails');
$table->json('contact_numbers')->nullable();
$table->integer('organization_id')->unsigned()->nullable();
$table->foreign('organization_id')->references('id')->on('organizations')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('persons');
}
};

View File

@@ -0,0 +1,32 @@
<?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::table('organizations', function (Blueprint $table) {
$table->unique('name');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('organizations', function (Blueprint $table) {
$table->dropUnique('organizations_name_unique');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('persons', function (Blueprint $table) {
$table->string('job_title')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('persons', function (Blueprint $table) {
$table->dropColumn('job_title');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('person_tags', function (Blueprint $table) {
$table->integer('tag_id')->unsigned();
$table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
$table->integer('person_id')->unsigned();
$table->foreign('person_id')->references('id')->on('persons')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('person_tags');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('person_activities', function (Blueprint $table) {
$table->integer('activity_id')->unsigned();
$table->foreign('activity_id')->references('id')->on('activities')->onDelete('cascade');
$table->integer('person_id')->unsigned();
$table->foreign('person_id')->references('id')->on('persons')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('lead_activities');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('persons', function (Blueprint $table) {
$table->integer('user_id')->unsigned()->nullable();
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('persons', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->dropColumn('user_id');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organizations', function (Blueprint $table) {
$table->integer('user_id')->unsigned()->nullable();
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->dropColumn('user_id');
});
}
};

View File

@@ -0,0 +1,41 @@
<?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('persons', function (Blueprint $table) {
$table->string('unique_id')->nullable()->unique();
});
$tableName = DB::getTablePrefix().'persons';
DB::statement("
UPDATE {$tableName}
SET unique_id = CONCAT(
user_id, '|',
organization_id, '|',
JSON_UNQUOTE(JSON_EXTRACT(emails, '$[0].value')), '|',
JSON_UNQUOTE(JSON_EXTRACT(contact_numbers, '$[0].value'))
)
");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('persons', function (Blueprint $table) {
$table->dropColumn('unique_id');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('persons', function (Blueprint $table) {
$table->dropForeign(['organization_id']);
$table->foreign('organization_id')->references('id')->on('organizations')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('persons', function (Blueprint $table) {
$table->dropForeign(['organization_id']);
$table->foreign('organization_id')->references('id')->on('organizations')->onDelete('cascade');
});
}
};

View File

@@ -0,0 +1,46 @@
<?php
namespace Webkul\Contact\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Attribute\Traits\CustomAttribute;
use Webkul\Contact\Contracts\Organization as OrganizationContract;
use Webkul\User\Models\UserProxy;
class Organization extends Model implements OrganizationContract
{
use CustomAttribute;
protected $casts = [
'address' => 'array',
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'address',
'user_id',
];
/**
* Get persons.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function persons()
{
return $this->hasMany(PersonProxy::modelClass());
}
/**
* Get the user that owns the lead.
*/
public function user()
{
return $this->belongsTo(UserProxy::modelClass());
}
}

View File

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

View File

@@ -0,0 +1,109 @@
<?php
namespace Webkul\Contact\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Webkul\Activity\Models\ActivityProxy;
use Webkul\Activity\Traits\LogsActivity;
use Webkul\Attribute\Traits\CustomAttribute;
use Webkul\Contact\Contracts\Person as PersonContract;
use Webkul\Contact\Database\Factories\PersonFactory;
use Webkul\Lead\Models\LeadProxy;
use Webkul\Tag\Models\TagProxy;
use Webkul\User\Models\UserProxy;
class Person extends Model implements PersonContract
{
use CustomAttribute, HasFactory, LogsActivity;
/**
* Table name.
*
* @var string
*/
protected $table = 'persons';
/**
* Eager loading.
*
* @var string
*/
protected $with = 'organization';
/**
* The attributes that are castable.
*
* @var array
*/
protected $casts = [
'emails' => 'array',
'contact_numbers' => 'array',
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'emails',
'contact_numbers',
'job_title',
'user_id',
'organization_id',
'unique_id',
];
/**
* Get the user that owns the lead.
*/
public function user(): BelongsTo
{
return $this->belongsTo(UserProxy::modelClass());
}
/**
* Get the organization that owns the person.
*/
public function organization(): BelongsTo
{
return $this->belongsTo(OrganizationProxy::modelClass());
}
/**
* Get the activities.
*/
public function activities(): BelongsToMany
{
return $this->belongsToMany(ActivityProxy::modelClass(), 'person_activities');
}
/**
* The tags that belong to the person.
*/
public function tags(): BelongsToMany
{
return $this->belongsToMany(TagProxy::modelClass(), 'person_tags');
}
/**
* Get the leads for the person.
*/
public function leads(): HasMany
{
return $this->hasMany(LeadProxy::modelClass(), 'person_id');
}
/**
* Create a new factory instance for the model.
*/
protected static function newFactory(): PersonFactory
{
return PersonFactory::new();
}
}

View File

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

View File

@@ -0,0 +1,26 @@
<?php
namespace Webkul\Contact\Providers;
use Illuminate\Routing\Router;
use Illuminate\Support\ServiceProvider;
class ContactServiceProvider extends ServiceProvider
{
/**
* Bootstrap services.
*
* @return void
*/
public function boot(Router $router)
{
$this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
}
/**
* Register services.
*
* @return void
*/
public function register() {}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Webkul\Contact\Providers;
use Webkul\Core\Providers\BaseModuleServiceProvider;
class ModuleServiceProvider extends BaseModuleServiceProvider
{
protected $models = [
\Webkul\Contact\Models\Person::class,
\Webkul\Contact\Models\Organization::class,
];
}

View File

@@ -0,0 +1,119 @@
<?php
namespace Webkul\Contact\Repositories;
use Illuminate\Container\Container;
use Illuminate\Support\Facades\DB;
use Webkul\Attribute\Repositories\AttributeRepository;
use Webkul\Attribute\Repositories\AttributeValueRepository;
use Webkul\Contact\Contracts\Organization;
use Webkul\Core\Eloquent\Repository;
class OrganizationRepository extends Repository
{
/**
* Create a new repository instance.
*
* @return void
*/
public function __construct(
protected AttributeRepository $attributeRepository,
protected AttributeValueRepository $attributeValueRepository,
Container $container
) {
parent::__construct($container);
}
/**
* Specify model class name.
*
* @return mixed
*/
public function model()
{
return Organization::class;
}
/**
* Create.
*
* @return \Webkul\Contact\Contracts\Organization
*/
public function create(array $data)
{
if (isset($data['user_id'])) {
$data['user_id'] = $data['user_id'] ?: null;
}
$organization = parent::create($data);
$this->attributeValueRepository->save(array_merge($data, [
'entity_id' => $organization->id,
]));
return $organization;
}
/**
* Update.
*
* @param int $id
* @param array $attribute
* @return \Webkul\Contact\Contracts\Organization
*/
public function update(array $data, $id, $attributes = [])
{
if (isset($data['user_id'])) {
$data['user_id'] = $data['user_id'] ?: null;
}
$organization = parent::update($data, $id);
/**
* If attributes are provided then only save the provided attributes and return.
*/
if (! empty($attributes)) {
$conditions = ['entity_type' => $data['entity_type']];
if (isset($data['quick_add'])) {
$conditions['quick_add'] = 1;
}
$attributes = $this->attributeRepository->where($conditions)
->whereIn('code', $attributes)
->get();
$this->attributeValueRepository->save(array_merge($data, [
'entity_id' => $organization->id,
]), $attributes);
return $organization;
}
$this->attributeValueRepository->save(array_merge($data, [
'entity_id' => $organization->id,
]));
return $organization;
}
/**
* Delete organization and it's persons.
*
* @param int $id
* @return @void
*/
public function delete($id)
{
$organization = $this->findOrFail($id);
DB::transaction(function () use ($organization, $id) {
$this->attributeValueRepository->deleteWhere([
'entity_id' => $id,
'entity_type' => 'organizations',
]);
$organization->delete();
});
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace Webkul\Contact\Repositories;
use Illuminate\Container\Container;
use Webkul\Attribute\Repositories\AttributeRepository;
use Webkul\Attribute\Repositories\AttributeValueRepository;
use Webkul\Contact\Contracts\Person;
use Webkul\Core\Eloquent\Repository;
class PersonRepository extends Repository
{
/**
* Searchable fields.
*/
protected $fieldSearchable = [
'name',
'emails',
'contact_numbers',
'organization_id',
'job_title',
'organization.name',
'user_id',
'user.name',
];
/**
* Create a new repository instance.
*
* @return void
*/
public function __construct(
protected AttributeRepository $attributeRepository,
protected AttributeValueRepository $attributeValueRepository,
protected OrganizationRepository $organizationRepository,
Container $container
) {
parent::__construct($container);
}
/**
* Specify model class name.
*
* @return mixed
*/
public function model()
{
return Person::class;
}
/**
* Create.
*
* @return \Webkul\Contact\Contracts\Person
*/
public function create(array $data)
{
$data = $this->sanitizeRequestedPersonData($data);
if (! empty($data['organization_name'])) {
$organization = $this->fetchOrCreateOrganizationByName($data['organization_name']);
$data['organization_id'] = $organization->id;
}
if (isset($data['user_id'])) {
$data['user_id'] = $data['user_id'] ?: null;
}
$person = parent::create($data);
$this->attributeValueRepository->save(array_merge($data, [
'entity_id' => $person->id,
]));
return $person;
}
/**
* Update.
*
* @return \Webkul\Contact\Contracts\Person
*/
public function update(array $data, $id, $attributes = [])
{
$data = $this->sanitizeRequestedPersonData($data);
$data['user_id'] = empty($data['user_id']) ? null : $data['user_id'];
if (! empty($data['organization_name'])) {
$organization = $this->fetchOrCreateOrganizationByName($data['organization_name']);
$data['organization_id'] = $organization->id;
unset($data['organization_name']);
}
$person = parent::update($data, $id);
/**
* If attributes are provided then only save the provided attributes and return.
*/
if (! empty($attributes)) {
$conditions = ['entity_type' => $data['entity_type']];
if (isset($data['quick_add'])) {
$conditions['quick_add'] = 1;
}
$attributes = $this->attributeRepository->where($conditions)
->whereIn('code', $attributes)
->get();
$this->attributeValueRepository->save(array_merge($data, [
'entity_id' => $person->id,
]), $attributes);
return $person;
}
$this->attributeValueRepository->save(array_merge($data, [
'entity_id' => $person->id,
]));
return $person;
}
/**
* Retrieves customers count based on date.
*
* @return int
*/
public function getCustomerCount($startDate, $endDate)
{
return $this
->whereBetween('created_at', [$startDate, $endDate])
->get()
->count();
}
/**
* Fetch or create an organization.
*/
public function fetchOrCreateOrganizationByName(string $organizationName)
{
$organization = $this->organizationRepository->findOneWhere([
'name' => $organizationName,
]);
return $organization ?: $this->organizationRepository->create([
'entity_type' => 'organizations',
'name' => $organizationName,
]);
}
/**
* Sanitize requested person data and return the clean array.
*/
private function sanitizeRequestedPersonData(array $data): array
{
if (
array_key_exists('organization_id', $data)
&& empty($data['organization_id'])
) {
$data['organization_id'] = null;
}
$uniqueIdParts = array_filter([
$data['user_id'] ?? null,
$data['organization_id'] ?? null,
$data['emails'][0]['value'] ?? null,
]);
$data['unique_id'] = implode('|', $uniqueIdParts);
if (isset($data['contact_numbers'])) {
$data['contact_numbers'] = collect($data['contact_numbers'])->filter(fn ($number) => ! is_null($number['value']))->toArray();
$data['unique_id'] .= '|'.$data['contact_numbers'][0]['value'];
}
return $data;
}
}