Files
usher-manage-stack/app/Http/Controllers/IssueController.php
2025-11-20 23:21:05 +08:00

508 lines
17 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Issue;
use App\Models\IssueAttachment;
use App\Models\IssueComment;
use App\Models\IssueLabel;
use App\Models\IssueTimeLog;
use App\Models\Member;
use App\Models\User;
use App\Support\AuditLogger;
use App\Mail\IssueAssignedMail;
use App\Mail\IssueStatusChangedMail;
use App\Mail\IssueCommentedMail;
use App\Mail\IssueClosedMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
class IssueController extends Controller
{
public function index(Request $request)
{
$query = Issue::with(['creator', 'assignee', 'labels'])
->latest();
// Filter by type
if ($type = $request->string('issue_type')->toString()) {
$query->where('issue_type', $type);
}
// Filter by status
if ($status = $request->string('status')->toString()) {
$query->where('status', $status);
}
// Filter by priority
if ($priority = $request->string('priority')->toString()) {
$query->where('priority', $priority);
}
// Filter by assignee
if ($assigneeId = $request->integer('assigned_to')) {
$query->where('assigned_to_user_id', $assigneeId);
}
// Filter by creator
if ($creatorId = $request->integer('created_by')) {
$query->where('created_by_user_id', $creatorId);
}
// Filter by label
if ($labelId = $request->integer('label')) {
$query->withLabel($labelId);
}
// Filter by due date range
if ($dueDateFrom = $request->string('due_date_from')->toString()) {
$query->whereDate('due_date', '>=', $dueDateFrom);
}
if ($dueDateTo = $request->string('due_date_to')->toString()) {
$query->whereDate('due_date', '<=', $dueDateTo);
}
// Text search
if ($search = $request->string('search')->toString()) {
$query->where(function ($q) use ($search) {
$q->where('issue_number', 'like', "%{$search}%")
->orWhere('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
// Show only open issues by default
if ($request->string('show_closed')->toString() !== '1') {
$query->open();
}
$issues = $query->paginate(20)->withQueryString();
// Get filters for dropdowns
$users = User::orderBy('name')->get();
$labels = IssueLabel::orderBy('name')->get();
// Get summary stats
$stats = [
'total_open' => Issue::open()->count(),
'assigned_to_me' => Issue::assignedTo(Auth::id())->open()->count(),
'overdue' => Issue::overdue()->count(),
'high_priority' => Issue::byPriority(Issue::PRIORITY_HIGH)->open()->count() +
Issue::byPriority(Issue::PRIORITY_URGENT)->open()->count(),
];
return view('admin.issues.index', compact('issues', 'users', 'labels', 'stats'));
}
public function create()
{
$users = User::orderBy('name')->get();
$labels = IssueLabel::orderBy('name')->get();
$members = Member::orderBy('full_name')->get();
$openIssues = Issue::open()->orderBy('issue_number')->get();
return view('admin.issues.create', compact('users', 'labels', 'members', 'openIssues'));
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'issue_type' => ['required', Rule::in([
Issue::TYPE_WORK_ITEM,
Issue::TYPE_PROJECT_TASK,
Issue::TYPE_MAINTENANCE,
Issue::TYPE_MEMBER_REQUEST,
])],
'priority' => ['required', Rule::in([
Issue::PRIORITY_LOW,
Issue::PRIORITY_MEDIUM,
Issue::PRIORITY_HIGH,
Issue::PRIORITY_URGENT,
])],
'assigned_to_user_id' => ['nullable', 'exists:users,id'],
'member_id' => ['nullable', 'exists:members,id'],
'parent_issue_id' => ['nullable', 'exists:issues,id'],
'due_date' => ['nullable', 'date'],
'estimated_hours' => ['nullable', 'numeric', 'min:0'],
'labels' => ['nullable', 'array'],
'labels.*' => ['exists:issue_labels,id'],
]);
$issue = DB::transaction(function () use ($validated, $request) {
$issue = Issue::create([
...$validated,
'created_by_user_id' => Auth::id(),
'status' => $validated['assigned_to_user_id'] ? Issue::STATUS_ASSIGNED : Issue::STATUS_NEW,
]);
// Attach labels
if (!empty($validated['labels'])) {
$issue->labels()->attach($validated['labels']);
}
// Auto-watch: creator and assignee
$watchers = [Auth::id()];
if ($validated['assigned_to_user_id'] && $validated['assigned_to_user_id'] != Auth::id()) {
$watchers[] = $validated['assigned_to_user_id'];
}
$issue->watchers()->attach(array_unique($watchers));
AuditLogger::log('issue.created', $issue, [
'issue_number' => $issue->issue_number,
'title' => $issue->title,
'type' => $issue->issue_type,
]);
return $issue;
});
// Send email notification to assignee
if ($issue->assigned_to_user_id && $issue->assignedTo) {
Mail::to($issue->assignedTo->email)->queue(new IssueAssignedMail($issue));
}
return redirect()->route('admin.issues.show', $issue)
->with('status', __('Issue created successfully.'));
}
public function show(Issue $issue)
{
$issue->load([
'creator',
'assignee',
'reviewer',
'member',
'parentIssue',
'subTasks',
'labels',
'watchers',
'comments.user',
'attachments.user',
'timeLogs.user',
'relatedIssues',
]);
$users = User::orderBy('name')->get();
$labels = IssueLabel::orderBy('name')->get();
return view('admin.issues.show', compact('issue', 'users', 'labels'));
}
public function edit(Issue $issue)
{
if ($issue->isClosed() && !Auth::user()->is_admin) {
return redirect()->route('admin.issues.show', $issue)
->with('error', __('Cannot edit closed issues.'));
}
$users = User::orderBy('name')->get();
$labels = IssueLabel::orderBy('name')->get();
$members = Member::orderBy('full_name')->get();
$openIssues = Issue::open()->where('id', '!=', $issue->id)->orderBy('issue_number')->get();
return view('admin.issues.edit', compact('issue', 'users', 'labels', 'members', 'openIssues'));
}
public function update(Request $request, Issue $issue)
{
if ($issue->isClosed() && !Auth::user()->is_admin) {
return redirect()->route('admin.issues.show', $issue)
->with('error', __('Cannot edit closed issues.'));
}
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'issue_type' => ['required', Rule::in([
Issue::TYPE_WORK_ITEM,
Issue::TYPE_PROJECT_TASK,
Issue::TYPE_MAINTENANCE,
Issue::TYPE_MEMBER_REQUEST,
])],
'priority' => ['required', Rule::in([
Issue::PRIORITY_LOW,
Issue::PRIORITY_MEDIUM,
Issue::PRIORITY_HIGH,
Issue::PRIORITY_URGENT,
])],
'assigned_to_user_id' => ['nullable', 'exists:users,id'],
'reviewer_id' => ['nullable', 'exists:users,id'],
'member_id' => ['nullable', 'exists:members,id'],
'parent_issue_id' => ['nullable', 'exists:issues,id'],
'due_date' => ['nullable', 'date'],
'estimated_hours' => ['nullable', 'numeric', 'min:0'],
'labels' => ['nullable', 'array'],
'labels.*' => ['exists:issue_labels,id'],
]);
$issue = DB::transaction(function () use ($issue, $validated) {
$issue->update($validated);
// Sync labels
if (isset($validated['labels'])) {
$issue->labels()->sync($validated['labels']);
}
AuditLogger::log('issue.updated', $issue, [
'issue_number' => $issue->issue_number,
]);
return $issue;
});
return redirect()->route('admin.issues.show', $issue)
->with('status', __('Issue updated successfully.'));
}
public function destroy(Issue $issue)
{
if (!Auth::user()->is_admin) {
abort(403, 'Only administrators can delete issues.');
}
AuditLogger::log('issue.deleted', $issue, [
'issue_number' => $issue->issue_number,
'title' => $issue->title,
]);
$issue->delete();
return redirect()->route('admin.issues.index')
->with('status', __('Issue deleted successfully.'));
}
// ==================== Workflow Actions ====================
public function assign(Request $request, Issue $issue)
{
$validated = $request->validate([
'assigned_to_user_id' => ['required', 'exists:users,id'],
]);
$issue->update([
'assigned_to_user_id' => $validated['assigned_to_user_id'],
'status' => Issue::STATUS_ASSIGNED,
]);
// Add assignee as watcher
if (!$issue->watchers->contains($validated['assigned_to_user_id'])) {
$issue->watchers()->attach($validated['assigned_to_user_id']);
}
AuditLogger::log('issue.assigned', $issue, [
'assigned_to' => $validated['assigned_to_user_id'],
]);
// Send email notification to assignee
if ($issue->assignedTo) {
Mail::to($issue->assignedTo->email)->queue(new IssueAssignedMail($issue));
}
return back()->with('status', __('Issue assigned successfully.'));
}
public function updateStatus(Request $request, Issue $issue)
{
$validated = $request->validate([
'status' => ['required', Rule::in([
Issue::STATUS_NEW,
Issue::STATUS_ASSIGNED,
Issue::STATUS_IN_PROGRESS,
Issue::STATUS_REVIEW,
Issue::STATUS_CLOSED,
])],
'close_reason' => ['nullable', 'string', 'max:500'],
]);
$newStatus = $validated['status'];
$oldStatus = $issue->status;
// Validate status transition
if ($newStatus === Issue::STATUS_IN_PROGRESS && !$issue->canMoveToInProgress()) {
return back()->with('error', __('Issue must be assigned before moving to in progress.'));
}
if ($newStatus === Issue::STATUS_REVIEW && !$issue->canMoveToReview()) {
return back()->with('error', __('Issue must be in progress before moving to review.'));
}
if ($newStatus === Issue::STATUS_CLOSED && !$issue->canBeClosed()) {
return back()->with('error', __('Cannot close issue in current state.'));
}
// Update status
$updateData = ['status' => $newStatus];
if ($newStatus === Issue::STATUS_CLOSED) {
$updateData['closed_at'] = now();
} elseif ($oldStatus === Issue::STATUS_CLOSED) {
// Reopening
$updateData['closed_at'] = null;
}
$issue->update($updateData);
AuditLogger::log('issue.status_changed', $issue, [
'from_status' => $oldStatus,
'to_status' => $newStatus,
'close_reason' => $validated['close_reason'] ?? null,
]);
// Send email notifications to watchers
if ($newStatus === Issue::STATUS_CLOSED) {
// Send "closed" notification
foreach ($issue->watchers as $watcher) {
Mail::to($watcher->email)->queue(new IssueClosedMail($issue));
}
} else {
// Send "status changed" notification
foreach ($issue->watchers as $watcher) {
Mail::to($watcher->email)->queue(new IssueStatusChangedMail($issue, $oldStatus, $newStatus));
}
}
return back()->with('status', __('Issue status updated successfully.'));
}
public function addComment(Request $request, Issue $issue)
{
$validated = $request->validate([
'comment_text' => ['required', 'string'],
'is_internal' => ['boolean'],
]);
$comment = $issue->comments()->create([
'user_id' => Auth::id(),
'comment_text' => $validated['comment_text'],
'is_internal' => $validated['is_internal'] ?? false,
]);
AuditLogger::log('issue.commented', $issue, [
'comment_id' => $comment->id,
]);
// Notify watchers (except the comment author and skip internal comments for non-watchers)
foreach ($issue->watchers as $watcher) {
// Don't send notification to the person who added the comment
if ($watcher->id === Auth::id()) {
continue;
}
Mail::to($watcher->email)->queue(new IssueCommentedMail($issue, $comment));
}
return back()->with('status', __('Comment added successfully.'));
}
public function uploadAttachment(Request $request, Issue $issue)
{
$validated = $request->validate([
'file' => ['required', 'file', 'max:10240'], // 10MB max
]);
$file = $request->file('file');
$fileName = $file->getClientOriginalName();
$filePath = $file->store('issue-attachments', 'private');
$attachment = $issue->attachments()->create([
'user_id' => Auth::id(),
'file_name' => $fileName,
'file_path' => $filePath,
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
]);
AuditLogger::log('issue.file_attached', $issue, [
'file_name' => $fileName,
'attachment_id' => $attachment->id,
]);
return back()->with('status', __('File uploaded successfully.'));
}
public function downloadAttachment(IssueAttachment $attachment)
{
if (!Storage::exists($attachment->file_path)) {
abort(404, 'File not found.');
}
return Storage::download($attachment->file_path, $attachment->file_name);
}
public function deleteAttachment(IssueAttachment $attachment)
{
$issueId = $attachment->issue_id;
AuditLogger::log('issue.file_deleted', $attachment->issue, [
'file_name' => $attachment->file_name,
'attachment_id' => $attachment->id,
]);
$attachment->delete();
return redirect()->route('admin.issues.show', $issueId)
->with('status', __('Attachment deleted successfully.'));
}
public function logTime(Request $request, Issue $issue)
{
$validated = $request->validate([
'hours' => ['required', 'numeric', 'min:0.01', 'max:999.99'],
'description' => ['nullable', 'string', 'max:500'],
'logged_at' => ['required', 'date'],
]);
$timeLog = $issue->timeLogs()->create([
'user_id' => Auth::id(),
'hours' => $validated['hours'],
'description' => $validated['description'],
'logged_at' => $validated['logged_at'],
]);
AuditLogger::log('issue.time_logged', $issue, [
'hours' => $validated['hours'],
'time_log_id' => $timeLog->id,
]);
return back()->with('status', __('Time logged successfully.'));
}
public function addWatcher(Request $request, Issue $issue)
{
$validated = $request->validate([
'user_id' => ['required', 'exists:users,id'],
]);
if ($issue->watchers->contains($validated['user_id'])) {
return back()->with('error', __('User is already watching this issue.'));
}
$issue->watchers()->attach($validated['user_id']);
AuditLogger::log('issue.watcher_added', $issue, [
'watcher_id' => $validated['user_id'],
]);
return back()->with('status', __('Watcher added successfully.'));
}
public function removeWatcher(Request $request, Issue $issue)
{
$validated = $request->validate([
'user_id' => ['required', 'exists:users,id'],
]);
$issue->watchers()->detach($validated['user_id']);
AuditLogger::log('issue.watcher_removed', $issue, [
'watcher_id' => $validated['user_id'],
]);
return back()->with('status', __('Watcher removed successfully.'));
}
}