Skip to content

Commit 820aef7

Browse files
Merge pull request #396 from crimsonstrife/dev
Update functionality and bugfixing
2 parents 1b6afe2 + 7679dd2 commit 820aef7

21 files changed

Lines changed: 1177 additions & 213 deletions

File tree

app/Domain/Issues/Events/IssueAssigneeChanged.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ final class IssueAssigneeChanged
1616
public function __construct(
1717
public string $issueId,
1818
public string $newAssigneeId,
19+
public ?string $actorId = null,
1920
) {
2021
}
2122
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Notifications;
4+
5+
use Illuminate\Http\RedirectResponse;
6+
7+
final class MarkAllReadController
8+
{
9+
public function __invoke(): RedirectResponse
10+
{
11+
$user = auth()->user();
12+
if (!$user) {
13+
abort(401);
14+
}
15+
$user->unreadNotifications->markAsRead();
16+
17+
return back();
18+
}
19+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Http\Requests\Issues\TransitionIssueStatusRequest;
6+
use App\Models\Issue;
7+
use App\Models\Project;
8+
use App\Services\Issues\IssueStatusTransitionService;
9+
use Illuminate\Http\RedirectResponse;
10+
11+
final class TransitionStatusController
12+
{
13+
public function __construct(
14+
private IssueStatusTransitionService $transitions
15+
) {
16+
}
17+
18+
public function __invoke(TransitionIssueStatusRequest $request, Project $project, Issue $issue): RedirectResponse
19+
{
20+
// Safety: ensure route-models are consistent
21+
if ($issue->project_id !== $project->id) {
22+
abort(404);
23+
}
24+
25+
$to = (int) $request->input('to_status_id');
26+
27+
if (!$this->transitions->canTransition($issue, $to)) {
28+
return back()->with('error', 'That transition is not allowed for this issue.');
29+
}
30+
31+
$issue->issue_status_id = $to;
32+
$issue->save();
33+
34+
// Retrieve the new status name for better user feedback
35+
$statusName = $issue->status->name ?? 'Unknown';
36+
return back()->with('success', "Status updated to: {$statusName}.");
37+
}
38+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace App\Http\Requests\Issues;
4+
5+
use App\Models\Issue;
6+
use Illuminate\Foundation\Http\FormRequest;
7+
use Illuminate\Validation\Rule;
8+
9+
class TransitionIssueStatusRequest extends FormRequest
10+
{
11+
public function authorize(): bool
12+
{
13+
/** @var Issue|null $issue */
14+
$issue = $this->route('issue');
15+
16+
return $issue !== null && $this->user()?->can('update', $issue) === true;
17+
}
18+
19+
public function rules(): array
20+
{
21+
return [
22+
'to_status_id' => ['required', 'integer', Rule::exists('issue_statuses', 'id')],
23+
];
24+
}
25+
26+
public function messages(): array
27+
{
28+
return [
29+
'to_status_id.required' => 'Choose a status to transition to.',
30+
];
31+
}
32+
}

app/Listeners/Issues/SendIssueAssignedNotification.php

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\Notifications\IssueAssigned;
99
use Illuminate\Contracts\Queue\ShouldQueue;
1010
use Illuminate\Queue\InteractsWithQueue;
11+
use Illuminate\Support\Facades\DB;
1112
use Illuminate\Support\Facades\Route;
1213

1314
/**
@@ -22,20 +23,43 @@ final class SendIssueAssignedNotification implements ShouldQueue
2223

2324
public function handle(IssueAssigneeChanged $event): void
2425
{
25-
/** @var Issue|null $issue */
26-
$issue = Issue::query()
27-
->select(['id', 'summary', 'project_id'])
28-
->find($event->issueId);
29-
30-
/** @var User|null $user */
31-
$user = User::query()
32-
->select(['id', 'name', 'email'])
33-
->find($event->newAssigneeId);
26+
// Self-assignment: don't notify the actor about their own action
27+
if ($event->actorId !== null && (string) $event->actorId === (string) $event->newAssigneeId) {
28+
return;
29+
}
3430

31+
$issue = Issue::query()->select(['id','summary','project_id'])->find($event->issueId);
32+
$user = User::query()->select(['id','name','email'])->find($event->newAssigneeId);
3533
if (! $issue || ! $user) {
3634
return;
3735
}
3836

37+
// ---- DEDUPE: same issue to same user, unread, very recent
38+
$query = $user->notifications()
39+
->where('type', IssueAssigned::class)
40+
->whereNull('read_at')
41+
->where('created_at', '>=', now()->subMinutes(2));
42+
43+
// JSON filter by driver
44+
$driver = DB::getDriverName();
45+
if ($driver === 'pgsql') {
46+
$query->whereRaw("data->>'issue_id' = ?", [(string) $issue->getKey()]);
47+
} elseif ($driver === 'mysql') {
48+
$query->where('data->issue_id', (string) $issue->getKey());
49+
} else {
50+
// fallback: simple LIKE (SQLite/dev)
51+
$needle = '"issue_id":"' . $issue->getKey() . '"';
52+
// Escape %, _, and \ for LIKE pattern
53+
$escapedNeedle = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $needle);
54+
$likePattern = '%' . $escapedNeedle . '%';
55+
$query->whereRaw("data LIKE ? ESCAPE '\\'", [$likePattern]);
56+
}
57+
58+
if ($query->exists()) {
59+
return; // skip duplicate
60+
}
61+
// ---- /DEDUPE
62+
3963
$url = Route::has('issues.show')
4064
? route('issues.show', ['project' => $issue->project, 'issue' => $issue])
4165
: url('projects/' . $issue->project_id . '/issues/' . $issue->key);

app/Models/ProjectStatusTransition.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Traits\IsPermissible;
77
use Illuminate\Database\Eloquent\Concerns\HasUuids;
88
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
910
use Illuminate\Support\Str;
1011
use Spatie\Activitylog\LogOptions;
1112
use Spatie\Activitylog\Traits\LogsActivity;
@@ -49,4 +50,31 @@ public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity): v
4950
$activity->event = $activity->event ?: 'updated'; // create/update/delete auto-populate
5051
$activity->description = 'project.status.' . $activity->event;
5152
}
53+
54+
/** Relationships */
55+
public function project(): BelongsTo
56+
{
57+
return $this->belongsTo(Project::class);
58+
}
59+
60+
public function from(): BelongsTo
61+
{
62+
return $this->belongsTo(IssueStatus::class, 'from_status_id');
63+
}
64+
65+
public function to(): BelongsTo
66+
{
67+
return $this->belongsTo(IssueStatus::class, 'to_status_id');
68+
}
69+
70+
public function type(): BelongsTo
71+
{
72+
return $this->belongsTo(IssueType::class, 'issue_type_id');
73+
}
74+
75+
/** Quick helper: constrain to a project + from status */
76+
public function scopeForProjectFrom($q, string $projectId, int $fromStatusId)
77+
{
78+
return $q->where('project_id', $projectId)->where('from_status_id', $fromStatusId);
79+
}
5280
}

