Skip to content

Commit 915ba5d

Browse files
committed
Добавлены миграции, модели, политики, контроллеры, и интерфейсы для предложений feature и голосования.
1 parent efcf346 commit 915ba5d

19 files changed

+1286
-0
lines changed
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\Enums\FeatureStatusEnum;
6+
use App\Models\Feature;
7+
use Illuminate\Http\Request;
8+
9+
class FeatureController extends Controller
10+
{
11+
/**
12+
* Display a listing of published features.
13+
*/
14+
public function index(Request $request)
15+
{
16+
$query = Feature::query()->with('author');
17+
18+
// For authenticated users, show their proposed features + all published
19+
// For guests, show only published features
20+
if ($user = $request->user()) {
21+
$query->where(function ($q) use ($user) {
22+
$q->where('status', FeatureStatusEnum::Published)
23+
->orWhere('status', FeatureStatusEnum::Implemented)
24+
->orWhere(function ($subQ) use ($user) {
25+
$subQ->where('status', FeatureStatusEnum::Proposed)
26+
->where('user_id', $user->id);
27+
});
28+
})
29+
// Sort: user's proposed features first, then by votes
30+
->orderByRaw("CASE WHEN user_id = ? AND status = 'proposed' THEN 0 ELSE 1 END", [$user->id])
31+
->orderBy('votes_count', 'desc')
32+
->orderBy('order', 'asc');
33+
} else {
34+
$query->whereIn('status', [FeatureStatusEnum::Published, FeatureStatusEnum::Implemented])
35+
->orderBy('votes_count', 'desc')
36+
->orderBy('order', 'asc');
37+
}
38+
39+
$features = $query->simplePaginate(5);
40+
41+
// Attach user vote status to each feature
42+
if ($user = $request->user()) {
43+
$features->getCollection()->transform(function ($feature) use ($user) {
44+
$feature->user_vote = $feature->getUserVote($user);
45+
46+
return $feature;
47+
});
48+
}
49+
50+
// Check if this is a turbo request (frame or stream)
51+
$isTurboRequest = $request->header('Turbo-Frame') || $request->wantsTurboStream();
52+
53+
if (! $isTurboRequest) {
54+
return view('features.index', [
55+
'features' => $features,
56+
]);
57+
}
58+
59+
return turbo_stream([
60+
turbo_stream()->removeAll('.feature-placeholder'),
61+
turbo_stream()->append('features-frame', view('features._list', [
62+
'features' => $features,
63+
])),
64+
65+
turbo_stream()->replace('feature-more', view('features._pagination', [
66+
'features' => $features,
67+
])),
68+
]);
69+
}
70+
71+
/**
72+
* Store a newly created feature proposal.
73+
*/
74+
public function store(Request $request)
75+
{
76+
$this->authorize('create', Feature::class);
77+
78+
$validated = $request->validate([
79+
'title' => 'required|string|max:255',
80+
'description' => 'required|string|max:5000',
81+
]);
82+
83+
$feature = Feature::create([
84+
'title' => $validated['title'],
85+
'description' => $validated['description'],
86+
'user_id' => $request->user()->id,
87+
]);
88+
89+
// Load the author relationship
90+
$feature->load('author');
91+
92+
// Add user_vote attribute
93+
$feature->user_vote = $feature->getUserVote($request->user());
94+
95+
return turbo_stream([
96+
turbo_stream()
97+
->target('features-frame')
98+
->action('prepend')
99+
->view('features._list', ['features' => collect([$feature])]),
100+
101+
turbo_stream()
102+
->append('.toast-wrapper')
103+
->view('features._toast', [
104+
'message' => 'Спасибо за предложение! Оно будет рассмотрено в ближайшее время.',
105+
]),
106+
]);
107+
}
108+
109+
/**
110+
* Vote for a feature (upvote only, no cancellation).
111+
*/
112+
public function vote(Request $request, Feature $feature)
113+
{
114+
$this->authorize('vote', $feature);
115+
116+
$validated = $request->validate([
117+
'vote' => 'required|integer|in:1',
118+
]);
119+
120+
$feature->toggleVote($request->user(), $validated['vote']);
121+
122+
// Refresh the feature data with vote count
123+
$feature = $feature->fresh();
124+
$feature->user_vote = $feature->getUserVote($request->user());
125+
126+
return turbo_stream([
127+
turbo_stream()
128+
->target("feature-vote-{$feature->id}")
129+
->action('replace')
130+
->view('features._vote-button', [
131+
'feature' => $feature,
132+
]),
133+
]);
134+
}
135+
136+
public function search(Request $request)
137+
{
138+
$query = $request->input('q', '');
139+
$user = $request->user();
140+
141+
// If query is empty or too short, return default list
142+
if (strlen($query) < 3) {
143+
$featureQuery = Feature::query()->with('author');
144+
145+
// For authenticated users, show their proposed features + all published
146+
if ($user) {
147+
$featureQuery->where(function ($q) use ($user) {
148+
$q->where('status', FeatureStatusEnum::Published)
149+
->orWhere('status', FeatureStatusEnum::Implemented)
150+
->orWhere(function ($subQ) use ($user) {
151+
$subQ->where('status', FeatureStatusEnum::Proposed)
152+
->where('user_id', $user->id);
153+
});
154+
})
155+
// Sort: user's proposed features first, then by votes
156+
->orderByRaw("CASE WHEN user_id = ? AND status = 'proposed' THEN 0 ELSE 1 END", [$user->id])
157+
->orderBy('votes_count', 'desc')
158+
->orderBy('order', 'asc');
159+
} else {
160+
$featureQuery->whereIn('status', [FeatureStatusEnum::Published, FeatureStatusEnum::Implemented])
161+
->orderBy('votes_count', 'desc')
162+
->orderBy('order', 'asc');
163+
}
164+
165+
$features = $featureQuery->simplePaginate(5);
166+
} else {
167+
// Perform search using Scout
168+
$searchQuery = Feature::search($query);
169+
170+
// For authenticated users, include their proposed features in search
171+
if ($user) {
172+
$searchQuery->query(fn ($builder) => $builder
173+
->with('author')
174+
->where(function ($q) use ($user) {
175+
$q->where('status', FeatureStatusEnum::Published)
176+
->orWhere('status', FeatureStatusEnum::Implemented)
177+
->orWhere(function ($subQ) use ($user) {
178+
$subQ->where('status', FeatureStatusEnum::Proposed)
179+
->where('user_id', $user->id);
180+
});
181+
})
182+
->orderByRaw("CASE WHEN user_id = ? AND status = 'proposed' THEN 0 ELSE 1 END", [$user->id])
183+
->orderBy('votes_count', 'desc')
184+
->orderBy('order', 'asc')
185+
);
186+
} else {
187+
$searchQuery->whereIn('status', [FeatureStatusEnum::Published, FeatureStatusEnum::Implemented])
188+
->query(fn ($builder) => $builder->with('author'))
189+
->orderBy('votes_count', 'desc')
190+
->orderBy('order', 'asc');
191+
}
192+
193+
$features = $searchQuery->simplePaginate(5);
194+
}
195+
196+
if ($user) {
197+
$features->getCollection()->transform(function ($feature) use ($user) {
198+
$feature->user_vote = $feature->getUserVote($user);
199+
200+
return $feature;
201+
});
202+
}
203+
204+
return turbo_stream([
205+
turbo_stream()
206+
->target('features-frame')
207+
->action('replace')
208+
->view('features._search-results', [
209+
'features' => $features,
210+
]),
211+
turbo_stream()
212+
->target('feature-more')
213+
->action('replace')
214+
->view('features._pagination', [
215+
'features' => $features,
216+
]),
217+
]);
218+
}
219+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace App\Models\Enums;
4+
5+
enum FeatureStatusEnum: string
6+
{
7+
case Proposed = 'proposed';
8+
case Published = 'published';
9+
case Rejected = 'rejected';
10+
case Implemented = 'implemented';
11+
12+
/**
13+
* Получить текстовое представление типа Feature.
14+
*
15+
* @return string
16+
*/
17+
public function text(): string
18+
{
19+
return match ($this) {
20+
self::Proposed => 'На рассмотреннии',
21+
self::Published => 'Опубликовано',
22+
self::Rejected => 'Отменено',
23+
self::Implemented => 'Реализовано',
24+
};
25+
}
26+
}

app/Models/Feature.php

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use App\Models\Concerns\HasAuthor;
6+
use App\Models\Enums\FeatureStatusEnum;
7+
use Illuminate\Database\Eloquent\Builder;
8+
use Illuminate\Database\Eloquent\Factories\HasFactory;
9+
use Illuminate\Database\Eloquent\Model;
10+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
11+
use Laravel\Scout\Searchable;
12+
use Orchid\Filters\Filterable;
13+
use Orchid\Filters\Types\Like;
14+
use Orchid\Screen\AsSource;
15+
16+
class Feature extends Model
17+
{
18+
use AsSource, Filterable, HasAuthor, HasFactory, Searchable;
19+
20+
protected $fillable = [
21+
'title',
22+
'description',
23+
'status',
24+
'user_id',
25+
'votes_count',
26+
];
27+
28+
protected $casts = [
29+
'status' => FeatureStatusEnum::class,
30+
'votes_count' => 'integer',
31+
];
32+
33+
protected $attributes = [
34+
'status' => 'proposed',
35+
'votes_count' => 0,
36+
];
37+
38+
protected $allowedFilters = [
39+
'title' => Like::class,
40+
'description' => Like::class,
41+
];
42+
43+
protected $allowedSorts = [
44+
'title',
45+
'votes_count',
46+
'created_at',
47+
];
48+
49+
/**
50+
* Get only published features.
51+
*/
52+
public function scopePublished(Builder $query): Builder
53+
{
54+
return $query->where('status', FeatureStatusEnum::Published);
55+
}
56+
57+
public function isProposed(): bool
58+
{
59+
return $this->status === FeatureStatusEnum::Proposed;
60+
}
61+
62+
public function isImplemented(): bool
63+
{
64+
return $this->status === FeatureStatusEnum::Implemented;
65+
}
66+
67+
public function isRejected(): bool
68+
{
69+
return $this->status === FeatureStatusEnum::Rejected;
70+
}
71+
72+
public function isPublished(): bool
73+
{
74+
return $this->status === FeatureStatusEnum::Published;
75+
}
76+
77+
/**
78+
* Get voters who voted for this feature.
79+
*/
80+
public function voters(): BelongsToMany
81+
{
82+
return $this->belongsToMany(User::class, 'feature_votes')
83+
->withPivot('vote')
84+
->withTimestamps();
85+
}
86+
87+
/**
88+
* Check if the user has voted for this feature.
89+
*/
90+
public function hasVotedBy(?User $user): bool
91+
{
92+
if (! $user) {
93+
return false;
94+
}
95+
96+
return $this->voters()->where('user_id', $user->id)->exists();
97+
}
98+
99+
/**
100+
* Get the user's vote for this feature (1 or -1).
101+
*/
102+
public function getUserVote(?User $user): ?int
103+
{
104+
if (! $user) {
105+
return null;
106+
}
107+
108+
$vote = $this->voters()->where('user_id', $user->id)->first();
109+
110+
return $vote?->pivot->vote;
111+
}
112+
113+
/**
114+
* Vote for this feature (one-time only).
115+
*/
116+
public function toggleVote(User $user, int $voteValue = 1): void
117+
{
118+
// Check if user has already voted
119+
$existingVote = $this->voters()->where('user_id', $user->id)->first();
120+
121+
// If user already voted, do nothing (no cancellation allowed)
122+
if ($existingVote) {
123+
return;
124+
}
125+
126+
// Add new vote (only upvotes allowed, voteValue should be 1)
127+
$this->voters()->attach($user->id, ['vote' => $voteValue]);
128+
$this->increment('votes_count', $voteValue);
129+
}
130+
131+
/**
132+
* Get the indexable data array for the model.
133+
*/
134+
public function toSearchableArray(): array
135+
{
136+
return [
137+
'title' => $this->title,
138+
];
139+
}
140+
}

0 commit comments

Comments
 (0)