Commit the Projects/Tasks/Ideas/TaskComments feature

This was already implemented, migrated, and running live, but had
never been committed. Bringing it into git: Project/Task/Idea/
TaskComment models, their JSON API (CRUD + status transitions,
task comments, idea<->project linking), Livewire UI pages, and the
nav/route wiring for them. All 42 of its own tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
m105
2026-08-06 20:41:57 +03:00
co-authored by Claude Sonnet 5
parent 6ff7f78c9c
commit a4ff6c0ccb
39 changed files with 3411 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreIdeaRequest;
use App\Http\Requests\UpdateIdeaRequest;
use App\Http\Resources\IdeaResource;
use App\Models\Idea;
use Illuminate\Http\Request;
class IdeaController extends Controller
{
public function index(Request $request)
{
$query = Idea::query();
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('category')) {
$query->where('category', $request->string('category'));
}
if ($request->filled('project_id')) {
$query->where('project_id', $request->integer('project_id'));
}
if ($request->filled('search')) {
$query->search($request->string('search'));
}
if ($request->filled('unlinked')) {
$query->unlinked();
}
if ($request->filled('priority')) {
$query->where('priority', $request->integer('priority'));
}
$ideas = $query->orderBy('priority', 'desc')->orderBy('created_at', 'desc')->get();
// Eager load relationships if requested
if ($request->boolean('with_project')) {
$ideas->load(['project']);
}
if ($request->boolean('with_user')) {
$ideas->load(['user']);
}
return IdeaResource::collection($ideas);
}
public function store(StoreIdeaRequest $request)
{
$idea = Idea::create($request->validated());
return (new IdeaResource($idea))
->response()
->setStatusCode(201);
}
public function show(Request $request, Idea $idea)
{
if ($request->boolean('with_project')) {
$idea->load(['project']);
}
if ($request->boolean('with_user')) {
$idea->load(['user']);
}
return new IdeaResource($idea);
}
public function update(UpdateIdeaRequest $request, Idea $idea)
{
$idea->update($request->validated());
return new IdeaResource($idea);
}
public function destroy(Idea $idea)
{
$idea->delete();
return response()->noContent();
}
/**
* Change idea status.
*/
public function changeStatus(Request $request, Idea $idea)
{
$request->validate([
'status' => ['required', 'string', 'in:' . implode(',', Idea::STATUSES)],
]);
$idea->update(['status' => $request->string('status')]);
return new IdeaResource($idea);
}
/**
* Link an idea to a project.
*/
public function linkToProject(Request $request, Idea $idea)
{
$request->validate([
'project_id' => ['required', 'integer', 'exists:projects,id'],
]);
$idea->update(['project_id' => $request->integer('project_id')]);
return new IdeaResource($idea->load(['project']));
}
/**
* Unlink an idea from a project.
*/
public function unlinkFromProject(Idea $idea)
{
$idea->update(['project_id' => null]);
return new IdeaResource($idea);
}
}
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\IdeaResource;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\TaskResource;
use App\Models\Project;
use Illuminate\Http\Request;
class ProjectController extends Controller
{
public function index(Request $request)
{
$query = Project::query();
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('name')) {
$query->where('name', 'like', '%' . $request->string('name') . '%');
}
$projects = $query->orderBy('name')->get();
// Eager load tasks if ?with_tasks=1
if ($request->boolean('with_tasks')) {
$projects->load(['tasks.comments']);
}
return ProjectResource::collection($projects);
}
public function store(StoreProjectRequest $request)
{
$project = Project::create($request->validated());
return (new ProjectResource($project))
->response()
->setStatusCode(201);
}
public function show(Request $request, Project $project)
{
if ($request->boolean('with_tasks')) {
$project->load(['tasks.comments']);
}
return new ProjectResource($project);
}
public function update(UpdateProjectRequest $request, Project $project)
{
$project->update($request->validated());
return new ProjectResource($project);
}
public function destroy(Project $project)
{
$project->delete();
return response()->noContent();
}
/**
* Get tasks for a project.
*/
public function tasks(Request $request, Project $project)
{
$query = $project->tasks()->with(['comments']);
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('priority')) {
$query->where('priority', $request->integer('priority'));
}
$tasks = $query->orderBy('priority', 'desc')->orderBy('created_at')->get();
return TaskResource::collection($tasks);
}
/**
* Get ideas for a project.
*/
public function ideas(Request $request, Project $project)
{
$query = $project->ideas()->with(['user']);
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('category')) {
$query->where('category', $request->string('category'));
}
if ($request->filled('priority')) {
$query->where('priority', $request->integer('priority'));
}
$ideas = $query->orderBy('priority', 'desc')->orderBy('created_at', 'desc')->get();
return IdeaResource::collection($ideas);
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTaskCommentRequest;
use App\Http\Resources\TaskCommentResource;
use App\Models\Task;
use App\Models\TaskComment;
use Illuminate\Http\Request;
class TaskCommentController extends Controller
{
/**
* Get all comments for a task.
*/
public function index(Request $request, Task $task)
{
$comments = $task->comments()->orderBy('created_at')->get();
return TaskCommentResource::collection($comments);
}
/**
* Create a new comment for a task.
*/
public function store(StoreTaskCommentRequest $request, Task $task)
{
$comment = $task->comments()->create($request->validated());
return (new TaskCommentResource($comment))
->response()
->setStatusCode(201);
}
/**
* Update a comment.
*/
public function update(Request $request, TaskComment $comment)
{
$request->validate([
'body' => ['required', 'string'],
]);
$comment->update([
'body' => $request->string('body'),
]);
return new TaskCommentResource($comment);
}
/**
* Show a single comment.
*/
public function show(TaskComment $comment)
{
return new TaskCommentResource($comment);
}
/**
* Delete a comment.
*/
public function destroy(TaskComment $comment)
{
$comment->delete();
return response()->noContent();
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTaskRequest;
use App\Http\Requests\UpdateTaskRequest;
use App\Http\Resources\TaskResource;
use App\Models\Task;
use Illuminate\Http\Request;
class TaskController extends Controller
{
public function index(Request $request)
{
$query = Task::with(['project', 'comments']);
if ($request->filled('status')) {
$query->where('status', $request->string('status'));
}
if ($request->filled('project_id')) {
$query->where('project_id', $request->integer('project_id'));
}
if ($request->filled('priority')) {
$query->where('priority', $request->integer('priority'));
}
if ($request->boolean('due_soon')) {
$query->dueSoon($request->integer('days', 3));
}
if ($request->boolean('overdue')) {
$query->overdue();
}
$tasks = $query->orderBy('priority', 'desc')->orderBy('created_at')->get();
return TaskResource::collection($tasks);
}
public function store(StoreTaskRequest $request)
{
$task = Task::create($request->validated());
return (new TaskResource($task))
->response()
->setStatusCode(201);
}
public function show(Request $request, Task $task)
{
$task->load(['project', 'comments']);
return new TaskResource($task);
}
public function update(UpdateTaskRequest $request, Task $task)
{
// Handle status transitions
$validated = $request->validated();
// Auto-set timestamps based on status
if (isset($validated['status'])) {
if ($validated['status'] === 'in_progress' && ! $task->started_at) {
$validated['started_at'] = now();
}
if (in_array($validated['status'], ['done', 'completed']) && ! $task->completed_at) {
$validated['completed_at'] = now();
}
if ($validated['status'] !== 'in_progress') {
$validated['started_at'] = null;
}
}
$task->update($validated);
return new TaskResource($task->fresh(['project', 'comments']));
}
public function destroy(Task $task)
{
$task->delete();
return response()->noContent();
}
/**
* Get comments for a task.
*/
public function comments(Request $request, Task $task)
{
$comments = $task->comments()->orderBy('created_at')->get();
return TaskCommentResource::collection($comments);
}
/**
* Change task status.
*/
public function changeStatus(Request $request, Task $task)
{
$request->validate([
'status' => ['required', 'string', 'in:' . implode(',', Task::STATUSES)],
]);
$status = $request->string('status');
$updateData = ['status' => $status];
// Auto-set timestamps
if ($status === 'in_progress' && ! $task->started_at) {
$updateData['started_at'] = now();
}
if (in_array($status, ['done', 'completed']) && ! $task->completed_at) {
$updateData['completed_at'] = now();
}
if ($status !== 'in_progress') {
$updateData['started_at'] = null;
}
$task->update($updateData);
return new TaskResource($task->fresh(['project', 'comments']));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use App\Models\Idea;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreIdeaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'status' => ['required', 'string', Rule::in(Idea::STATUSES)],
'project_id' => ['nullable', 'integer', 'exists:projects,id'],
'priority' => ['integer', 'min:0', 'max:10'],
'category' => ['nullable', 'string', Rule::in(Idea::CATEGORIES)],
'tags' => ['nullable', 'string', 'max:255'],
'user_id' => ['nullable', 'integer', 'exists:users,id'],
];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProjectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'status' => ['required', 'string', Rule::in(Project::STATUSES)],
'due_at' => ['nullable', 'date'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests;
use App\Models\TaskComment;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTaskCommentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'body' => ['required', 'string'],
'author_type' => ['required', 'string', Rule::in(TaskComment::AUTHOR_TYPES)],
'author_name' => ['nullable', 'string', 'max:255'],
];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use App\Models\Task;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'status' => ['required', 'string', Rule::in(Task::STATUSES)],
'project_id' => ['required', 'integer', 'exists:projects,id'],
'priority' => ['integer', 'min:0', 'max:10'],
'due_at' => ['nullable', 'date'],
'estimated_hours' => ['nullable', 'integer', 'min:0'],
];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use App\Models\Idea;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateIdeaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['sometimes', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'status' => ['sometimes', 'string', Rule::in(Idea::STATUSES)],
'project_id' => ['nullable', 'integer', 'exists:projects,id'],
'priority' => ['integer', 'min:0', 'max:10'],
'category' => ['nullable', 'string', Rule::in(Idea::CATEGORIES)],
'tags' => ['nullable', 'string', 'max:255'],
'user_id' => ['nullable', 'integer', 'exists:users,id'],
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProjectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'status' => ['sometimes', 'string', Rule::in(Project::STATUSES)],
'due_at' => ['nullable', 'date'],
];
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Models\Task;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['sometimes', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'status' => ['sometimes', 'string', Rule::in(Task::STATUSES)],
'project_id' => ['sometimes', 'integer', 'exists:projects,id'],
'priority' => ['sometimes', 'integer', 'min:0', 'max:10'],
'due_at' => ['nullable', 'date'],
'started_at' => ['nullable', 'date'],
'completed_at' => ['nullable', 'date'],
'estimated_hours' => ['nullable', 'integer', 'min:0'],
'actual_hours' => ['nullable', 'integer', 'min:0'],
];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IdeaResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'category' => $this->category,
'tags' => $this->tags,
'tags_array' => $this->tags_array,
'project_id' => $this->project_id,
'user_id' => $this->user_id,
'project' => $this->whenLoaded('project', fn () => [
'id' => $this->project->id,
'name' => $this->project->name,
]),
'user' => $this->whenLoaded('user', fn () => [
'id' => $this->user->id,
'name' => $this->user->name,
]),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'status' => $this->status,
'due_at' => $this->due_at?->toIso8601String(),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
'task_count' => $this->whenLoaded('tasks', fn () => $this->tasks->count()),
'active_task_count' => $this->whenLoaded('tasks', fn () => $this->activeTasks->count()),
'completed_task_count' => $this->whenLoaded('tasks', fn () => $this->completedTasks->count()),
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TaskCommentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'body' => $this->body,
'author_type' => $this->author_type,
'author_name' => $this->author_name,
'task_id' => $this->task_id,
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TaskResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'due_at' => $this->due_at?->toIso8601String(),
'started_at' => $this->started_at?->toIso8601String(),
'completed_at' => $this->completed_at?->toIso8601String(),
'estimated_hours' => $this->estimated_hours,
'actual_hours' => $this->actual_hours,
'project_id' => $this->project_id,
'comment_count' => $this->whenLoaded('comments', fn () => $this->comments->count()),
'project' => $this->whenLoaded('project', fn () => [
'id' => $this->project->id,
'name' => $this->project->name,
]),
'comments' => $this->whenLoaded('comments', fn () => TaskCommentResource::collection($this->comments)),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
];
}
}