Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions app/Http/Controllers/FeatureController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<?php

namespace App\Http\Controllers;

use App\Models\Enums\FeatureStatusEnum;
use App\Models\Feature;
use Illuminate\Http\Request;

class FeatureController extends Controller
{
/**
* Display a listing of published features.
*/
public function index(Request $request)
{
$query = Feature::query()->with('author');

// For authenticated users, show their proposed features + all published
// For guests, show only published features
if ($user = $request->user()) {
$query->where(function ($q) use ($user) {
$q->where('status', FeatureStatusEnum::Published)
->orWhere('status', FeatureStatusEnum::Implemented)
->orWhere(function ($subQ) use ($user) {
$subQ->where('status', FeatureStatusEnum::Proposed)
->where('user_id', $user->id);
});
})
// Sort: user's proposed features first, then by votes
->orderByRaw("CASE WHEN user_id = ? AND status = 'proposed' THEN 0 ELSE 1 END", [$user->id])
->orderBy('votes_count', 'desc')
->orderBy('order', 'asc');
} else {
$query->whereIn('status', [FeatureStatusEnum::Published, FeatureStatusEnum::Implemented])
->orderBy('votes_count', 'desc')
->orderBy('order', 'asc');
}

$features = $query->simplePaginate(5);

// Attach user vote status to each feature
if ($user = $request->user()) {
$features->getCollection()->transform(function ($feature) use ($user) {
$feature->user_vote = $feature->getUserVote($user);

return $feature;
});
}

// Check if this is a turbo request (frame or stream)
$isTurboRequest = $request->header('Turbo-Frame') || $request->wantsTurboStream();

if (! $isTurboRequest) {
return view('features.index', [
'features' => $features,
]);
}

return turbo_stream([
turbo_stream()->removeAll('.feature-placeholder'),
turbo_stream()->append('features-frame', view('features._list', [
'features' => $features,
])),

turbo_stream()->replace('feature-more', view('features._pagination', [
'features' => $features,
])),
]);
}

/**
* Store a newly created feature proposal.
*/
public function store(Request $request)
{
$this->authorize('create', Feature::class);

$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'required|string|max:5000',
]);

$feature = Feature::create([
'title' => $validated['title'],
'description' => $validated['description'],
'user_id' => $request->user()->id,
]);

// Load the author relationship
$feature->load('author');

// Add user_vote attribute
$feature->user_vote = $feature->getUserVote($request->user());

return turbo_stream([
turbo_stream()
->target('features-frame')
->action('prepend')
->view('features._list', ['features' => collect([$feature])]),

turbo_stream()
->append('.toast-wrapper')
->view('features._toast', [
'message' => 'Спасибо за предложение! Оно будет рассмотрено в ближайшее время.',
]),
]);
}

/**
* Vote for a feature (upvote only, no cancellation).
*/
public function vote(Request $request, Feature $feature)
{
$this->authorize('vote', $feature);

$validated = $request->validate([
'vote' => 'required|integer|in:1',
]);

$feature->toggleVote($request->user(), $validated['vote']);

// Refresh the feature data with vote count
$feature = $feature->fresh();
$feature->user_vote = $feature->getUserVote($request->user());

return turbo_stream([
turbo_stream()
->target("feature-vote-{$feature->id}")
->action('replace')
->view('features._vote-button', [
'feature' => $feature,
]),
]);
}

