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 @@ Sanctum API v1 MIT + CI

@@ -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); +});