app/Observers/IssueObserver.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ public function created(Issue $issue): void
8484
event(new IssueAssigneeChanged(
8585
issueId: (string) $issue->getKey(),
8686
newAssigneeId: (string) $issue->assignee_id,
87+
actorId: auth()->id() ? (string) auth()->id() : null,
8788
));
8889
}
8990
}
@@ -127,6 +128,7 @@ public function updated(Issue $issue): void
127128
event(new IssueAssigneeChanged(
128129
issueId: (string) $issue->getKey(),
129130
newAssigneeId: (string) $issue->assignee_id,
131+
actorId: auth()->id() ? (string) auth()->id() : null,
130132
));
131133
}
132134
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace App\Providers;
4+
5+
use Illuminate\Support\ServiceProvider;
6+
use Spatie\CpuLoadHealthCheck\CpuLoadCheck;
7+
use Spatie\Health\Checks\Checks\CacheCheck;
8+
use Spatie\Health\Checks\Checks\DatabaseCheck;
9+
use Spatie\Health\Checks\Checks\OptimizedAppCheck;
10+
use Spatie\Health\Checks\Checks\QueueCheck;
11+
use Spatie\Health\Checks\Checks\UsedDiskSpaceCheck;
12+
use Spatie\Health\Facades\Health;
13+
use Spatie\SecurityAdvisoriesHealthCheck\SecurityAdvisoriesCheck;
14+
15+
class AppHealthServiceProvider extends ServiceProvider
16+
{
17+
/**
18+
* Register services.
19+
*/
20+
public function register(): void
21+
{
22+
//
23+
}
24+
25+
/**
26+
* Bootstrap services.
27+
*/
28+
public function boot(): void
29+
{
30+
$this->app->booted(function (): void {
31+
Health::checks([
32+
CacheCheck::new()
33+
->everyFifteenMinutes()
34+
->name('Cache Check'),
35+
OptimizedAppCheck::new()
36+
->everyThirtyMinutes()
37+
->name('Check Optimization'),
38+
UsedDiskSpaceCheck::new()
39+
->daily()
40+
->name('Check Used DiskSpace'),
41+
DatabaseCheck::new()
42+
->name('Check Database Connection')
43+
->everyMinute(),
44+
QueueCheck::new()
45+
->everyFiveMinutes()
46+
->name('Check Job Queue'),
47+
CpuLoadCheck::new()
48+
->failWhenLoadIsHigherInTheLast5Minutes(2.0)
49+
->failWhenLoadIsHigherInTheLast15Minutes(1.5),
50+
SecurityAdvisoriesCheck::new()
51+
->daily(),
52+
]);
53+
});
54+
}
55+
}

