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,28 @@
{
"name": "krayin/laravel-activity",
"license": "MIT",
"authors": [
{
"name": "Jitendra Singh",
"email": "jitendra@webkul.com"
}
],
"require": {
"krayin/laravel-core": "^1.0",
"krayin/laravel-user": "^1.0"
},
"autoload": {
"psr-4": {
"Webkul\\Activity\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Webkul\\Activity\\Providers\\ActivityServiceProvider"
],
"aliases": {}
}
},
"minimum-stability": "dev"
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
<?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('activities', function (Blueprint $table) {
$table->increments('id');
$table->string('title')->nullable();
$table->string('type');
$table->text('comment')->nullable();
$table->json('additional')->nullable();
$table->datetime('schedule_from')->nullable();
$table->datetime('schedule_to')->nullable();
$table->boolean('is_done')->default(0);
$table->integer('user_id')->unsigned();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('activities');
}
};

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('activity_files', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('path');
$table->integer('activity_id')->unsigned();
$table->foreign('activity_id')->references('id')->on('activities')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('activity_files');
}
};

View File

@@ -0,0 +1,39 @@
<?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('activity_participants', function (Blueprint $table) {
$table->increments('id');
$table->integer('activity_id')->unsigned();
$table->foreign('activity_id')->references('id')->on('activities')->onDelete('cascade');
$table->integer('user_id')->nullable()->unsigned();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
$table->integer('person_id')->nullable()->unsigned();
$table->foreign('person_id')->references('id')->on('persons')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('activity_participants');
}
};

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('activities', function (Blueprint $table) {
$table->string('location')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('activities', function (Blueprint $table) {
$table->dropColumn('location');
});
}
};

View File

@@ -0,0 +1,52 @@
<?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()
{
Schema::table('activities', function (Blueprint $table) {
$table->dropForeign(['user_id']);
$table->unsignedInteger('user_id')->nullable()->change();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('activities', function (Blueprint $table) {
$tablePrefix = DB::getTablePrefix();
// Disable foreign key checks temporarily.
DB::statement('SET FOREIGN_KEY_CHECKS=0');
// Drop the foreign key constraint using raw SQL.
DB::statement('ALTER TABLE '.$tablePrefix.'activities DROP FOREIGN KEY activities_user_id_foreign');
// Drop the index.
DB::statement('ALTER TABLE '.$tablePrefix.'activities DROP INDEX activities_user_id_foreign');
// Change the column to be non-nullable.
$table->unsignedInteger('user_id')->nullable(false)->change();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
// Re-enable foreign key checks.
DB::statement('SET FOREIGN_KEY_CHECKS=1');
});
}
};

View File

@@ -0,0 +1,111 @@
<?php
namespace Webkul\Activity\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Activity\Contracts\Activity as ActivityContract;
use Webkul\Contact\Models\PersonProxy;
use Webkul\Lead\Models\LeadProxy;
use Webkul\Product\Models\ProductProxy;
use Webkul\User\Models\UserProxy;
use Webkul\Warehouse\Models\WarehouseProxy;
class Activity extends Model implements ActivityContract
{
/**
* Define table name of property
*
* @var string
*/
protected $table = 'activities';
/**
* Define relationships that should be touched on save
*
* @var array
*/
protected $with = ['user'];
/**
* Cast attributes to date time
*
* @var array
*/
protected $casts = [
'schedule_from' => 'datetime',
'schedule_to' => 'datetime',
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'title',
'type',
'location',
'comment',
'additional',
'schedule_from',
'schedule_to',
'is_done',
'user_id',
];
/**
* Get the user that owns the activity.
*/
public function user()
{
return $this->belongsTo(UserProxy::modelClass());
}
/**
* The participants that belong to the activity.
*/
public function participants()
{
return $this->hasMany(ParticipantProxy::modelClass());
}
/**
* Get the file associated with the activity.
*/
public function files()
{
return $this->hasMany(FileProxy::modelClass(), 'activity_id');
}
/**
* The leads that belong to the activity.
*/
public function leads()
{
return $this->belongsToMany(LeadProxy::modelClass(), 'lead_activities');
}
/**
* The Person that belong to the activity.
*/
public function persons()
{
return $this->belongsToMany(PersonProxy::modelClass(), 'person_activities');
}
/**
* The leads that belong to the activity.
*/
public function products()
{
return $this->belongsToMany(ProductProxy::modelClass(), 'product_activities');
}
/**
* The Warehouse that belong to the activity.
*/
public function warehouses()
{
return $this->belongsToMany(WarehouseProxy::modelClass(), 'warehouse_activities');
}
}

View File

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

View File

@@ -0,0 +1,71 @@
<?php
namespace Webkul\Activity\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Webkul\Activity\Contracts\File as FileContract;
class File extends Model implements FileContract
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'activity_files';
/**
* The attributes that should be appended to the model.
*
* @var array
*/
protected $appends = ['url'];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'path',
'activity_id',
];
/**
* Get image url for the product image.
*/
public function url()
{
return Storage::url($this->path);
}
/**
* Get image url for the product image.
*/
public function getUrlAttribute()
{
return $this->url();
}
/**
* Get the activity that owns the file.
*/
public function activity()
{
return $this->belongsTo(ActivityProxy::modelClass());
}
/**
* @return array
*/
public function toArray()
{
$array = parent::toArray();
$array['url'] = $this->url;
return $array;
}
}

