first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

118
common/Comments/Comment.php Executable file
View File

@@ -0,0 +1,118 @@
<?php
namespace Common\Comments;
use App\Models\User;
use Common\Core\BaseModel;
use Common\Files\Traits\HandlesEntryPaths;
use Common\Votes\OrdersByWeightedScore;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Comment extends BaseModel
{
use HandlesEntryPaths, HasFactory, OrdersByWeightedScore;
const MODEL_TYPE = 'comment';
protected $guarded = ['id'];
protected $hidden = ['commentable_type', 'commentable_id', 'path'];
protected $casts = [
'id' => 'integer',
'user_id' => 'integer',
'deleted' => 'boolean',
];
protected $appends = ['depth', 'model_type'];
public function votes(): HasMany
{
return $this->hasMany(CommentVote::class);
}
public function reports(): HasMany
{
return $this->hasMany(CommentReport::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function commentable(): MorphTo
{
return $this->morphTo();
}
public function scopeRootOnly(Builder $builder): Builder
{
return $builder->whereNull('parent_id');
}
public function scopeChildrenOnly(Builder $builder): Builder
{
return $builder->whereNotNull('parent_id');
}
public function getDepthAttribute(): int
{
if (!$this->path || !$this->parent_id) {
return 0;
}
return count(explode('/', $this->getRawOriginal('path')));
}
public function toNormalizedArray(): array
{
return [
'id' => $this->id,
'name' => $this->content,
'model_type' => self::MODEL_TYPE,
];
}
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'content' => $this->content,
'parent_id' => $this->parent_id,
'user_id' => $this->user_id,
'deleted' => $this->deleted,
'commentable_id' => $this->commentable_id,
'commentable_type' => $this->commentable_type,
'created_at' => $this->created_at->timestamp ?? '_null',
'updated_at' => $this->updated_at->timestamp ?? '_null',
];
}
public static function filterableFields(): array
{
return [
'id',
'parent_id',
'user_id',
'deleted',
'commentable_id',
'commentable_type',
'created_at',
'updated_at',
];
}
protected static function newFactory()
{
return CommentFactory::new();
}
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Common\Comments;
use App\Models\User;
use Arr;
use Common\Auth\Roles\Role;
use Common\Billing\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;
class CommentFactory extends Factory
{
protected $model = Comment::class;
public function definition(): array
{
return [
'content' => $this->faker->realText(),
'commentable_type' => Arr::random([
User::MODEL_TYPE,
Product::MODEL_TYPE,
Role::MODEL_TYPE,
]),
'commentable_id' => Arr::random([1, 2, 3, 4, 5]),
'user_id' => Arr::random([1, 2, 3, 4, 5]),
'parent_id' => Arr::random([1, null]),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Common\Comments;
use App\Models\User;
use Common\Auth\BaseUser;
use Common\Core\Policies\BasePolicy;
class CommentPolicy extends BasePolicy
{
public function vote(User $user)
{
return $user->hasPermission('comments.create');
}
public function index(?BaseUser $user, $userId = null)
{
return $user->hasPermission('comments.view') ||
$user->id === (int) $userId;
}
public function show(?BaseUser $user, Comment $comment)
{
return $user->hasPermission('comments.view') ||
$comment->user_id === $user->id;
}
public function store(BaseUser $user)
{
return $user->id && $user->hasPermission('comments.create');
}
public function update(BaseUser $user, ?Comment $comment = null)
{
return $user->hasPermission('comments.update') ||
($comment && $comment->user_id === $user->id);
}
public function destroy(BaseUser $user, $commentIds)
{
if ($user->hasPermission('comments.delete')) {
return true;
} else {
$dbCount = app(Comment::class)
->whereIn('id', $commentIds)
->where('user_id', $user->id)
->count();
return $dbCount === count($commentIds);
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Common\Comments;
use Illuminate\Database\Eloquent\Model;
class CommentReport extends Model
{
protected $guarded = ['id'];
protected $casts = [
'id' => 'integer',
'user_id' => 'integer',
'comment_id' => 'integer',
];
const MODEL_TYPE = 'comment_report';
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
}

15
common/Comments/CommentVote.php Executable file
View File

@@ -0,0 +1,15 @@
<?php
namespace Common\Comments;
use Common\Votes\Vote;
class CommentVote extends Vote
{
const MODEL_TYPE = 'comment_vote';
public static function getModelTypeAttribute(): string
{
return self::MODEL_TYPE;
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace Common\Comments\Controllers;
use Common\Comments\Comment;
use Common\Comments\CrupdateComment;
use Common\Comments\CrupdateCommentRequest;
use Common\Core\BaseController;
use Common\Database\Datasource\Datasource;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Symfony\Component\HttpFoundation\Response;
class CommentController extends BaseController
{
public function __construct(
protected Comment $comment,
protected Request $request,
) {
}
public function index(): Response
{
$userId = $this->request->get('userId');
$this->authorize('index', [Comment::class, $userId]);
$builder = $this->comment->with(['user']);
// will need to specify this outside of filters on edit title comments page
if (request('commentable_id') && request('commentable_type')) {
$builder->where([
'commentable_id' => request('commentable_id'),
'commentable_type' => request('commentable_type'),
]);
}
$dataSource = new Datasource(
$builder,
$this->request->all(),
);
$pagination = $dataSource->paginate();
$pagination->transform(function (Comment $comment) {
if ($comment->relationLoaded('commentable') && $comment->commentable) {
$normalized = $comment->commentable->toNormalizedArray();
$comment->unsetRelation('commentable');
$comment->setAttribute('commentable', $normalized);
}
return $comment;
});
return $this->success(['pagination' => $pagination]);
}
public function show(Comment $comment): Response
{
$this->authorize('show', $comment);
return $this->success(['comment' => $comment]);
}
public function store(CrupdateCommentRequest $request): Response
{
$this->authorize('store', Comment::class);
$comment = app(CrupdateComment::class)->execute($request->all());
return $this->success(['comment' => $comment]);
}
public function update(
Comment $comment,
CrupdateCommentRequest $request,
): Response {
$this->authorize('store', $comment);
$comment = app(CrupdateComment::class)->execute(
$request->all(),
$comment,
);
return $this->success(['comment' => $comment]);
}
public function destroy(string $ids): Response
{
$commentIds = explode(',', $ids);
$this->authorize('destroy', [Comment::class, $commentIds]);
$allDeleted = [];
$allMarkedAsDeleted = [];
$this->comment
->whereIn('id', $commentIds)
->chunkById(100, function (Collection $comments) use (
&$allDeleted,
&$allMarkedAsDeleted,
) {
$toMarkAsDeleted = [];
$toDelete = [];
foreach ($comments as $comment) {
if ($comment->allChildren()->count() > 1) {
$toMarkAsDeleted[] = $comment->id;
} else {
$toDelete[] = $comment->id;
}
}
if (!empty($toMarkAsDeleted)) {
$this->comment
->whereIn('id', $toMarkAsDeleted)
->update(['deleted' => true]);
}
if (!empty($toDelete)) {
$this->comment->whereIn('id', $toDelete)->delete();
}
$allDeleted = array_merge($allDeleted, $toDelete);
$allMarkedAsDeleted = array_merge(
$allMarkedAsDeleted,
$toMarkAsDeleted,
);
});
return $this->success([
'allDeleted' => $allDeleted,
'allMarkedAsDeleted' => $allMarkedAsDeleted,
]);
}
public function restore()
{
$this->authorize('update', Comment::class);
$commentIds = $this->request->get('commentIds');
$this->comment
->whereIn('id', $commentIds)
->update(['deleted' => false]);
return $this->success();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Common\Comments\Controllers;
use Common\Comments\PaginateModelComments;
use Common\Core\BaseController;
class CommentableController extends BaseController
{
public function index()
{
$modelType = request('commentable_type');
$modelId = request('commentable_id');
if (!$modelType || !$modelId) {
abort(404);
}
$commentable = app(modelTypeToNamespace($modelType))->findOrFail(
$modelId,
);
$pagination = app(PaginateModelComments::class)->execute($commentable);
return $this->success([
'pagination' => $pagination,
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Common\Comments;
use App\Models\User;
use Auth;
use Common\Comments\Notifications\CommentReceivedReply;
use Illuminate\Support\Arr;
class CrupdateComment
{
public function execute(
array $data,
Comment $initialComment = null,
): Comment {
if (!$initialComment) {
$comment = new Comment([
'user_id' => Auth::id(),
]);
} else {
$comment = $initialComment;
}
$inReplyTo = Arr::get($data, 'inReplyTo');
// specific app might need to store
// some extra data along with comment
$attributes = Arr::except($data, 'inReplyTo');
if ($inReplyTo) {
$attributes['parent_id'] = $inReplyTo['id'];
}
if (isset($attributes['commentable_type'])) {
// track => App\Track
$attributes['commentable_type'] = $data['commentable_type'];
}
$comment->fill($attributes)->save();
$comment->generatePath();
if (
!$initialComment &&
$inReplyTo &&
$inReplyTo['user']['id'] !== Auth::id()
) {
app(User::class)
->find($inReplyTo['user']['id'])
->notify(new CommentReceivedReply($comment, $inReplyTo));
}
return $comment;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Common\Comments;
use Auth;
use Common\Core\BaseFormRequest;
class CrupdateCommentRequest extends BaseFormRequest
{
public function rules(): array
{
$required = $this->getMethod() === 'POST' ? 'required' : '';
$ignore =
$this->getMethod() === 'PUT' ? $this->route('comment')->id : '';
$userId = $this->route('comment')
? $this->route('comment')->user_id
: Auth::id();
return [
'content' => 'required|string|max:1000|min:3',
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Common\Comments;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class LoadChildComments
{
public function execute(
Model $commentable,
Collection $rootComments,
): Collection {
$paths = $rootComments
->map(function (Comment $comment) {
$path = $comment->getRawOriginal('path');
return "LIKE '$path%'";
})
->implode(' OR path ');
$childComments = app(Comment::class)
->with(['user' => fn($builder) => $builder->compact()])
->where('commentable_id', $commentable->id)
->where('commentable_type', $commentable->getMorphClass())
->childrenOnly()
->where(fn(Builder $builder) => $builder->whereRaw("path $paths"))
->orderBy('path', 'asc')
->orderBy('created_at', 'desc')
->orderByWeightedScore()
->limit(100)
->get();
$childComments->each(function ($child) use ($rootComments) {
$index = $rootComments->search(
fn($parent) => $parent['id'] === $child['parent_id'],
);
$rootComments->splice($index + 1, 0, [$child]);
});
return $rootComments;
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Common\Comments\Notifications;
use App\Models\User;
use App\Services\UrlGenerator;
use Common\Comments\Comment;
use Illuminate\Bus\Queueable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
class CommentReceivedReply extends Notification
{
use Queueable;
public Model $commentable;
public function __construct(
public Comment $newComment,
public array $originalComment,
) {
$this->newComment = $newComment;
$this->originalComment = $originalComment;
$this->commentable = app(
modelTypeToNamespace($newComment['commentable_type']),
)->find($newComment['commentable_id']);
}
public function via(User $notifiable): array
{
return ['database'];
}
public function toArray(User $notifiable): array
{
$username = $this->newComment['user']['display_name'];
$commentable = $this->commentable->toNormalizedArray();
return [
'image' => $this->originalComment['user']['avatar'],
'mainAction' => [
'action' => app(UrlGenerator::class)->generate(
$this->commentable,
),
],
'lines' => [
[
'content' => __(':username replied to your comment:', [
'username' => $username,
]),
'action' => [
'action' => app(UrlGenerator::class)->user(
$this->newComment['user'],
),
'label' => __('View user'),
],
'type' => 'secondary',
],
[
'content' =>
'"' .
Str::limit($this->newComment['content'], 180) .
'"',
'icon' => 'comment',
'type' => 'primary',
],
[
'content' => __('on') . " {$commentable['name']}",
'type' => 'secondary',
],
],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Common\Comments;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
class PaginateModelComments
{
public function execute(Model $commentable): array
{
$pagination = $commentable
->comments()
->rootOnly()
->with([
'user' => fn($builder) => $builder->compact(),
])
->paginate(request('perPage') ?? 25);
$comments = app(LoadChildComments::class)->execute(
$commentable,
Collection::make($pagination->items()),
);
$comments->load([
'votes' => fn($builder) => $builder->withCurrentUserVotes(),
]);
$comments->transform(function (Comment $comment) {
if ($comment->deleted) {
$comment->content = null;
}
$comment->current_vote = $comment->votes->first()?->vote_type;
$comment->unsetRelation('votes');
return $comment;
});
$pagination = $pagination->toArray();
$pagination['data'] = $comments;
return $pagination;
}
}