public function search(Request $request)
{
$query = $request->input('q', '');
$user = $request->user();

// If query is empty or too short, return default list
if (strlen($query) < 3) {
$featureQuery = Feature::query()->with('author');

// For authenticated users, show their proposed features + all published
if ($user) {
$featureQuery->where(function ($q) use ($user) {
$q->where('status', FeatureStatusEnum::Published)
->orWhere('status', FeatureStatusEnum::Implemented)
->orWhere(function ($subQ) use ($user) {
$subQ->where('status', FeatureStatusEnum::Proposed)
->where('user_id', $user->id);
});
})
// Sort: user's proposed features first, then by votes
->orderByRaw("CASE WHEN user_id = ? AND status = 'proposed' THEN 0 ELSE 1 END", [$user->id])
->orderBy('votes_count', 'desc')
->orderBy('order', 'asc');
} else {
$featureQuery->whereIn('status', [FeatureStatusEnum::Published, FeatureStatusEnum::Implemented])
->orderBy('votes_count', 'desc')
->orderBy('order', 'asc');
}

$features = $featureQuery->simplePaginate(5);
} else {
// Perform search using Scout
$searchQuery = Feature::search($query);

// For authenticated users, include their proposed features in search
if ($user) {
$searchQuery->query(fn ($builder) => $builder
->with('author')
->where(function ($q) use ($user) {
$q->where('status', FeatureStatusEnum::Published)
->orWhere('status', FeatureStatusEnum::Implemented)
->orWhere(function ($subQ) use ($user) {
$subQ->where('status', FeatureStatusEnum::Proposed)
->where('user_id', $user->id);
});
})
->orderByRaw("CASE WHEN user_id = ? AND status = 'proposed' THEN 0 ELSE 1 END", [$user->id])
->orderBy('votes_count', 'desc')
->orderBy('order', 'asc')
);
} else {
$searchQuery->whereIn('status', [FeatureStatusEnum::Published, FeatureStatusEnum::Implemented])
->query(fn ($builder) => $builder->with('author'))
->orderBy('votes_count', 'desc')
->orderBy('order', 'asc');
}

$features = $searchQuery->simplePaginate(5);
}

if ($user) {
$features->getCollection()->transform(function ($feature) use ($user) {
$feature->user_vote = $feature->getUserVote($user);

return $feature;
});
}

return turbo_stream([
turbo_stream()
->target('features-frame')
->action('replace')
->view('features._search-results', [
'features' => $features,
]),
turbo_stream()
->target('feature-more')
->action('replace')
->view('features._pagination', [
'features' => $features,
]),
]);
}
}
26 changes: 26 additions & 0 deletions app/Models/Enums/FeatureStatusEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace App\Models\Enums;

enum FeatureStatusEnum: string
{
case Proposed = 'proposed';
case Published = 'published';
case Rejected = 'rejected';
case Implemented = 'implemented';

/**
* Получить текстовое представление типа Feature.
*
* @return string
*/
public function text(): string
{
return match ($this) {
self::Proposed => 'На рассмотреннии',
self::Published => 'Опубликовано',
self::Rejected => 'Отменено',
self::Implemented => 'Реализовано',
};
}
}
140 changes: 140 additions & 0 deletions app/Models/Feature.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

namespace App\Models;

use App\Models\Concerns\HasAuthor;
use App\Models\Enums\FeatureStatusEnum;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Laravel\Scout\Searchable;
use Orchid\Filters\Filterable;
use Orchid\Filters\Types\Like;
use Orchid\Screen\AsSource;

class Feature extends Model
{
use AsSource, Filterable, HasAuthor, HasFactory, Searchable;

protected $fillable = [
'title',
'description',
'status',
'user_id',
'votes_count',
];

protected $casts = [
'status' => FeatureStatusEnum::class,
'votes_count' => 'integer',
];

protected $attributes = [
'status' => 'proposed',
'votes_count' => 0,
];

protected $allowedFilters = [
'title' => Like::class,
'description' => Like::class,
];

protected $allowedSorts = [
'title',
'votes_count',
'created_at',
];

/**
* Get only published features.
*/
public function scopePublished(Builder $query): Builder
{
return $query->where('status', FeatureStatusEnum::Published);
}

public function isProposed(): bool
{
return $this->status === FeatureStatusEnum::Proposed;
}

public function isImplemented(): bool
{
return $this->status === FeatureStatusEnum::Implemented;
}

public function isRejected(): bool
{
return $this->status === FeatureStatusEnum::Rejected;
}

public function isPublished(): bool
{
return $this->status === FeatureStatusEnum::Published;
}

/**
* Get voters who voted for this feature.
*/
public function voters(): BelongsToMany
{
return $this->belongsToMany(User::class, 'feature_votes')
->withPivot('vote')
->withTimestamps();
}

/**
* Check if the user has voted for this feature.
*/
public function hasVotedBy(?User $user): bool
{
if (! $user) {
return false;
}

return $this->voters()->where('user_id', $user->id)->exists();
}

/**
* Get the user's vote for this feature (1 or -1).
*/
public function getUserVote(?User $user): ?int
{
if (! $user) {
return null;
}

$vote = $this->voters()->where('user_id', $user->id)->first();

return $vote?->pivot->vote;
}

/**
* Vote for this feature (one-time only).
*/
public function toggleVote(User $user, int $voteValue = 1): void
{
// Check if user has already voted
$existingVote = $this->voters()->where('user_id', $user->id)->first();

// If user already voted, do nothing (no cancellation allowed)
if ($existingVote) {
return;
}

// Add new vote (only upvotes allowed, voteValue should be 1)
$this->voters()->attach($user->id, ['vote' => $voteValue]);
$this->increment('votes_count', $voteValue);
}

/**
* Get the indexable data array for the model.
*/
public function toSearchableArray(): array
{
return [
'title' => $this->title,
];
}
}
Loading
Loading