View File

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

View File

@@ -0,0 +1,52 @@
<?php
namespace Webkul\Activity\Models;
use Illuminate\Database\Eloquent\Model;
use Webkul\Activity\Contracts\Participant as ParticipantContract;
use Webkul\Contact\Models\PersonProxy;
use Webkul\User\Models\UserProxy;
class Participant extends Model implements ParticipantContract
{
public $timestamps = false;
protected $table = 'activity_participants';
protected $with = ['user', 'person'];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'activity_id',
'user_id',
'person_id',
];
/**
* Get the activity that owns the participant.
*/
public function activity()
{
return $this->belongsTo(ActivityProxy::modelClass());
}
/**
* Get the user that owns the participant.
*/
public function user()
{
return $this->belongsTo(UserProxy::modelClass());
}
/**
* Get the person that owns the participant.
*/
public function person()
{
return $this->belongsTo(PersonProxy::modelClass());
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,187 @@
<?php
namespace Webkul\Activity\Repositories;
use Illuminate\Container\Container;
use Webkul\Core\Eloquent\Repository;
class ActivityRepository extends Repository
{
/**
* Create a new repository instance.
*
* @return void
*/
public function __construct(
protected FileRepository $fileRepository,
Container $container
) {
parent::__construct($container);
}
/**
* Specify Model class name
*
* @return mixed
*/
public function model()
{
return 'Webkul\Activity\Contracts\Activity';
}
/**
* Create pipeline.
*
* @return \Webkul\Activity\Contracts\Activity
*/
public function create(array $data)
{
$activity = parent::create($data);
if (isset($data['file'])) {
$this->fileRepository->create([
'name' => $data['name'] ?? $data['file']->getClientOriginalName(),
'path' => $data['file']->store('activities/'.$activity->id),
'activity_id' => $activity->id,
]);
}
if (! isset($data['participants'])) {
return $activity;
}
foreach ($data['participants']['users'] ?? [] as $userId) {
$activity->participants()->create([
'user_id' => $userId,
]);
}
foreach ($data['participants']['persons'] ?? [] as $personId) {
$activity->participants()->create([
'person_id' => $personId,
]);
}
return $activity;
}
/**
* Update pipeline.
*
* @param int $id
* @param string $attribute
* @return \Webkul\Activity\Contracts\Activity
*/
public function update(array $data, $id, $attribute = 'id')
{
$activity = parent::update($data, $id);
if (isset($data['participants'])) {
$activity->participants()->delete();
foreach ($data['participants']['users'] ?? [] as $userId) {
/**
* In some cases, the component exists in an HTML form, and traditional HTML does not send an empty array.
* Therefore, we need to check for an empty string.
* This scenario occurs only when all participants are removed.
*/
if (empty($userId)) {
break;
}
$activity->participants()->create([
'user_id' => $userId,
]);
}
foreach ($data['participants']['persons'] ?? [] as $personId) {
/**
* In some cases, the component exists in an HTML form, and traditional HTML does not send an empty array.
* Therefore, we need to check for an empty string.
* This scenario occurs only when all participants are removed.
*/
if (empty($personId)) {
break;
}
$activity->participants()->create([
'person_id' => $personId,
]);
}
}
return $activity;
}
/**
* @param string $dateRange
* @return mixed
*/
public function getActivities($dateRange)
{
$tablePrefix = \DB::getTablePrefix();
return $this->select(
'activities.id',
'activities.created_at',
'activities.title',
'activities.schedule_from as start',
'activities.schedule_to as end',
'users.name as user_name',
)
->addSelect(\DB::raw('IF('.$tablePrefix.'activities.is_done, "done", "") as class'))
->leftJoin('activity_participants', 'activities.id', '=', 'activity_participants.activity_id')
->leftJoin('users', 'activities.user_id', '=', 'users.id')
->whereIn('type', ['call', 'meeting', 'lunch'])
->whereBetween('activities.schedule_from', $dateRange)
->where(function ($query) {
if ($userIds = bouncer()->getAuthorizedUserIds()) {
$query->whereIn('activities.user_id', $userIds)
->orWhereIn('activity_participants.user_id', $userIds);
}
})
->distinct()
->get();
}
/**
* @param string $startFrom
* @param string $endFrom
* @param array $participants
* @param int $id
* @return bool
*/
public function isDurationOverlapping($startFrom, $endFrom, $participants, $id)
{
$queryBuilder = $this->leftJoin('activity_participants', 'activities.id', '=', 'activity_participants.activity_id')
->where(function ($query) use ($startFrom, $endFrom) {
$query->where([
['activities.schedule_from', '<=', $startFrom],
['activities.schedule_to', '>=', $startFrom],
])->orWhere([
['activities.schedule_from', '>=', $startFrom],
['activities.schedule_from', '<=', $endFrom],
]);
})
->where(function ($query) use ($participants) {
if (is_null($participants)) {
return;
}
if (isset($participants['users'])) {
$query->orWhereIn('activity_participants.user_id', $participants['users']);
}
if (isset($participants['persons'])) {
$query->orWhereIn('activity_participants.person_id', $participants['persons']);
}
})
->groupBy('activities.id');
if (! is_null($id)) {
$queryBuilder->where('activities.id', '!=', $id);
}
return $queryBuilder->count() ? true : false;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Webkul\Activity\Repositories;
use Webkul\Core\Eloquent\Repository;
class FileRepository extends Repository
{
/**
* Specify model class name.
*
* @return mixed
*/
public function model()
{
return \Webkul\Activity\Contracts\File::class;
}
}

View File

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

View File

@@ -0,0 +1,183 @@
<?php
namespace Webkul\Activity\Traits;
use Webkul\Activity\Repositories\ActivityRepository;
use Webkul\Attribute\Contracts\AttributeValue;
use Webkul\Attribute\Repositories\AttributeValueRepository;
trait LogsActivity
{
/**
* The "booted" method of the model.
*/
protected static function booted(): void
{
static::created(function ($model) {
if (! method_exists($model->entity ?? $model, 'activities')) {
return;
}
if (! $model instanceof AttributeValue) {
$activity = app(ActivityRepository::class)->create([
'type' => 'system',
'title' => trans('admin::app.activities.created'),
'is_done' => 1,
'user_id' => auth()->check()
? auth()->id()
: null,
]);
$model->activities()->attach($activity->id);
return;
}
static::logActivity($model);
});
static::updated(function ($model) {
if (! method_exists($model->entity ?? $model, 'activities')) {
return;
}
static::logActivity($model);
});
static::deleting(function ($model) {
if (! method_exists($model->entity ?? $model, 'activities')) {
return;
}
$model->activities()->delete();
});
}
/**
* Create activity.
*/
protected static function logActivity($model)
{
$customAttributes = [];
if (method_exists($model, 'getCustomAttributes')) {
$customAttributes = $model->getCustomAttributes()->pluck('code')->toArray();
}
$updatedAttributes = static::getUpdatedAttributes($model);
foreach ($updatedAttributes as $attributeCode => $attributeData) {
if (in_array($attributeCode, $customAttributes)) {
continue;
}
$attributeCode = $model->attribute?->name ?: $attributeCode;
$activity = app(ActivityRepository::class)->create([
'type' => 'system',
'title' => trans('admin::app.activities.updated', ['attribute' => $attributeCode]),
'is_done' => 1,
'additional' => json_encode([
'attribute' => $attributeCode,
'new' => [
'value' => $attributeData['new'],
'label' => static::getAttributeLabel($attributeData['new'], $model->attribute),
],
'old' => [
'value' => $attributeData['old'],
'label' => static::getAttributeLabel($attributeData['old'], $model->attribute),
],
]),
'user_id' => auth()->id(),
]);
if ($model instanceof AttributeValue) {
$model->entity->activities()->attach($activity->id);
} else {
$model->activities()->attach($activity->id);
}
}
}
/**
* Get attribute label.
*/
protected static function getAttributeLabel($value, $attribute)
{
return app(AttributeValueRepository::class)->getAttributeLabel($value, $attribute);
}
/**
* Create activity.
*/
protected static function getUpdatedAttributes($model)
{
$updatedAttributes = [];
foreach ($model->getDirty() as $key => $value) {
if (in_array($key, [
'id',
'attribute_id',
'entity_id',
'entity_type',
'updated_at',
])) {
continue;
}
$newValue = static::decodeValueIfJson($value);
$oldValue = static::decodeValueIfJson($model->getOriginal($key));
if ($newValue != $oldValue) {
$updatedAttributes[$key] = [
'new' => $newValue,
'old' => $oldValue,
];
}
}
return $updatedAttributes;
}
/**
* Convert value if json.
*/
protected static function decodeValueIfJson($value)
{
if (
! is_array($value)
&& json_decode($value, true)
) {
$value = json_decode($value, true);
}
if (! is_array($value)) {
return $value;
}
static::ksortRecursive($value);
return $value;
}
/**
* Sort array recursively.
*/
protected static function ksortRecursive(&$array)
{
if (! is_array($array)) {
return;
}
ksort($array);
foreach ($array as &$value) {
if (! is_array($value)) {
continue;
}
static::ksortRecursive($value);
}
}
}