diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..93cc554
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,75 @@
+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.5"
+ 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
+ config: phpunit.xml
+ - db: PostgreSQL
+ connection: pgsql
+ config: phpunit.pgsql.xml
+
+ 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.5"
+ 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: vendor/bin/pest --configuration=${{ matrix.config }}
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
diff --git a/README.md b/README.md
index 50ce9a4..c6647da 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
+
@@ -21,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
@@ -105,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
```
@@ -130,6 +133,7 @@ php artisan test --env=pgsql
tests/
โโโ Feature/Api/V1/ # AuthTest, TaskTest, ProjectTest
+ โโโ phpunit.pgsql.xml # PostgreSQL test configuration (CI)
## ๐ API Reference
@@ -186,6 +190,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
@@ -207,6 +212,8 @@ 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 |
+| `sort` | `?sort=-due_date,title` | whitelist: title, status, priority, due_date, created_at ยท `-` prefix = descending |
Invalid `status`/`priority` values โ `422`.
@@ -236,6 +243,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..7de0fe5 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,79 @@
"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
+ }
+ },
+ {
+ "name": "sort",
+ "in": "query",
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
"responses": {
"200": {
"description": "Paginated list of the user's trashed tasks",
@@ -825,6 +884,99 @@
}
}
},
+ "401": {
+ "$ref": "#/components/responses/AuthenticationException"
+ },
+ "422": {
+ "$ref": "#/components/responses/ValidationException"
+ }
+ }
+ }
+ },
+ "/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"
}
@@ -955,6 +1107,28 @@
"null"
]
}
+ },
+ {
+ "name": "per_page",
+ "in": "query",
+ "schema": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ {
+ "name": "sort",
+ "in": "query",
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
}
],
"responses": {
@@ -1000,128 +1174,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 +1300,17 @@
],
"properties": {
"data": {
- "$ref": "#/components/schemas/TaskResource"
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/TaskResource"
+ },
+ {
+ "type": "object",
+ "required": [
+ "user"
+ ]
+ }
+ ]
}
},
"required": [
@@ -1324,7 +1390,17 @@
"const": "Task updated successfully"
},
"data": {
- "$ref": "#/components/schemas/TaskResource"
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/TaskResource"
+ },
+ {
+ "type": "object",
+ "required": [
+ "user"
+ ]
+ }
+ ]
}
},
"required": [
@@ -1372,21 +1448,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 +1532,7 @@
]
},
"color": {
- "type": "string"
+ "$ref": "#/components/schemas/ProjectColor"
},
"user": {
"$ref": "#/components/schemas/UserResource"
@@ -1641,16 +1703,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": [
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..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;
@@ -14,6 +16,7 @@
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 +34,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())
+ ->sort($request->input('sort'))
+ ->paginate((int) $request->validated('per_page', 10));
return TaskResource::collection($tasks);
}
@@ -76,6 +67,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 +83,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 +94,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 +111,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);
}
@@ -142,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(),
+ ],
+ ]);
+ }
}
diff --git a/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php b/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php
index b17d166..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;
@@ -30,6 +32,18 @@ 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'],
+ '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.");
+ }
+ }
+ },
+ ],
];
}
}
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..287c41d 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;
@@ -14,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',
@@ -24,6 +34,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 +52,39 @@ 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));
+ }
+
+ /**
+ * 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;
+ }
}
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": {
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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);
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..e83c4b0 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,94 @@
->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);
+});
+
+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);
+});