From 035dfed40206a56693612fa1c4339d8d564009d4 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Sat, 22 Aug 2026 21:39:42 +0330 Subject: [PATCH 01/22] chore: update composer package name and description --- composer.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 36da9d4..c62f73d 100644 --- a/composer.json +++ b/composer.json @@ -1,11 +1,13 @@ { "$schema": "https://getcomposer.org/schema.json", - "name": "laravel/laravel", + "name": "mahdiimax/taskify", "type": "project", - "description": "The skeleton application for the Laravel framework.", + "description": "A clean, RESTful task management API built with Laravel", "keywords": [ "laravel", - "framework" + "api", + "task-management", + "sanctum" ], "license": "MIT", "require": { From 852c2ef6c22ded96edb622a4c292ad6b5e3d44ff Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Sat, 22 Aug 2026 21:42:13 +0330 Subject: [PATCH 02/22] refactor: cast model attributes to enums and extract task filter scope --- app/Models/Project.php | 13 +++++++++++++ app/Models/Task.php | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/app/Models/Project.php b/app/Models/Project.php index cbaffba..9cd57da 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\ProjectColor; use App\Http\Resources\Api\V1\ProjectResource; use Illuminate\Database\Eloquent\Attributes\UseResource; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -22,6 +23,18 @@ class Project extends Model 'user_id', ]; + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'color' => ProjectColor::class, + ]; + } + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/app/Models/Task.php b/app/Models/Task.php index 43c9596..3c52b00 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -2,8 +2,11 @@ namespace App\Models; +use App\Enums\TaskPriority; +use App\Enums\TaskStatus; use App\Http\Resources\Api\V1\TaskResource; use Illuminate\Database\Eloquent\Attributes\UseResource; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -24,6 +27,15 @@ class Task extends Model 'due_date', ]; + protected function casts(): array + { + return [ + 'status' => TaskStatus::class, + 'priority' => TaskPriority::class, + 'due_date' => 'datetime', + ]; + } + public function user(): BelongsTo { return $this->belongsTo(User::class); @@ -33,4 +45,18 @@ public function project(): BelongsTo { return $this->belongsTo(Project::class); } + + public function scopeFilter(Builder $query, array $filters): Builder + { + return $query + ->when($filters['search'] ?? null, function (Builder $query, string $search) { + $query->where(function (Builder $query) use ($search) { + $query->whereLike('title', "%{$search}%") + ->orWhereLike('description', "%{$search}%"); + }); + }) + ->when($filters['status'] ?? null, fn (Builder $query, string $status) => $query->where('status', $status)) + ->when($filters['priority'] ?? null, fn (Builder $query, string $priority) => $query->where('priority', $priority)) + ->when($filters['project_id'] ?? null, fn (Builder $query, int|string $projectId) => $query->where('project_id', $projectId)); + } } From f5eead3a9a06f5765140899f9aacee2ba69fa4a6 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Sat, 22 Aug 2026 21:44:24 +0330 Subject: [PATCH 03/22] fix: return empty 204 responses and support per_page pagination --- .../Controllers/Api/V1/AuthController.php | 2 +- .../Controllers/Api/V1/ProjectController.php | 11 ++---- .../Controllers/Api/V1/TaskController.php | 39 +++++++------------ .../Requests/Api/V1/Task/IndexTaskRequest.php | 1 + 4 files changed, 20 insertions(+), 33 deletions(-) diff --git a/app/Http/Controllers/Api/V1/AuthController.php b/app/Http/Controllers/Api/V1/AuthController.php index d6fa060..a214919 100644 --- a/app/Http/Controllers/Api/V1/AuthController.php +++ b/app/Http/Controllers/Api/V1/AuthController.php @@ -27,7 +27,7 @@ public function register(RegisterRequest $request): JsonResponse $user = User::create([ 'name' => $request->name, 'email' => $request->email, - 'password' => Hash::make($request->password), + 'password' => $request->password, ]); return response()->json([ diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index c823677..63bfcba 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -13,6 +13,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Http\Response as HttpResponse; #[Group('Projects')] class ProjectController extends Controller @@ -87,16 +88,12 @@ public function update(UpdateProjectRequest $request, Project $project): JsonRes /** * Remove the specified resource from storage. */ - #[Response(status: 204, description: 'Project deleted successfully', examples: [[ - 'message' => 'Project deleted successfully', - ]])] - public function destroy(Project $project): JsonResponse + #[Response(status: 204, description: 'Project deleted successfully')] + public function destroy(Project $project): HttpResponse { $project->delete(); - return response()->json([ - 'message' => 'Project deleted successfully', - ], 204); + return response()->noContent(); } /** diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index 615e0e2..023fcc0 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -12,8 +12,8 @@ use Dedoc\Scramble\Attributes\Response; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Http\Response as HttpResponse; #[Group('Tasks')] class TaskController extends Controller @@ -31,21 +31,9 @@ class TaskController extends Controller public function index(IndexTaskRequest $request): AnonymousResourceCollection { $tasks = $request->user()->tasks() - ->when($request->filled('search'), function ($query) use ($request) { - $search = '%'.strtolower($request->input('search')).'%'; - $query->where(function ($query) use ($search) { - $query->whereRaw('LOWER(title) LIKE ?', [$search]) - ->orWhereRaw('LOWER(description) LIKE ?', [$search]); - }); - }) - ->when($request->filled('status'), function ($query) use ($request) { - $query->where('status', $request->input('status')); - }) - ->when($request->filled('priority'), function ($query) use ($request) { - $query->where('priority', $request->input('priority')); - })->when($request->filled('project_id'), function ($query) use ($request) { - $query->where('project_id', $request->input('project_id')); - })->latest()->paginate(10); + ->filter($request->validated()) + ->latest() + ->paginate((int) $request->validated('per_page', 10)); return TaskResource::collection($tasks); } @@ -76,6 +64,7 @@ public function store(StoreTaskRequest $request): JsonResponse public function show(Task $task): TaskResource { $this->authorize('view', $task); + $task->loadMissing('user'); return new TaskResource($task); } @@ -91,6 +80,7 @@ public function update(UpdateTaskRequest $request, Task $task): JsonResponse { $this->authorize('update', $task); $task->update($request->validated()); + $task->loadMissing('user'); return response()->json([ 'message' => 'Task updated successfully', @@ -101,17 +91,13 @@ public function update(UpdateTaskRequest $request, Task $task): JsonResponse /** * Remove the specified task from storage. */ - #[Response(status: 204, description: 'Task deleted successfully', examples: [[ - 'message' => 'Task deleted successfully', - ]])] - public function destroy(Task $task): JsonResponse + #[Response(status: 204, description: 'Task deleted successfully')] + public function destroy(Task $task): HttpResponse { $this->authorize('delete', $task); $task->delete(); - return response()->json([ - 'message' => 'Task deleted successfully', - ], 204); + return response()->noContent(); } /** @@ -122,9 +108,12 @@ public function destroy(Task $task): JsonResponse 'links' => ['first' => null, 'last' => null, 'prev' => null, 'next' => null], 'meta' => ['current_page' => 1, 'from' => 1, 'last_page' => 1, 'path' => 'http://localhost:8000/api/v1/tasks/trashed', 'per_page' => 10, 'to' => 1, 'total' => 1], ]])] - public function trashed(Request $request): AnonymousResourceCollection + public function trashed(IndexTaskRequest $request): AnonymousResourceCollection { - $trashedTasks = $request->user()->tasks()->onlyTrashed()->latest()->paginate(10); + $trashedTasks = $request->user()->tasks() + ->onlyTrashed() + ->latest() + ->paginate((int) $request->validated('per_page', 10)); return TaskResource::collection($trashedTasks); } diff --git a/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php b/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php index b17d166..1aa8190 100644 --- a/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php +++ b/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php @@ -30,6 +30,7 @@ public function rules(): array 'status' => ['nullable', Rule::enum(TaskStatus::class)], 'priority' => ['nullable', Rule::enum(TaskPriority::class)], 'project_id' => ['nullable', Rule::exists('projects', 'id')->where('user_id', $this->user()->id)], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], ]; } } From fd6e1f783d0c644308ed1641a27baca9f45ee88b Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Sat, 22 Aug 2026 21:46:18 +0330 Subject: [PATCH 04/22] test: cover per_page limits, no-content deletes, and enum casts --- tests/Feature/Api/V1/ProjectTest.php | 2 +- tests/Feature/Api/V1/TaskTest.php | 31 ++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/tests/Feature/Api/V1/ProjectTest.php b/tests/Feature/Api/V1/ProjectTest.php index 3549de7..612a4ec 100644 --- a/tests/Feature/Api/V1/ProjectTest.php +++ b/tests/Feature/Api/V1/ProjectTest.php @@ -73,7 +73,7 @@ $project = Project::factory()->create(['user_id' => $user->id]); $this->actingAs($user) ->deleteJson(route('api.v1.projects.destroy', $project)) - ->assertStatus(204); + ->assertNoContent(); $this->assertSoftDeleted('projects', ['id' => $project->id]); $this->actingAs($user) ->postJson(route('api.v1.projects.restore', $project)) diff --git a/tests/Feature/Api/V1/TaskTest.php b/tests/Feature/Api/V1/TaskTest.php index d2e3ef8..748ec63 100644 --- a/tests/Feature/Api/V1/TaskTest.php +++ b/tests/Feature/Api/V1/TaskTest.php @@ -99,7 +99,7 @@ $task = Task::factory()->create(['user_id' => $user->id]); $response = $this->actingAs($user)->deleteJson(route('api.v1.tasks.destroy', ['task' => $task->id])); - $response->assertStatus(204); + $response->assertNoContent(); $this->assertSoftDeleted('tasks', ['id' => $task->id]); $restoreResponse = $this->actingAs($user)->postJson(route('api.v1.tasks.restore', ['task' => $task->id])); @@ -206,7 +206,7 @@ $this->withHeader('Authorization', "Bearer {$token}") ->deleteJson(route('api.v1.tasks.destroy', $task)) - ->assertStatus(204); + ->assertNoContent(); $this->assertSoftDeleted('tasks', ['id' => $task->id]); }); @@ -231,3 +231,30 @@ ->getJson(route('api.v1.tasks.index')) ->assertStatus(401); }); + +test('tasks list supports custom per_page', function () { + $user = User::factory()->create(); + Task::factory(15)->create(['user_id' => $user->id]); + + $this->actingAs($user) + ->getJson(route('api.v1.tasks.index', ['per_page' => 5])) + ->assertOk() + ->assertJsonCount(5, 'data') + ->assertJsonPath('meta.per_page', 5); +}); + +test('per_page above 100 is rejected', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson(route('api.v1.tasks.index', ['per_page' => 101])) + ->assertStatus(422); +}); + +test('task model casts status and priority to enums', function () { + $user = User::factory()->create(); + $task = Task::factory()->create(['user_id' => $user->id]); + + expect($task->status)->toBeInstanceOf(TaskStatus::class) + ->and($task->priority)->toBeInstanceOf(TaskPriority::class); +}); From 38743af41ff8855c8d863408aafb3387c21ad4d4 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Sat, 22 Aug 2026 21:46:43 +0330 Subject: [PATCH 05/22] docs: document per_page parameter and 204 status code --- README.md | 2 + api.json | 263 ++++++++++++++++++++++-------------------------------- 2 files changed, 110 insertions(+), 155 deletions(-) diff --git a/README.md b/README.md index 50ce9a4..f821ac1 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ Tokens are issued by `login`, live **24 hours**, and are revoked by `logout`. | `priority` | `?priority=high` | exact match | | `search` | `?search=buy milk` | case-insensitive partial match on title/description | | `project_id` | `?project_id=3` | exact match (your projects only) | +| `per_page` | `?per_page=25` | page size 1–100, default 10 | Invalid `status`/`priority` values → `422`. @@ -236,6 +237,7 @@ Invalid `status`/`priority` values → `422`. | --- | --- | | `200` | Success | | `201` | Created | +| `204` | Deleted (empty response body) | | `400` | Already authenticated (on login/register) | | `401` | Unauthenticated / expired or revoked token | | `403` | Forbidden (another user's resource) | diff --git a/api.json b/api.json index 641db27..b123d5c 100644 --- a/api.json +++ b/api.json @@ -729,21 +729,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "examples": [ - { - "message": "Project deleted successfully" - } - ], - "properties": { - "message": { - "type": "string", - "const": "Project deleted successfully" - } - }, - "required": [ - "message" - ] + "type": "string" } } } @@ -767,6 +753,69 @@ "tags": [ "Tasks" ], + "parameters": [ + { + "name": "search", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ], + "maxLength": 255 + } + }, + { + "name": "status", + "in": "query", + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/TaskStatus" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "priority", + "in": "query", + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/TaskPriority" + }, + { + "type": "null" + } + ] + } + }, + { + "name": "project_id", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } + } + ], "responses": { "200": { "description": "Paginated list of the user's trashed tasks", @@ -827,6 +876,9 @@ }, "401": { "$ref": "#/components/responses/AuthenticationException" + }, + "422": { + "$ref": "#/components/responses/ValidationException" } } } @@ -955,6 +1007,18 @@ "null" ] } + }, + { + "name": "per_page", + "in": "query", + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "maximum": 100 + } } ], "responses": { @@ -1000,128 +1064,10 @@ "items": { "$ref": "#/components/schemas/TaskResource" } - }, - "links": { - "type": "object", - "properties": { - "first": { - "type": [ - "string", - "null" - ] - }, - "last": { - "type": [ - "string", - "null" - ] - }, - "prev": { - "type": [ - "string", - "null" - ] - }, - "next": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "first", - "last", - "prev", - "next" - ] - }, - "meta": { - "type": "object", - "properties": { - "current_page": { - "type": "integer", - "minimum": 1 - }, - "from": { - "type": [ - "integer", - "null" - ], - "minimum": 1 - }, - "last_page": { - "type": "integer", - "minimum": 1 - }, - "links": { - "type": "array", - "description": "Generated paginator links.", - "items": { - "type": "object", - "properties": { - "url": { - "type": [ - "string", - "null" - ] - }, - "label": { - "type": "string" - }, - "active": { - "type": "boolean" - } - }, - "required": [ - "url", - "label", - "active" - ] - } - }, - "path": { - "type": [ - "string", - "null" - ], - "description": "Base path for paginator generated URLs." - }, - "per_page": { - "type": "integer", - "description": "Number of items shown per page.", - "minimum": 0 - }, - "to": { - "type": [ - "integer", - "null" - ], - "description": "Number of the last item in the slice.", - "minimum": 1 - }, - "total": { - "type": "integer", - "description": "Total number of items being paginated.", - "minimum": 0 - } - }, - "required": [ - "current_page", - "from", - "last_page", - "links", - "path", - "per_page", - "to", - "total" - ] } }, "required": [ - "data", - "links", - "meta" + "data" ] } } @@ -1244,7 +1190,17 @@ ], "properties": { "data": { - "$ref": "#/components/schemas/TaskResource" + "allOf": [ + { + "$ref": "#/components/schemas/TaskResource" + }, + { + "type": "object", + "required": [ + "user" + ] + } + ] } }, "required": [ @@ -1324,7 +1280,17 @@ "const": "Task updated successfully" }, "data": { - "$ref": "#/components/schemas/TaskResource" + "allOf": [ + { + "$ref": "#/components/schemas/TaskResource" + }, + { + "type": "object", + "required": [ + "user" + ] + } + ] } }, "required": [ @@ -1372,21 +1338,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "examples": [ - { - "message": "Task deleted successfully" - } - ], - "properties": { - "message": { - "type": "string", - "const": "Task deleted successfully" - } - }, - "required": [ - "message" - ] + "type": "string" } } } @@ -1470,7 +1422,7 @@ ] }, "color": { - "type": "string" + "$ref": "#/components/schemas/ProjectColor" }, "user": { "$ref": "#/components/schemas/UserResource" @@ -1641,16 +1593,17 @@ ] }, "status": { - "type": "string" + "$ref": "#/components/schemas/TaskStatus" }, "priority": { - "type": "string" + "$ref": "#/components/schemas/TaskPriority" }, "due_date": { "type": [ "string", "null" - ] + ], + "format": "date-time" }, "project_id": { "type": [ From f7f75efa6858396f6ded585003efb21f96739aef Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Mon, 24 Aug 2026 16:32:05 +0330 Subject: [PATCH 06/22] feat: add sortable fields whitelist and sort scope to tasks --- app/Models/Task.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/Models/Task.php b/app/Models/Task.php index 3c52b00..287c41d 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -17,6 +17,13 @@ class Task extends Model { use HasFactory, SoftDeletes; + /** + * Fields that may be used to sort task listings. + * + * @var list + */ + public const SORTABLE_FIELDS = ['title', 'status', 'priority', 'due_date', 'created_at']; + protected $fillable = [ 'title', 'description', @@ -59,4 +66,25 @@ public function scopeFilter(Builder $query, array $filters): Builder ->when($filters['priority'] ?? null, fn (Builder $query, string $priority) => $query->where('priority', $priority)) ->when($filters['project_id'] ?? null, fn (Builder $query, int|string $projectId) => $query->where('project_id', $projectId)); } + + /** + * Apply sorting from a comma-separated sort string ("-" prefix = descending). + * Falls back to newest-first when no sort is given. + */ + public function scopeSort(Builder $query, ?string $sort): Builder + { + $fields = array_filter(array_map('trim', explode(',', (string) $sort))); + if ($fields === []) { + return $query->latest(); + } + foreach ($fields as $field) { + $direction = str_starts_with($field, '-') ? 'desc' : 'asc'; + $column = ltrim($field, '-'); + if (in_array($column, self::SORTABLE_FIELDS, true)) { + $query->orderBy($column, $direction); + } + } + + return $query; + } } From cfa168a751e0dcddddc1bcd191052f556e5eb8c7 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Mon, 24 Aug 2026 16:34:16 +0330 Subject: [PATCH 07/22] feat: validate sort parameter against allowed fields --- app/Http/Requests/Api/V1/Task/IndexTaskRequest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php b/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php index 1aa8190..57386da 100644 --- a/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php +++ b/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php @@ -4,6 +4,8 @@ use App\Enums\TaskPriority; use App\Enums\TaskStatus; +use App\Models\Task; +use Closure; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -31,6 +33,17 @@ public function rules(): array 'priority' => ['nullable', Rule::enum(TaskPriority::class)], 'project_id' => ['nullable', Rule::exists('projects', 'id')->where('user_id', $this->user()->id)], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'sort' => [ + 'nullable', + 'string', + function (string $attribute, mixed $value, Closure $fail) { + foreach (explode(',', (string) $value) as $field) { + if (! in_array(ltrim(trim($field), '-'), Task::SORTABLE_FIELDS, true)) { + $fail("The {$field} sort field is not supported."); + } + } + }, + ], ]; } } From 32228ae82732c755c2cfdcc6dc6f54185e103133 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Mon, 24 Aug 2026 16:34:56 +0330 Subject: [PATCH 08/22] feat: add task statistics endpoint and wire sorting into task listing --- .../Controllers/Api/V1/TaskController.php | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index 023fcc0..8cc6dac 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers\Api\V1; +use App\Enums\TaskPriority; +use App\Enums\TaskStatus; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\Task\IndexTaskRequest; use App\Http\Requests\Api\V1\Task\StoreTaskRequest; @@ -12,6 +14,7 @@ use Dedoc\Scramble\Attributes\Response; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Response as HttpResponse; @@ -32,7 +35,7 @@ public function index(IndexTaskRequest $request): AnonymousResourceCollection { $tasks = $request->user()->tasks() ->filter($request->validated()) - ->latest() + ->sort($request->input('sort')) ->paginate((int) $request->validated('per_page', 10)); return TaskResource::collection($tasks); @@ -131,4 +134,54 @@ public function restore(Task $task): TaskResource return new TaskResource($task); } + + /** + * Aggregate statistics for the authenticated user's tasks. + */ + #[Response(status: 200, description: 'Task statistics', examples: [[ + 'data' => [ + 'total' => 42, + 'by_status' => ['pending' => 10, 'in_progress' => 12, 'done' => 20], + 'by_priority' => ['low' => 8, 'medium' => 14, 'high' => 20], + 'overdue' => 3, + 'due_today' => 2, + 'completed_this_week' => 5, + ], + ]])] + public function stats(Request $request): JsonResponse + { + $user = $request->user(); + $byStatus = $user->tasks() + ->select('status') + ->selectRaw('COUNT(*) AS aggregate') + ->groupBy('status') + ->pluck('aggregate', 'status'); + $byPriority = $user->tasks() + ->select('priority') + ->selectRaw('COUNT(*) AS aggregate') + ->groupBy('priority') + ->pluck('aggregate', 'priority'); + + return response()->json([ + 'data' => [ + 'total' => $user->tasks()->count(), + 'by_status' => collect(TaskStatus::cases()) + ->mapWithKeys(fn (TaskStatus $status) => [$status->value => (int) ($byStatus[$status->value] ?? 0)]), + 'by_priority' => collect(TaskPriority::cases()) + ->mapWithKeys(fn (TaskPriority $priority) => [$priority->value => (int) ($byPriority[$priority->value] ?? 0)]), + 'overdue' => $user->tasks() + ->whereNotNull('due_date') + ->where('due_date', '<', now()) + ->where('status', '!=', TaskStatus::DONE->value) + ->count(), + 'due_today' => $user->tasks() + ->whereBetween('due_date', [now()->startOfDay(), now()->endOfDay()]) + ->count(), + 'completed_this_week' => $user->tasks() + ->where('status', TaskStatus::DONE->value) + ->where('updated_at', '>=', now()->startOfWeek()) + ->count(), + ], + ]); + } } From 9bf2f545b2b151efa115affea39dc7d639011e6e Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Mon, 24 Aug 2026 16:35:27 +0330 Subject: [PATCH 09/22] feat: register stats route --- routes/api.php | 1 + 1 file changed, 1 insertion(+) diff --git a/routes/api.php b/routes/api.php index 49b281b..8f2ff50 100644 --- a/routes/api.php +++ b/routes/api.php @@ -16,6 +16,7 @@ Route::middleware('auth:sanctum')->group(function () { Route::prefix('tasks')->controller(TaskController::class)->name('tasks.')->group(function () { Route::get('trashed', 'trashed')->name('trashed'); + Route::get('stats', 'stats')->name('stats'); Route::post('{task}/restore', 'restore')->withTrashed()->name('restore'); }); Route::apiResource('tasks', TaskController::class); From 56dc485c5c245d92f5235904180ba8f56a9518f9 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Mon, 24 Aug 2026 16:35:51 +0330 Subject: [PATCH 10/22] test: cover task statistics and sorting behavior --- tests/Feature/Api/V1/TaskTest.php | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/Feature/Api/V1/TaskTest.php b/tests/Feature/Api/V1/TaskTest.php index 748ec63..e83c4b0 100644 --- a/tests/Feature/Api/V1/TaskTest.php +++ b/tests/Feature/Api/V1/TaskTest.php @@ -258,3 +258,67 @@ expect($task->status)->toBeInstanceOf(TaskStatus::class) ->and($task->priority)->toBeInstanceOf(TaskPriority::class); }); + +test('user can retrieve their task statistics', function () { + $user = User::factory()->create(); + $other = User::factory()->create(); + + Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::PENDING, 'priority' => TaskPriority::HIGH]); + Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::DONE, 'priority' => TaskPriority::LOW]); + Task::factory()->create(['user_id' => $other->id]); + + $response = $this->actingAs($user)->getJson(route('api.v1.tasks.stats'))->assertOk(); + + expect($response->json('data.total'))->toBe(2) + ->and($response->json('data.by_status.pending'))->toBe(1) + ->and($response->json('data.by_status.in_progress'))->toBe(0) + ->and($response->json('data.by_status.done'))->toBe(1) + ->and($response->json('data.by_priority.high'))->toBe(1) + ->and($response->json('data.by_priority.low'))->toBe(1) + ->and($response->json('data.by_priority.medium'))->toBe(0); +}); + +test('statistics include overdue, due today, and completed this week', function () { + $user = User::factory()->create(); + + Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::PENDING, 'due_date' => now()->subDay()]); + Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::PENDING, 'due_date' => now()->addHours(2)]); + Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::PENDING, 'due_date' => now()->addDays(5)]); + Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::DONE, 'due_date' => now()->subDay()]); + Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::DONE]); + + $response = $this->actingAs($user)->getJson(route('api.v1.tasks.stats'))->assertOk(); + + expect($response->json('data.overdue'))->toBe(1) + ->and($response->json('data.due_today'))->toBe(1) + ->and($response->json('data.completed_this_week'))->toBe(2); +}); + +test('unauthenticated user cannot access statistics', function () { + $this->getJson(route('api.v1.tasks.stats'))->assertStatus(401); +}); + +test('user can sort tasks ascending and descending', function () { + $user = User::factory()->create(); + Task::factory()->create(['user_id' => $user->id, 'title' => 'Alpha']); + Task::factory()->create(['user_id' => $user->id, 'title' => 'Zulu']); + Task::factory()->create(['user_id' => $user->id, 'title' => 'Mike']); + + $this->actingAs($user) + ->getJson(route('api.v1.tasks.index', ['sort' => '-title'])) + ->assertOk() + ->assertJsonPath('data.*.title', ['Zulu', 'Mike', 'Alpha']); + + $this->actingAs($user) + ->getJson(route('api.v1.tasks.index', ['sort' => 'title'])) + ->assertOk() + ->assertJsonPath('data.*.title', ['Alpha', 'Mike', 'Zulu']); +}); + +test('unsupported sort field is rejected', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson(route('api.v1.tasks.index', ['sort' => 'description'])) + ->assertStatus(422); +}); From 60057b08e73755a1206dbd6a2f234d59c8815386 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Mon, 24 Aug 2026 16:36:58 +0330 Subject: [PATCH 11/22] docs: update OpenAPI spec with statistics endpoint and sorting --- api.json | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/api.json b/api.json index b123d5c..7de0fe5 100644 --- a/api.json +++ b/api.json @@ -814,6 +814,16 @@ "minimum": 1, "maximum": 100 } + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ] + } } ], "responses": { @@ -883,6 +893,96 @@ } } }, + "/v1/tasks/stats": { + "get": { + "operationId": "v1.tasks.stats", + "summary": "Aggregate statistics for the authenticated user's tasks", + "tags": [ + "Tasks" + ], + "responses": { + "200": { + "description": "Task statistics", + "content": { + "application/json": { + "schema": { + "type": "object", + "examples": [ + { + "data": { + "total": 42, + "by_status": { + "pending": 10, + "in_progress": 12, + "done": 20 + }, + "by_priority": { + "low": 8, + "medium": 14, + "high": 20 + }, + "overdue": 3, + "due_today": 2, + "completed_this_week": 5 + } + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + }, + "by_status": { + "type": "array", + "items": { + "type": "string" + } + }, + "by_priority": { + "type": "array", + "items": { + "type": "string" + } + }, + "overdue": { + "type": "integer", + "minimum": 0 + }, + "due_today": { + "type": "integer", + "minimum": 0 + }, + "completed_this_week": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "total", + "by_status", + "by_priority", + "overdue", + "due_today", + "completed_this_week" + ] + } + }, + "required": [ + "data" + ] + } + } + } + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + } + } + } + }, "/v1/tasks/{task}/restore": { "post": { "operationId": "v1.tasks.restore", @@ -1019,6 +1119,16 @@ "minimum": 1, "maximum": 100 } + }, + { + "name": "sort", + "in": "query", + "schema": { + "type": [ + "string", + "null" + ] + } } ], "responses": { From 14886b18765ebe8ab6f30f0d6b1b957a67586000 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Mon, 24 Aug 2026 16:37:29 +0330 Subject: [PATCH 12/22] docs: document statistics endpoint and sort parameter --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f821ac1..f86b30c 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ Tokens are issued by `login`, live **24 hours**, and are revoked by `logout`. | PUT/PATCH | `/tasks/{task}` | Update a task | | DELETE | `/tasks/{task}` | Soft delete a task | | GET | `/tasks/trashed` | List soft-deleted tasks | +| GET | `/tasks/stats` | Aggregate statistics for your tasks | | POST | `/tasks/{task}/restore` | Restore a soft-deleted task | #### Task Fields @@ -208,6 +209,7 @@ Tokens are issued by `login`, live **24 hours**, and are revoked by `logout`. | `search` | `?search=buy milk` | case-insensitive partial match on title/description | | `project_id` | `?project_id=3` | exact match (your projects only) | | `per_page` | `?per_page=25` | page size 1–100, default 10 | +| `sort` | `?sort=-due_date,title` | whitelist: title, status, priority, due_date, created_at · `-` prefix = descending | Invalid `status`/`priority` values → `422`. From 071e76f78eedbe4ca9f7b82bd660292d2e755893 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Fri, 28 Aug 2026 17:33:11 +0330 Subject: [PATCH 13/22] chore: add larastan as dev dependency --- AGENTS.md | 10 ++- composer.json | 1 + composer.lock | 197 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 206 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 07492a6..ae038be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ This application is a Laravel application and its main Laravel ecosystems packag - laravel/framework (LARAVEL) - v13 - laravel/prompts (PROMPTS) - v0 - laravel/sanctum (SANCTUM) - v4 +- larastan/larastan (LARASTAN) - v3 - laravel/boost (BOOST) - v2 - laravel/mcp (MCP) - v0 - laravel/pail (PAIL) - v1 @@ -97,7 +98,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - Always use curly braces for control structures, even for single-line bodies. - Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. - Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` -- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Follow existing application Enum naming conventions. - Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. - Use array shape type definitions in PHPDoc blocks. @@ -107,6 +108,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. +=== tests rules === + +# Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. + === laravel/core rules === # Do Things the Laravel Way diff --git a/composer.json b/composer.json index c62f73d..91b2df8 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ }, "require-dev": { "fakerphp/faker": "^1.23", + "larastan/larastan": "^3.10", "laravel/boost": "^2.2", "laravel/pail": "^1.2.5", "laravel/pao": "^1.0.6", diff --git a/composer.lock b/composer.lock index 84ccb20..e9d4828 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "729a78bb11b6350b3fcfcabb236d3bd5", + "content-hash": "4749eaab7635b7251521126062f586f2", "packages": [ { "name": "brick/math", @@ -6700,6 +6700,47 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "iamcal/sql-parser", + "version": "v0.7", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + }, + "time": "2026-01-28T22:20:33+00:00" + }, { "name": "jean85/pretty-package-versions", "version": "2.1.1", @@ -6760,6 +6801,96 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "larastan/larastan", + "version": "v3.10.0", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "2970f83398154178a739609c244577267c7ee8eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb", + "reference": "2970f83398154178a739609c244577267c7ee8eb", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", + "php": "^8.2", + "phpstan/phpstan": "^2.2.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", + "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.10.0" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-05-28T08:00:58+00:00" + }, { "name": "laravel/agent-detector", "version": "v2.0.2", @@ -8209,6 +8340,70 @@ }, "time": "2026-01-06T21:53:42+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.2.9", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/13d6b4f347bad222da436580c8304fa6f83e6bd0", + "reference": "13d6b4f347bad222da436580c8304fa6f83e6bd0", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-08-22T07:38:16+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "14.2.4", From a9f3d300e6eedfe56b940c14416a1f9076dd9711 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Fri, 28 Aug 2026 17:41:54 +0330 Subject: [PATCH 14/22] chore: configure phpstan at level 5 --- .gitignore | 1 + phpstan.neon | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 phpstan.neon diff --git a/.gitignore b/.gitignore index 7be55e2..465c609 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ _ide_helper.php Homestead.json Homestead.yaml Thumbs.db +/build/phpstan \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..5613b4f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + paths: + - app + level: 5 + tmpDir: build/phpstan \ No newline at end of file From 0ba3506a9dd4729083ae055f4a6a1edbb084e97e Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Fri, 28 Aug 2026 18:59:59 +0330 Subject: [PATCH 15/22] Revert "chore: configure phpstan at level 5" This reverts commit a9f3d300e6eedfe56b940c14416a1f9076dd9711. --- .gitignore | 1 - phpstan.neon | 8 -------- 2 files changed, 9 deletions(-) delete mode 100644 phpstan.neon diff --git a/.gitignore b/.gitignore index 465c609..7be55e2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,3 @@ _ide_helper.php Homestead.json Homestead.yaml Thumbs.db -/build/phpstan \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon deleted file mode 100644 index 5613b4f..0000000 --- a/phpstan.neon +++ /dev/null @@ -1,8 +0,0 @@ -includes: - - vendor/larastan/larastan/extension.neon - -parameters: - paths: - - app - level: 5 - tmpDir: build/phpstan \ No newline at end of file From 9896c24c18e9e8f3a462b4f478cd012fd7214e0a Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Fri, 28 Aug 2026 19:00:05 +0330 Subject: [PATCH 16/22] Revert "chore: add larastan as dev dependency" This reverts commit 071e76f78eedbe4ca9f7b82bd660292d2e755893. --- AGENTS.md | 10 +-- composer.json | 1 - composer.lock | 197 +------------------------------------------------- 3 files changed, 2 insertions(+), 206 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae038be..07492a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,6 @@ This application is a Laravel application and its main Laravel ecosystems packag - laravel/framework (LARAVEL) - v13 - laravel/prompts (PROMPTS) - v0 - laravel/sanctum (SANCTUM) - v4 -- larastan/larastan (LARASTAN) - v3 - laravel/boost (BOOST) - v2 - laravel/mcp (MCP) - v0 - laravel/pail (PAIL) - v1 @@ -98,7 +97,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - Always use curly braces for control structures, even for single-line bodies. - Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. - Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` -- Follow existing application Enum naming conventions. +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. - Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. - Use array shape type definitions in PHPDoc blocks. @@ -108,13 +107,6 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. -=== tests rules === - -# Test Enforcement - -- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. -- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. - === laravel/core rules === # Do Things the Laravel Way diff --git a/composer.json b/composer.json index 91b2df8..c62f73d 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,6 @@ }, "require-dev": { "fakerphp/faker": "^1.23", - "larastan/larastan": "^3.10", "laravel/boost": "^2.2", "laravel/pail": "^1.2.5", "laravel/pao": "^1.0.6", diff --git a/composer.lock b/composer.lock index e9d4828..84ccb20 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4749eaab7635b7251521126062f586f2", + "content-hash": "729a78bb11b6350b3fcfcabb236d3bd5", "packages": [ { "name": "brick/math", @@ -6700,47 +6700,6 @@ }, "time": "2025-04-30T06:54:44+00:00" }, - { - "name": "iamcal/sql-parser", - "version": "v0.7", - "source": { - "type": "git", - "url": "https://github.com/iamcal/SQLParser.git", - "reference": "610392f38de49a44dab08dc1659960a29874c4b8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", - "reference": "610392f38de49a44dab08dc1659960a29874c4b8", - "shasum": "" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^1.0", - "phpunit/phpunit": "^5|^6|^7|^8|^9" - }, - "type": "library", - "autoload": { - "psr-4": { - "iamcal\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Cal Henderson", - "email": "cal@iamcal.com" - } - ], - "description": "MySQL schema parser", - "support": { - "issues": "https://github.com/iamcal/SQLParser/issues", - "source": "https://github.com/iamcal/SQLParser/tree/v0.7" - }, - "time": "2026-01-28T22:20:33+00:00" - }, { "name": "jean85/pretty-package-versions", "version": "2.1.1", @@ -6801,96 +6760,6 @@ }, "time": "2025-03-19T14:43:43+00:00" }, - { - "name": "larastan/larastan", - "version": "v3.10.0", - "source": { - "type": "git", - "url": "https://github.com/larastan/larastan.git", - "reference": "2970f83398154178a739609c244577267c7ee8eb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb", - "reference": "2970f83398154178a739609c244577267c7ee8eb", - "shasum": "" - }, - "require": { - "ext-json": "*", - "iamcal/sql-parser": "^0.7.0", - "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", - "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", - "php": "^8.2", - "phpstan/phpstan": "^2.2.0" - }, - "require-dev": { - "doctrine/coding-standard": "^14", - "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", - "mockery/mockery": "^1.6.12", - "nikic/php-parser": "^5.4", - "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", - "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", - "phpstan/phpstan-deprecation-rules": "^2.0.1", - "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8" - }, - "suggest": { - "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", - "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" - }, - "type": "phpstan-extension", - "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-master": "3.0-dev" - } - }, - "autoload": { - "psr-4": { - "Larastan\\Larastan\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Can Vural", - "email": "can9119@gmail.com" - } - ], - "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", - "keywords": [ - "PHPStan", - "code analyse", - "code analysis", - "larastan", - "laravel", - "package", - "php", - "static analysis" - ], - "support": { - "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.10.0" - }, - "funding": [ - { - "url": "https://github.com/canvural", - "type": "github" - } - ], - "time": "2026-05-28T08:00:58+00:00" - }, { "name": "laravel/agent-detector", "version": "v2.0.2", @@ -8340,70 +8209,6 @@ }, "time": "2026-01-06T21:53:42+00:00" }, - { - "name": "phpstan/phpstan", - "version": "2.2.9", - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/13d6b4f347bad222da436580c8304fa6f83e6bd0", - "reference": "13d6b4f347bad222da436580c8304fa6f83e6bd0", - "shasum": "" - }, - "require": { - "php": "^7.4|^8.0" - }, - "conflict": { - "phpstan/phpstan-shim": "*" - }, - "bin": [ - "phpstan", - "phpstan.phar" - ], - "type": "library", - "autoload": { - "files": [ - "bootstrap.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ondřej Mirtes" - }, - { - "name": "Markus Staab" - }, - { - "name": "Vincent Langlet" - } - ], - "description": "PHPStan - PHP Static Analysis Tool", - "keywords": [ - "dev", - "static analysis" - ], - "support": { - "docs": "https://phpstan.org/user-guide/getting-started", - "forum": "https://github.com/phpstan/phpstan/discussions", - "issues": "https://github.com/phpstan/phpstan/issues", - "security": "https://github.com/phpstan/phpstan/security/policy", - "source": "https://github.com/phpstan/phpstan-src" - }, - "funding": [ - { - "url": "https://github.com/ondrejmirtes", - "type": "github" - }, - { - "url": "https://github.com/phpstan", - "type": "github" - } - ], - "time": "2026-08-22T07:38:16+00:00" - }, { "name": "phpunit/php-code-coverage", "version": "14.2.4", From a5d218fe7b7d8382a26e4267e1e81da5321e068b Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Fri, 28 Aug 2026 22:39:48 +0330 Subject: [PATCH 17/22] ci: add GitHub Actions pipeline with Pest Postgres matrix --- .github/workflows/ci.yml | 73 ++++++++++++++++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 74 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dd7c6ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + pint: + name: Code Style + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: mbstring, xml, ctype, json, bcmath, pdo, pgsql + tools: composer:v2 + coverage: none + - uses: actions/cache@v4 + with: + path: vendor + key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} + - run: composer install --no-progress --prefer-dist + - run: vendor/bin/pint --test + + test: + name: Tests (${{ matrix.db }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - db: SQLite + connection: sqlite + - db: PostgreSQL + connection: pgsql + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: testing + POSTGRES_USER: root + POSTGRES_PASSWORD: password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DB_CONNECTION: ${{ matrix.connection }} + + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: mbstring, xml, ctype, json, bcmath, pdo, pgsql, sqlite3 + tools: composer:v2 + coverage: none + - uses: actions/cache@v4 + with: + path: vendor + key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} + - run: composer install --no-progress --prefer-dist + - run: cp .env.example .env + - run: php artisan key:generate + - run: php artisan test diff --git a/README.md b/README.md index f86b30c..285355e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Sanctum API v1 MIT + CI

From 37f4f2c329f57e5676f96ae50a9932971a3f3500 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Fri, 28 Aug 2026 22:49:02 +0330 Subject: [PATCH 18/22] ci: run workflows on PHP 8.5 to match locked dependencies --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd7c6ce..d810ad0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: "8.3" + php-version: "8.5" extensions: mbstring, xml, ctype, json, bcmath, pdo, pgsql tools: composer:v2 coverage: none @@ -59,7 +59,7 @@ jobs: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: "8.3" + php-version: "8.5" extensions: mbstring, xml, ctype, json, bcmath, pdo, pgsql, sqlite3 tools: composer:v2 coverage: none From 03e10be5ed2ca49a2dec4508e32f193c52e9f1ca Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Fri, 28 Aug 2026 23:10:02 +0330 Subject: [PATCH 19/22] ci: run tests against PostgreSQL via per-matrix PHPUnit config --- .github/workflows/ci.yml | 4 +++- phpunit.pgsql.xml | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 phpunit.pgsql.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d810ad0..2a41eba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,10 @@ jobs: include: - db: SQLite connection: sqlite + config: phpunit.xml - db: PostgreSQL connection: pgsql + config: phpunit.pgsql.xml services: postgres: @@ -70,4 +72,4 @@ jobs: - run: composer install --no-progress --prefer-dist - run: cp .env.example .env - run: php artisan key:generate - - run: php artisan test + - run: php artisan test --configuration=${{ matrix.config }} diff --git a/phpunit.pgsql.xml b/phpunit.pgsql.xml new file mode 100644 index 0000000..1257788 --- /dev/null +++ b/phpunit.pgsql.xml @@ -0,0 +1,40 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + + + + + From 200d3097795b338b0620528fb6d675d37097f410 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Fri, 28 Aug 2026 23:17:56 +0330 Subject: [PATCH 20/22] ci: run pest directly to avoid duplicate configuration flag --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a41eba..93cc554 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,4 +72,4 @@ jobs: - run: composer install --no-progress --prefer-dist - run: cp .env.example .env - run: php artisan key:generate - - run: php artisan test --configuration=${{ matrix.config }} + - run: vendor/bin/pest --configuration=${{ matrix.config }} From 48192b1729f9c86b68d070b346caa821f85c908f Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Sat, 29 Aug 2026 22:07:00 +0330 Subject: [PATCH 21/22] docs: add changelog --- CHANGELOG.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..70092e9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/), +and this project adheres to [Semantic Versioning](https://semver.org/). + +## [1.2.0] - 2026-08-28 + +### Added + +- `GET /tasks/stats` endpoint with aggregate counts by status/priority, + overdue, due-today, and completed-this-week +- Sorting support on the task listing via a whitelisted `?sort=` parameter + (`-` prefix for descending order) +- `per_page` pagination control (1–100, default 10) +- Continuous integration pipeline (Pint + Pest suite, including a PostgreSQL + matrix) via GitHub Actions +- CI status badge in the README +- Project `color` cast to the `ProjectColor` enum + +### Changed + +- PHPUnit config split into `phpunit.xml` (SQLite) and `phpunit.pgsql.xml` + (PostgreSQL) for the driver matrix +- Composer package renamed from `laravel/laravel` to `mahdiimax/taskify` +- Task `status`, `priority`, and `due_date` cast to enums / datetime +- Task filtering extracted into a reusable `filter()` query scope using + driver-aware `whereLike()` + +### Fixed + +- Delete endpoints return a proper empty `204 No Content` response +- Redundant manual `Hash::make()` removed in favor of the `hashed` cast +- Eager loading added to prevent N+1 queries on task resources + +## [1.1.0] - 2026-08-14 + +### Added + +- Projects: full CRUD with soft delete & restore +- Color-coded projects via the `ProjectColor` enum +- `project_id` on tasks, with filtering by project scoped to the owner +- Exposed `project_id` in the task resource + +## [1.0.0] - 2026-08-06 + +### Added + +- Sanctum token authentication (24h expiry, logout revocation) +- Full task CRUD with soft delete & restore (`trashed` / `restore`) +- Task filtering by `status` / `priority` and case-insensitive `search` +- Per-user task isolation via policies +- Rate limiting (auth 5/min, API 60/min) +- Interactive OpenAPI docs via Scramble +- Pest feature tests: auth, revocation, token expiry, filtering, rate limits +- PostgreSQL + Docker / pgAdmin setup + +[Unreleased]: https://github.com/MahdiiMax/taskify/compare/1.1.0...develop +[1.2.0]: https://github.com/MahdiiMax/taskify/compare/1.1.0...1.2.0 +[1.1.0]: https://github.com/MahdiiMax/taskify/compare/1.0.0...1.1.0 +[1.0.0]: https://github.com/MahdiiMax/taskify/releases/tag/1.0.0 From 1f1d1fad4197a66beb1609b8aa011c35a0c81e04 Mon Sep 17 00:00:00 2001 From: MahdiiMax Date: Sat, 29 Aug 2026 22:08:38 +0330 Subject: [PATCH 22/22] docs: refresh README for v1.2.0 features and testing --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 285355e..c6647da 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,12 @@ - 🔐 **Sanctum token auth** — 24-hour token expiry, revocable via logout - 📝 **Full CRUD** with soft delete & restore -- 🔍 **Smart filtering** — exact `status`/`priority` filters + case-insensitive search +- 🔍 **Smart filtering** — exact `status`/`priority`/`project_id` filters + case-insensitive search, plus `?sort=` ordering and `?per_page=` pagination +- 📊 **Statistics** — `GET /tasks/stats` aggregates by status/priority, overdue, due-today, and completed-this-week - 👤 **Per-user isolation** — tasks and projects are private to their owner - 📁 **Projects** — organize tasks into color-coded projects with full CRUD, soft delete & restore - 🚦 **Rate limiting** — auth (5/min) & API (60/min) +- ✅ **CI pipeline** — Pint style checks + Pest test suite across SQLite & PostgreSQL - 📚 **Interactive docs** via Scramble - 🗃️ **PostgreSQL** + Docker setup included @@ -106,10 +108,10 @@ composer test # or: php artisan test The suite runs on in-memory SQLite — no DB server needed. ⚠️ *SQLite is more lenient than PostgreSQL; run the suite against real Postgres before release to catch driver-specific issues.* -If you want to run tests in your own PostgreSQL server: +Run the suite against a real PostgreSQL server (adjust the credentials in `phpunit.pgsql.xml` to match your database): ```bash -php artisan test --env=pgsql +vendor/bin/pest --configuration=phpunit.pgsql.xml ``` @@ -131,6 +133,7 @@ php artisan test --env=pgsql tests/ ├── Feature/Api/V1/ # AuthTest, TaskTest, ProjectTest + └── phpunit.pgsql.xml # PostgreSQL test configuration (CI) ## 📜 API Reference