app/Providers/EventServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
final class EventServiceProvider extends ServiceProvider
1010
{
11+
protected static $shouldDiscoverEvents = false;
12+
1113
/** @var array<class-string, array<int, class-string>> */
1214
protected $listen = [
1315
IssueAssigneeChanged::class => [
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
namespace App\Services\Issues;
4+
5+
use App\Models\Issue;
6+
use App\Models\IssueStatus;
7+
use App\Models\ProjectStatusTransition;
8+
use Illuminate\Support\Collection;
9+
10+
final class IssueStatusTransitionService
11+
{
12+
/**
13+
* @return Collection<int, IssueStatus>
14+
*/
15+
public function allowedToStatusesForIssue(Issue $issue): Collection
16+
{
17+
if (!$issue->issue_status_id) {
18+
return collect();
19+
}
20+
21+
$toIds = ProjectStatusTransition::query()
22+
->forProjectFrom($issue->project_id, (int) $issue->issue_status_id)
23+
->where(function ($q) use ($issue) {
24+
$q->where('is_global', true)
25+
->orWhere(function ($qq) use ($issue) {
26+
$qq->where('is_global', false)
27+
->where('issue_type_id', $issue->issue_type_id);
28+
});
29+
})
30+
->pluck('to_status_id')
31+
->unique()
32+
->values();
33+
34+
// Fallback: if no transitions configured, allow any status.
35+
if ($toIds->isEmpty()) {
36+
return IssueStatus::query()
37+
->orderBy('name')
38+
->get(['id', 'name', 'color', 'is_done']);
39+
}
40+
41+
return IssueStatus::query()
42+
->whereIn('id', $toIds)
43+
->orderBy('name')
44+
->get(['id', 'name', 'color', 'is_done']);
45+
}
46+
47+
public function canTransition(Issue $issue, int $toStatusId): bool
48+
{
49+
if ((int) $issue->issue_status_id === $toStatusId) {
50+
return true;
51+
}
52+
53+
$allowed = $this->allowedToStatusesForIssue($issue)->pluck('id')->all();
54+
55+
// If transitions are configured, enforce them strictly:
56+
if (!empty($allowed)) {
57+
return in_array($toStatusId, $allowed, true);
58+
}
59+
60+
// If not configured, allow any valid status id:
61+
return IssueStatus::query()->whereKey($toStatusId)->exists();
62+
}
63+
}

0 commit comments

Comments
 (0)