From 08fe3877e4d40b354e734a81be814540b63bd59d Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 21:01:42 +0200 Subject: [PATCH 1/3] feat: add the request context, ability check and error mapping CompanyContext carries the company from the `company` header and the authenticated user, so no controller reads tenancy from a parameter and no host user model is imported. Authorizes namespaces a bare ability name the way the registry stores it and turns a refusal into the framework's own AuthorizationException, which renders 403. Every domain rule the services refuse to break already extends TasksProjectsException, so one renderable registered by the provider maps all of them: a running timer is a 409 conflict because the first timer is still valid, everything else is a 422 carrying a stable snake_case key the UI can switch on. --- app/Http/DomainExceptionRenderer.php | 47 +++++++++++++++++++ .../TasksProjectsServiceProvider.php | 20 ++++++++ app/Support/Authorizes.php | 41 ++++++++++++++++ app/Support/CompanyContext.php | 28 +++++++++++ 4 files changed, 136 insertions(+) create mode 100644 app/Http/DomainExceptionRenderer.php create mode 100644 app/Support/Authorizes.php create mode 100644 app/Support/CompanyContext.php diff --git a/app/Http/DomainExceptionRenderer.php b/app/Http/DomainExceptionRenderer.php new file mode 100644 index 0000000..21972d3 --- /dev/null +++ b/app/Http/DomainExceptionRenderer.php @@ -0,0 +1,47 @@ + Response::HTTP_CONFLICT, + ]; + + public static function register(Handler $handler): void + { + $handler->renderable(static fn (TasksProjectsException $exception): JsonResponse => self::render($exception)); + } + + public static function render(TasksProjectsException $exception): JsonResponse + { + return new JsonResponse([ + 'message' => $exception->getMessage(), + 'error' => self::errorKey($exception), + ], self::STATUSES[$exception::class] ?? Response::HTTP_UNPROCESSABLE_ENTITY); + } + + /** `TimerAlreadyRunning` becomes `timer_already_running`. */ + public static function errorKey(TasksProjectsException $exception): string + { + return Str::snake(class_basename($exception)); + } +} diff --git a/app/Providers/TasksProjectsServiceProvider.php b/app/Providers/TasksProjectsServiceProvider.php index 638caf4..9474d4e 100644 --- a/app/Providers/TasksProjectsServiceProvider.php +++ b/app/Providers/TasksProjectsServiceProvider.php @@ -4,10 +4,13 @@ namespace Modules\TasksProjects\Providers; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Foundation\Application; +use Illuminate\Foundation\Exceptions\Handler; use InvoiceShelf\Modules\Contracts\DataCleanup; use InvoiceShelf\Modules\Contracts\Host\SettingsStore; use InvoiceShelf\Modules\Support\ModuleServiceProvider; +use Modules\TasksProjects\Http\DomainExceptionRenderer; use Modules\TasksProjects\Lifecycle\DataCleanup as TasksProjectsDataCleanup; use Modules\TasksProjects\Support\ModuleRegistration; @@ -37,6 +40,23 @@ public function boot(): void ModuleRegistration::register($modulePath); + $this->registerExceptionRendering(); + $this->loadRoutesFrom($modulePath.'/routes/api.php'); } + + /** + * Map every broken domain rule to its HTTP response in one place. + * + * The services throw a TasksProjectsException rather than returning an + * error, so a single renderable keeps the controllers free of try/catch. + */ + private function registerExceptionRendering(): void + { + $handler = $this->app->make(ExceptionHandler::class); + + if ($handler instanceof Handler) { + DomainExceptionRenderer::register($handler); + } + } } diff --git a/app/Support/Authorizes.php b/app/Support/Authorizes.php new file mode 100644 index 0000000..0c5d52d --- /dev/null +++ b/app/Support/Authorizes.php @@ -0,0 +1,41 @@ +authorization->allows($context->userId, $context->companyId, self::id($ability)); + } + + /** @throws AuthorizationException when the user lacks the ability in this company */ + public function require(CompanyContext $context, string $ability): void + { + if (! $this->allows($context, $ability)) { + throw new AuthorizationException('This action requires the '.self::id($ability).' ability.'); + } + } +} diff --git a/app/Support/CompanyContext.php b/app/Support/CompanyContext.php new file mode 100644 index 0000000..81b127f --- /dev/null +++ b/app/Support/CompanyContext.php @@ -0,0 +1,28 @@ +header('company'), + (int) $request->user()->getAuthIdentifier(), + ); + } +} From d58973c22c97d7c32564fda6ea6b9ba327a04350 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 21:01:54 +0200 Subject: [PATCH 2/3] feat: add the module's HTTP API Thirty-seven routes under `api/v1/tasks-projects`, all behind the host stack, all named `tasks-projects..`: projects with their members, tasks with the board and its columns, time entries and the running timer, the two-step billing round trip, the reporting summary and the settings the UI reads. Controllers are thin by construction. They take the company and the user from the request, check the one ability the action needs, hand a validated payload to a service and return a resource; every lookup goes through a service's findForCompany, so a row from another company is a 404 rather than a leak, and no action carries a query of its own. Lists come back paged through `?limit=`, defaulted to fifteen and clamped at a hundred, cut from the collection the service returns rather than by a second query. The board and the column list are not paged: they are the board's own shape, and the editor reorders all of it at once. Two filters the API promises were missing from the services and are added here: a text search over a project's name and identifier, and the task list's due-date range. Money stays in integer minor units everywhere. The prepared invoice keeps its zero fractions on the way out, so `quantity` remains the two-decimal number of hours the preview showed. --- app/Application/ProjectService.php | 11 +- app/Application/TaskService.php | 10 +- app/Http/Controllers/BillingController.php | 85 ++++++++++++ app/Http/Controllers/BoardController.php | 52 ++++++++ app/Http/Controllers/Controller.php | 94 +++++++++++++ app/Http/Controllers/MembersController.php | 33 +++++ .../Controllers/ProjectMembersController.php | 61 +++++++++ app/Http/Controllers/ProjectsController.php | 95 ++++++++++++++ app/Http/Controllers/ReportsController.php | 48 +++++++ app/Http/Controllers/SettingsController.php | 40 ++++++ .../Controllers/TaskStatusesController.php | 78 +++++++++++ app/Http/Controllers/TasksController.php | 98 ++++++++++++++ .../Controllers/TimeEntriesController.php | 124 ++++++++++++++++++ app/Http/Controllers/TimerController.php | 74 +++++++++++ .../Requests/AttachProjectMemberRequest.php | 48 +++++++ app/Http/Requests/BoardRequest.php | 17 +++ app/Http/Requests/ConfirmInvoiceRequest.php | 27 ++++ app/Http/Requests/ListProjectsRequest.php | 21 +++ app/Http/Requests/ListTasksRequest.php | 22 ++++ app/Http/Requests/ListTimeEntriesRequest.php | 22 ++++ app/Http/Requests/ModuleRequest.php | 23 ++++ app/Http/Requests/MoveTaskRequest.php | 39 ++++++ app/Http/Requests/PrepareInvoiceRequest.php | 20 +++ .../Requests/ReorderTaskStatusesRequest.php | 17 +++ app/Http/Requests/ReportSummaryRequest.php | 17 +++ app/Http/Requests/StartTimerRequest.php | 17 +++ app/Http/Requests/StoreProjectRequest.php | 27 ++++ app/Http/Requests/StoreTaskRequest.php | 28 ++++ app/Http/Requests/StoreTaskStatusRequest.php | 20 +++ app/Http/Requests/StoreTimeEntryRequest.php | 23 ++++ app/Http/Requests/UnbilledTimeRequest.php | 18 +++ app/Http/Requests/UpdateProjectRequest.php | 27 ++++ app/Http/Requests/UpdateTaskRequest.php | 28 ++++ app/Http/Requests/UpdateTaskStatusRequest.php | 20 +++ app/Http/Requests/UpdateTimeEntryRequest.php | 22 ++++ app/Http/Resources/ProjectMemberResource.php | 34 +++++ app/Http/Resources/ProjectResource.php | 64 +++++++++ app/Http/Resources/TaskResource.php | 47 +++++++ app/Http/Resources/TaskStatusResource.php | 35 +++++ app/Http/Resources/TimeEntryResource.php | 46 +++++++ routes/api.php | 67 +++++++++- 41 files changed, 1696 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/BillingController.php create mode 100644 app/Http/Controllers/BoardController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/MembersController.php create mode 100644 app/Http/Controllers/ProjectMembersController.php create mode 100644 app/Http/Controllers/ProjectsController.php create mode 100644 app/Http/Controllers/ReportsController.php create mode 100644 app/Http/Controllers/SettingsController.php create mode 100644 app/Http/Controllers/TaskStatusesController.php create mode 100644 app/Http/Controllers/TasksController.php create mode 100644 app/Http/Controllers/TimeEntriesController.php create mode 100644 app/Http/Controllers/TimerController.php create mode 100644 app/Http/Requests/AttachProjectMemberRequest.php create mode 100644 app/Http/Requests/BoardRequest.php create mode 100644 app/Http/Requests/ConfirmInvoiceRequest.php create mode 100644 app/Http/Requests/ListProjectsRequest.php create mode 100644 app/Http/Requests/ListTasksRequest.php create mode 100644 app/Http/Requests/ListTimeEntriesRequest.php create mode 100644 app/Http/Requests/ModuleRequest.php create mode 100644 app/Http/Requests/MoveTaskRequest.php create mode 100644 app/Http/Requests/PrepareInvoiceRequest.php create mode 100644 app/Http/Requests/ReorderTaskStatusesRequest.php create mode 100644 app/Http/Requests/ReportSummaryRequest.php create mode 100644 app/Http/Requests/StartTimerRequest.php create mode 100644 app/Http/Requests/StoreProjectRequest.php create mode 100644 app/Http/Requests/StoreTaskRequest.php create mode 100644 app/Http/Requests/StoreTaskStatusRequest.php create mode 100644 app/Http/Requests/StoreTimeEntryRequest.php create mode 100644 app/Http/Requests/UnbilledTimeRequest.php create mode 100644 app/Http/Requests/UpdateProjectRequest.php create mode 100644 app/Http/Requests/UpdateTaskRequest.php create mode 100644 app/Http/Requests/UpdateTaskStatusRequest.php create mode 100644 app/Http/Requests/UpdateTimeEntryRequest.php create mode 100644 app/Http/Resources/ProjectMemberResource.php create mode 100644 app/Http/Resources/ProjectResource.php create mode 100644 app/Http/Resources/TaskResource.php create mode 100644 app/Http/Resources/TaskStatusResource.php create mode 100644 app/Http/Resources/TimeEntryResource.php diff --git a/app/Application/ProjectService.php b/app/Application/ProjectService.php index cdfaf72..b15de48 100644 --- a/app/Application/ProjectService.php +++ b/app/Application/ProjectService.php @@ -4,6 +4,7 @@ namespace Modules\TasksProjects\Application; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\DB; @@ -23,7 +24,7 @@ final class ProjectService ]; /** - * @param array{status?: string, customer_id?: int, user_id?: int} $filters + * @param array{status?: string, customer_id?: int, user_id?: int, search?: string} $filters * @return Collection */ public function listFor(int $companyId, array $filters = []): Collection @@ -45,6 +46,14 @@ public function listFor(int $companyId, array $filters = []): Collection ->select('project_id')); } + if (isset($filters['search']) && $filters['search'] !== '') { + $search = '%'.$filters['search'].'%'; + + $query->where(static function (Builder $inner) use ($search): void { + $inner->where('name', 'like', $search)->orWhere('identifier', 'like', $search); + }); + } + return $query->orderBy('name')->orderBy('id')->get(); } diff --git a/app/Application/TaskService.php b/app/Application/TaskService.php index 3b08c7c..04c9831 100644 --- a/app/Application/TaskService.php +++ b/app/Application/TaskService.php @@ -35,7 +35,7 @@ public function __construct( ) {} /** - * @param array{project_id?: int, assignee_id?: int, task_status_id?: int, customer_id?: int, search?: string} $filters + * @param array{project_id?: int, assignee_id?: int, task_status_id?: int, customer_id?: int, due_before?: string, due_after?: string, search?: string} $filters * @return Collection */ public function listFor(int $companyId, array $filters = []): Collection @@ -48,6 +48,14 @@ public function listFor(int $companyId, array $filters = []): Collection } } + if (isset($filters['due_after'])) { + $query->where('due_date', '>=', Carbon::parse($filters['due_after'])->toDateString()); + } + + if (isset($filters['due_before'])) { + $query->where('due_date', '<=', Carbon::parse($filters['due_before'])->toDateString()); + } + if (isset($filters['search']) && $filters['search'] !== '') { $query->where('name', 'like', '%'.$filters['search'].'%'); } diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php new file mode 100644 index 0000000..5d81963 --- /dev/null +++ b/app/Http/Controllers/BillingController.php @@ -0,0 +1,85 @@ +context($request); + $this->authorize($context, Abilities::INVOICE_TASKS); + + $filters = $request->validated(); + + return response()->json(['data' => $this->billing->unbilled( + $context->companyId, + (int) $filters['customer_id'], + $filters['from'] ?? null, + $filters['to'] ?? null, + )]); + } + + /** + * The body the browser posts to the host invoice endpoint. + * + * Zero fractions are preserved so `quantity` stays the two-decimal number + * of hours the preview showed, rather than collapsing to an integer on the + * way out. + */ + public function prepare(PrepareInvoiceRequest $request): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::INVOICE_TASKS); + + $validated = $request->validated(); + + $payload = $this->billing->prepare( + $context->companyId, + array_map(intval(...), $validated['entry_ids']), + $validated['grouping'], + ); + + return response()->json(['data' => $payload], 200, [], JSON_PRESERVE_ZERO_FRACTION); + } + + /** Stamp the entries with the invoice and line ids the host handed back. */ + public function confirm(ConfirmInvoiceRequest $request): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::INVOICE_TASKS); + + $validated = $request->validated(); + + $items = array_map(static fn (array $item): array => [ + 'invoice_item_id' => (int) $item['invoice_item_id'], + 'entry_ids' => array_map(intval(...), $item['entry_ids']), + ], $validated['items']); + + return response()->json([ + 'stamped' => $this->billing->confirm($context->companyId, (int) $validated['invoice_id'], $items), + ]); + } +} diff --git a/app/Http/Controllers/BoardController.php b/app/Http/Controllers/BoardController.php new file mode 100644 index 0000000..8be6b55 --- /dev/null +++ b/app/Http/Controllers/BoardController.php @@ -0,0 +1,52 @@ +context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + $this->statuses->ensureDefaults($context->companyId); + + $filters = $request->validated(); + $columns = $this->board->columns( + $context->companyId, + isset($filters['project_id']) ? (int) $filters['project_id'] : null, + isset($filters['assignee_id']) ? (int) $filters['assignee_id'] : null, + ); + + return response()->json(['data' => array_map(static fn (array $column): array => [ + 'status' => TaskStatusResource::make($column['status'])->resolve($request), + 'tasks' => TaskResource::collection($column['tasks'])->resolve($request), + ], $columns)]); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..b1a7966 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,94 @@ +authorizes->require($context, $ability); + } + + protected function allows(CompanyContext $context, string $ability): bool + { + return $this->authorizes->allows($context, $ability); + } + + /** + * Whether the caller sees other members' time: either the ability, or the + * company setting that opens the timesheet to everyone. + */ + protected function canSeeAllTime(CompanyContext $context, ModuleSettings $settings): bool + { + return $this->allows($context, Abilities::VIEW_ALL_TIME) + || $settings->membersSeeAllTime($context->companyId); + } + + /** `?limit=`, defaulted and clamped rather than rejected. */ + protected function limit(Request $request): int + { + $limit = $request->integer('limit'); + + return $limit <= 0 ? self::DEFAULT_LIMIT : min($limit, self::MAX_LIMIT); + } + + /** + * Page an already-loaded collection. + * + * The services own every query and hand back whole collections, so the page + * is cut here rather than in a second query. The lists this module serves + * are one company's projects, tasks and time entries, which is the scale + * that fits in memory comfortably. + * + * @template TValue + * + * @param Collection $items + * @return LengthAwarePaginator + */ + protected function paginate(Collection $items, Request $request): LengthAwarePaginator + { + $perPage = $this->limit($request); + $page = LengthAwarePaginator::resolveCurrentPage(); + + return new LengthAwarePaginator( + $items->forPage($page, $perPage)->values(), + $items->count(), + $perPage, + $page, + [ + 'path' => LengthAwarePaginator::resolveCurrentPath(), + 'query' => $request->query(), + ], + ); + } +} diff --git a/app/Http/Controllers/MembersController.php b/app/Http/Controllers/MembersController.php new file mode 100644 index 0000000..36a589d --- /dev/null +++ b/app/Http/Controllers/MembersController.php @@ -0,0 +1,33 @@ +context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + return response()->json(['data' => $this->companyData->companyMembers($context->companyId)]); + } +} diff --git a/app/Http/Controllers/ProjectMembersController.php b/app/Http/Controllers/ProjectMembersController.php new file mode 100644 index 0000000..395cdec --- /dev/null +++ b/app/Http/Controllers/ProjectMembersController.php @@ -0,0 +1,61 @@ +context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + return ProjectMemberResource::collection($this->members->listFor($context->companyId, $id)); + } + + public function store(AttachProjectMemberRequest $request, int $id): ProjectMemberResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + $validated = $request->validated(); + + return new ProjectMemberResource($this->members->attach( + $context->companyId, + $id, + (int) $validated['user_id'], + isset($validated['rate']) ? (int) $validated['rate'] : null, + )); + } + + public function destroy(Request $request, int $id, int $userId): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + $this->members->detach($context->companyId, $id, $userId); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/ProjectsController.php b/app/Http/Controllers/ProjectsController.php new file mode 100644 index 0000000..e9ebe91 --- /dev/null +++ b/app/Http/Controllers/ProjectsController.php @@ -0,0 +1,95 @@ +context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + $filters = $request->validated(); + $projects = $this->projects->listFor($context->companyId, array_filter([ + 'status' => $filters['status'] ?? null, + 'customer_id' => isset($filters['customer_id']) ? (int) $filters['customer_id'] : null, + 'user_id' => isset($filters['member_id']) ? (int) $filters['member_id'] : null, + 'search' => $filters['search'] ?? null, + ], static fn (mixed $value): bool => $value !== null)); + + return ProjectResource::collection($this->paginate($projects, $request)); + } + + public function store(StoreProjectRequest $request): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::CREATE_PROJECT); + + return new ProjectResource($this->projects->create( + $context->companyId, + $request->validated() + ['creator_id' => $context->userId], + )); + } + + public function show(Request $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + $project = $this->projects->findForCompany($context->companyId, $id); + + return (new ProjectResource($project))->withTotals($this->projects->totals($project)); + } + + public function update(UpdateProjectRequest $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + return new ProjectResource($this->projects->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::DELETE_PROJECT); + + $this->projects->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + public function archive(Request $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + return new ProjectResource($this->projects->archive($context->companyId, $id)); + } + + public function unarchive(Request $request, int $id): ProjectResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_PROJECT); + + return new ProjectResource($this->projects->unarchive($context->companyId, $id)); + } +} diff --git a/app/Http/Controllers/ReportsController.php b/app/Http/Controllers/ReportsController.php new file mode 100644 index 0000000..46a7c4f --- /dev/null +++ b/app/Http/Controllers/ReportsController.php @@ -0,0 +1,48 @@ +context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $filters = $request->validated(); + $from = isset($filters['from']) ? Carbon::parse($filters['from']) : Carbon::now()->startOfMonth(); + $to = isset($filters['to']) ? Carbon::parse($filters['to']) : Carbon::now(); + + return response()->json(['data' => $this->reports->summary( + $context->companyId, + $from->toDateString(), + $to->toDateString(), + $context->userId, + $this->canSeeAllTime($context, $this->settings), + )]); + } +} diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php new file mode 100644 index 0000000..637ad53 --- /dev/null +++ b/app/Http/Controllers/SettingsController.php @@ -0,0 +1,40 @@ +context($request); + $this->authorize($context, Abilities::VIEW_PROJECT); + + return response()->json(['data' => [ + 'default_rate' => $this->settings->defaultRate($context->companyId), + 'rounding_minutes' => $this->settings->roundingMinutes($context->companyId), + 'week_start' => $this->settings->weekStart($context->companyId), + 'members_see_all_time' => $this->settings->membersSeeAllTime($context->companyId), + 'rounding_increments' => ModuleSettings::ROUNDING_INCREMENTS, + ]]); + } +} diff --git a/app/Http/Controllers/TaskStatusesController.php b/app/Http/Controllers/TaskStatusesController.php new file mode 100644 index 0000000..24e22ae --- /dev/null +++ b/app/Http/Controllers/TaskStatusesController.php @@ -0,0 +1,78 @@ +context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + $this->statuses->ensureDefaults($context->companyId); + + return TaskStatusResource::collection($this->statuses->listFor($context->companyId)); + } + + public function store(StoreTaskStatusRequest $request): TaskStatusResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + return new TaskStatusResource($this->statuses->create($context->companyId, $request->validated())); + } + + public function update(UpdateTaskStatusRequest $request, int $id): TaskStatusResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + return new TaskStatusResource($this->statuses->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + $this->statuses->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + /** Apply the wanted column order; anything left out follows the listed ids. */ + public function reorder(ReorderTaskStatusesRequest $request): AnonymousResourceCollection + { + $context = $this->context($request); + $this->authorize($context, Abilities::MANAGE_TASK_STATUS); + + $ids = array_map(intval(...), $request->validated()['ids']); + $this->statuses->reorder($context->companyId, $ids); + + return TaskStatusResource::collection($this->statuses->listFor($context->companyId)); + } +} diff --git a/app/Http/Controllers/TasksController.php b/app/Http/Controllers/TasksController.php new file mode 100644 index 0000000..bdc5014 --- /dev/null +++ b/app/Http/Controllers/TasksController.php @@ -0,0 +1,98 @@ +context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + $filters = $request->validated(); + $tasks = $this->tasks->listFor($context->companyId, array_filter([ + 'project_id' => isset($filters['project_id']) ? (int) $filters['project_id'] : null, + 'assignee_id' => isset($filters['assignee_id']) ? (int) $filters['assignee_id'] : null, + 'task_status_id' => isset($filters['task_status_id']) ? (int) $filters['task_status_id'] : null, + 'customer_id' => isset($filters['customer_id']) ? (int) $filters['customer_id'] : null, + 'due_before' => $filters['due_before'] ?? null, + 'due_after' => $filters['due_after'] ?? null, + 'search' => $filters['search'] ?? null, + ], static fn (mixed $value): bool => $value !== null)); + + return TaskResource::collection($this->paginate($tasks, $request)); + } + + public function store(StoreTaskRequest $request): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::CREATE_TASK); + + return new TaskResource($this->tasks->create( + $context->companyId, + $request->validated() + ['creator_id' => $context->userId], + )); + } + + public function show(Request $request, int $id): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_TASK); + + return new TaskResource($this->tasks->findForCompany($context->companyId, $id)); + } + + public function update(UpdateTaskRequest $request, int $id): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_TASK); + + return new TaskResource($this->tasks->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::DELETE_TASK); + + $this->tasks->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + /** Drop a task between two neighbours of the target column. */ + public function move(MoveTaskRequest $request, int $id): TaskResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::EDIT_TASK); + + $validated = $request->validated(); + + return new TaskResource($this->tasks->move( + $context->companyId, + $id, + (int) $validated['task_status_id'], + isset($validated['before_id']) ? (int) $validated['before_id'] : null, + isset($validated['after_id']) ? (int) $validated['after_id'] : null, + )); + } +} diff --git a/app/Http/Controllers/TimeEntriesController.php b/app/Http/Controllers/TimeEntriesController.php new file mode 100644 index 0000000..a16048b --- /dev/null +++ b/app/Http/Controllers/TimeEntriesController.php @@ -0,0 +1,124 @@ +context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $filters = $request->validated(); + $entries = $this->entries->listFor( + $context->companyId, + array_filter([ + 'user_id' => isset($filters['user_id']) ? (int) $filters['user_id'] : null, + 'project_id' => isset($filters['project_id']) ? (int) $filters['project_id'] : null, + 'task_id' => isset($filters['task_id']) ? (int) $filters['task_id'] : null, + 'from' => $filters['from'] ?? null, + 'to' => $filters['to'] ?? null, + 'billable' => array_key_exists('billable', $filters) ? $request->boolean('billable') : null, + 'billed' => array_key_exists('billed', $filters) ? $request->boolean('billed') : null, + ], static fn (mixed $value): bool => $value !== null), + $context->userId, + $this->canSeeAllTime($context, $this->settings), + ); + + return TimeEntryResource::collection($this->paginate($entries, $request)); + } + + public function store(StoreTimeEntryRequest $request): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $validated = $request->validated(); + $userId = isset($validated['user_id']) ? (int) $validated['user_id'] : $context->userId; + + if ($userId !== $context->userId) { + $this->authorize($context, Abilities::EDIT_ALL_TIME); + } + + return new TimeEntryResource($this->entries->create($context->companyId, ['user_id' => $userId] + $validated)); + } + + public function show(Request $request, int $id): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $entry = $this->entries->findForCompany($context->companyId, $id); + + if ($this->belongsToSomeoneElse($context, $entry) && ! $this->canSeeAllTime($context, $this->settings)) { + $this->authorize($context, Abilities::VIEW_ALL_TIME); + } + + return new TimeEntryResource($entry); + } + + public function update(UpdateTimeEntryRequest $request, int $id): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $this->requireWriteAccess($context, $this->entries->findForCompany($context->companyId, $id)); + + return new TimeEntryResource($this->entries->update($context->companyId, $id, $request->validated())); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $this->requireWriteAccess($context, $this->entries->findForCompany($context->companyId, $id)); + $this->entries->delete($context->companyId, $id); + + return response()->json(['success' => true]); + } + + private function requireWriteAccess(CompanyContext $context, TimeEntry $entry): void + { + if ($this->belongsToSomeoneElse($context, $entry)) { + $this->authorize($context, Abilities::EDIT_ALL_TIME); + } + } + + private function belongsToSomeoneElse(CompanyContext $context, TimeEntry $entry): bool + { + return (int) $entry->user_id !== $context->userId; + } +} diff --git a/app/Http/Controllers/TimerController.php b/app/Http/Controllers/TimerController.php new file mode 100644 index 0000000..7d0885f --- /dev/null +++ b/app/Http/Controllers/TimerController.php @@ -0,0 +1,74 @@ +context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $entry = $this->timer->running($context->companyId, $context->userId); + + return response()->json([ + 'data' => $entry === null ? null : TimeEntryResource::make($entry)->resolve($request), + ]); + } + + public function start(StartTimerRequest $request): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $validated = $request->validated(); + + return new TimeEntryResource($this->timer->start( + $context->companyId, + $context->userId, + (int) $validated['task_id'], + $validated['description'] ?? null, + )); + } + + public function stop(Request $request): TimeEntryResource + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + return new TimeEntryResource($this->timer->stop($context->companyId, $context->userId)); + } + + /** Throw the running entry away without recording any time. */ + public function destroy(Request $request): JsonResponse + { + $context = $this->context($request); + $this->authorize($context, Abilities::VIEW_OWN_TIME); + + $this->timer->discard($context->companyId, $context->userId); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Requests/AttachProjectMemberRequest.php b/app/Http/Requests/AttachProjectMemberRequest.php new file mode 100644 index 0000000..db8dc73 --- /dev/null +++ b/app/Http/Requests/AttachProjectMemberRequest.php @@ -0,0 +1,48 @@ +> */ + public function rules(): array + { + return [ + 'user_id' => ['required', 'integer', 'min:1', Rule::in($this->companyMemberIds())], + 'rate' => ['nullable', 'integer', 'min:0'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'user_id.in' => 'The selected user is not a member of this company.', + ]; + } + + /** @return list */ + private function companyMemberIds(): array + { + $context = CompanyContext::fromRequest($this); + + return array_map( + static fn (array $member): int => (int) $member['id'], + app(CompanyDataReader::class)->companyMembers($context->companyId), + ); + } +} diff --git a/app/Http/Requests/BoardRequest.php b/app/Http/Requests/BoardRequest.php new file mode 100644 index 0000000..47fd3fe --- /dev/null +++ b/app/Http/Requests/BoardRequest.php @@ -0,0 +1,17 @@ +> */ + public function rules(): array + { + return [ + 'project_id' => ['sometimes', 'integer', 'min:1'], + 'assignee_id' => ['sometimes', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/ConfirmInvoiceRequest.php b/app/Http/Requests/ConfirmInvoiceRequest.php new file mode 100644 index 0000000..fe762dd --- /dev/null +++ b/app/Http/Requests/ConfirmInvoiceRequest.php @@ -0,0 +1,27 @@ +> */ + public function rules(): array + { + return [ + 'invoice_id' => ['required', 'integer', 'min:1'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.invoice_item_id' => ['required', 'integer', 'min:1'], + 'items.*.entry_ids' => ['required', 'array', 'min:1'], + 'items.*.entry_ids.*' => ['integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/ListProjectsRequest.php b/app/Http/Requests/ListProjectsRequest.php new file mode 100644 index 0000000..ed1a5dc --- /dev/null +++ b/app/Http/Requests/ListProjectsRequest.php @@ -0,0 +1,21 @@ +> */ + public function rules(): array + { + return [ + 'status' => ['sometimes', 'string', 'in:'.Project::STATUS_ACTIVE.','.Project::STATUS_ARCHIVED], + 'customer_id' => ['sometimes', 'integer', 'min:1'], + 'member_id' => ['sometimes', 'integer', 'min:1'], + 'search' => ['sometimes', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/ListTasksRequest.php b/app/Http/Requests/ListTasksRequest.php new file mode 100644 index 0000000..a8031a1 --- /dev/null +++ b/app/Http/Requests/ListTasksRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'project_id' => ['sometimes', 'integer', 'min:1'], + 'assignee_id' => ['sometimes', 'integer', 'min:1'], + 'task_status_id' => ['sometimes', 'integer', 'min:1'], + 'customer_id' => ['sometimes', 'integer', 'min:1'], + 'due_before' => ['sometimes', 'date'], + 'due_after' => ['sometimes', 'date'], + 'search' => ['sometimes', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/ListTimeEntriesRequest.php b/app/Http/Requests/ListTimeEntriesRequest.php new file mode 100644 index 0000000..243f9bd --- /dev/null +++ b/app/Http/Requests/ListTimeEntriesRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'user_id' => ['sometimes', 'integer', 'min:1'], + 'project_id' => ['sometimes', 'integer', 'min:1'], + 'task_id' => ['sometimes', 'integer', 'min:1'], + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + 'billable' => ['sometimes', 'boolean'], + 'billed' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/ModuleRequest.php b/app/Http/Requests/ModuleRequest.php new file mode 100644 index 0000000..0537cd6 --- /dev/null +++ b/app/Http/Requests/ModuleRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'task_status_id' => ['required', 'integer', 'min:1'], + 'before_id' => ['nullable', 'integer', 'min:1', $this->neighbour()], + 'after_id' => ['nullable', 'integer', 'min:1', $this->neighbour()], + ]; + } + + private function neighbour(): Exists + { + $context = CompanyContext::fromRequest($this); + + return Rule::exists((new Task)->getTable(), 'id') + ->where('company_id', $context->companyId) + ->where('task_status_id', $this->integer('task_status_id')); + } +} diff --git a/app/Http/Requests/PrepareInvoiceRequest.php b/app/Http/Requests/PrepareInvoiceRequest.php new file mode 100644 index 0000000..496ac42 --- /dev/null +++ b/app/Http/Requests/PrepareInvoiceRequest.php @@ -0,0 +1,20 @@ +> */ + public function rules(): array + { + return [ + 'entry_ids' => ['required', 'array', 'min:1'], + 'entry_ids.*' => ['integer', 'min:1'], + 'grouping' => ['required', 'string', 'in:'.implode(',', BillingService::GROUPINGS)], + ]; + } +} diff --git a/app/Http/Requests/ReorderTaskStatusesRequest.php b/app/Http/Requests/ReorderTaskStatusesRequest.php new file mode 100644 index 0000000..6bb39f0 --- /dev/null +++ b/app/Http/Requests/ReorderTaskStatusesRequest.php @@ -0,0 +1,17 @@ +> */ + public function rules(): array + { + return [ + 'ids' => ['required', 'array', 'min:1'], + 'ids.*' => ['integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/ReportSummaryRequest.php b/app/Http/Requests/ReportSummaryRequest.php new file mode 100644 index 0000000..bf240db --- /dev/null +++ b/app/Http/Requests/ReportSummaryRequest.php @@ -0,0 +1,17 @@ +> */ + public function rules(): array + { + return [ + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + ]; + } +} diff --git a/app/Http/Requests/StartTimerRequest.php b/app/Http/Requests/StartTimerRequest.php new file mode 100644 index 0000000..6c22e34 --- /dev/null +++ b/app/Http/Requests/StartTimerRequest.php @@ -0,0 +1,17 @@ +> */ + public function rules(): array + { + return [ + 'task_id' => ['required', 'integer', 'min:1'], + 'description' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/StoreProjectRequest.php b/app/Http/Requests/StoreProjectRequest.php new file mode 100644 index 0000000..41b821e --- /dev/null +++ b/app/Http/Requests/StoreProjectRequest.php @@ -0,0 +1,27 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'customer_id' => ['nullable', 'integer', 'min:1'], + 'identifier' => ['nullable', 'string', 'max:32'], + 'description' => ['nullable', 'string'], + 'colour' => ['nullable', 'string', 'max:16'], + 'status' => ['sometimes', 'string', 'in:'.Project::STATUS_ACTIVE.','.Project::STATUS_ARCHIVED], + 'currency_id' => ['nullable', 'integer', 'min:1'], + 'default_rate' => ['nullable', 'integer', 'min:0'], + 'budget_minutes' => ['nullable', 'integer', 'min:0'], + 'due_date' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/StoreTaskRequest.php b/app/Http/Requests/StoreTaskRequest.php new file mode 100644 index 0000000..0a119c5 --- /dev/null +++ b/app/Http/Requests/StoreTaskRequest.php @@ -0,0 +1,28 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'project_id' => ['nullable', 'integer', 'min:1'], + 'customer_id' => ['nullable', 'integer', 'min:1'], + 'task_status_id' => ['nullable', 'integer', 'min:1'], + 'description' => ['nullable', 'string'], + 'assignee_id' => ['nullable', 'integer', 'min:1'], + 'priority' => ['nullable', 'string', 'max:16', 'in:'.implode(',', Task::PRIORITIES)], + 'due_date' => ['nullable', 'date'], + 'estimated_minutes' => ['nullable', 'integer', 'min:0'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/StoreTaskStatusRequest.php b/app/Http/Requests/StoreTaskStatusRequest.php new file mode 100644 index 0000000..1f68f4d --- /dev/null +++ b/app/Http/Requests/StoreTaskStatusRequest.php @@ -0,0 +1,20 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'colour' => ['nullable', 'string', 'max:16'], + 'position' => ['nullable', 'integer', 'min:0'], + 'is_default' => ['sometimes', 'boolean'], + 'is_closed' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/StoreTimeEntryRequest.php b/app/Http/Requests/StoreTimeEntryRequest.php new file mode 100644 index 0000000..766a2f7 --- /dev/null +++ b/app/Http/Requests/StoreTimeEntryRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'task_id' => ['required', 'integer', 'min:1'], + 'user_id' => ['nullable', 'integer', 'min:1'], + 'started_at' => ['nullable', 'date'], + 'ended_at' => ['nullable', 'date', 'after_or_equal:started_at'], + 'duration_minutes' => ['nullable', 'integer', 'min:0'], + 'description' => ['nullable', 'string'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/UnbilledTimeRequest.php b/app/Http/Requests/UnbilledTimeRequest.php new file mode 100644 index 0000000..20f2fe0 --- /dev/null +++ b/app/Http/Requests/UnbilledTimeRequest.php @@ -0,0 +1,18 @@ +> */ + public function rules(): array + { + return [ + 'customer_id' => ['required', 'integer', 'min:1'], + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + ]; + } +} diff --git a/app/Http/Requests/UpdateProjectRequest.php b/app/Http/Requests/UpdateProjectRequest.php new file mode 100644 index 0000000..5427556 --- /dev/null +++ b/app/Http/Requests/UpdateProjectRequest.php @@ -0,0 +1,27 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'customer_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'identifier' => ['sometimes', 'nullable', 'string', 'max:32'], + 'description' => ['sometimes', 'nullable', 'string'], + 'colour' => ['sometimes', 'nullable', 'string', 'max:16'], + 'status' => ['sometimes', 'string', 'in:'.Project::STATUS_ACTIVE.','.Project::STATUS_ARCHIVED], + 'currency_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'default_rate' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'budget_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'due_date' => ['sometimes', 'nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTaskRequest.php b/app/Http/Requests/UpdateTaskRequest.php new file mode 100644 index 0000000..1513592 --- /dev/null +++ b/app/Http/Requests/UpdateTaskRequest.php @@ -0,0 +1,28 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'project_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'customer_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'task_status_id' => ['sometimes', 'integer', 'min:1'], + 'description' => ['sometimes', 'nullable', 'string'], + 'assignee_id' => ['sometimes', 'nullable', 'integer', 'min:1'], + 'priority' => ['sometimes', 'nullable', 'string', 'max:16', 'in:'.implode(',', Task::PRIORITIES)], + 'due_date' => ['sometimes', 'nullable', 'date'], + 'estimated_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['sometimes', 'nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTaskStatusRequest.php b/app/Http/Requests/UpdateTaskStatusRequest.php new file mode 100644 index 0000000..b67a31e --- /dev/null +++ b/app/Http/Requests/UpdateTaskStatusRequest.php @@ -0,0 +1,20 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'colour' => ['sometimes', 'nullable', 'string', 'max:16'], + 'position' => ['sometimes', 'integer', 'min:0'], + 'is_default' => ['sometimes', 'boolean'], + 'is_closed' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTimeEntryRequest.php b/app/Http/Requests/UpdateTimeEntryRequest.php new file mode 100644 index 0000000..2a04ff1 --- /dev/null +++ b/app/Http/Requests/UpdateTimeEntryRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'task_id' => ['sometimes', 'integer', 'min:1'], + 'started_at' => ['sometimes', 'nullable', 'date'], + 'ended_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:started_at'], + 'duration_minutes' => ['sometimes', 'nullable', 'integer', 'min:0'], + 'description' => ['sometimes', 'nullable', 'string'], + 'billable' => ['sometimes', 'boolean'], + 'rate' => ['sometimes', 'nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Resources/ProjectMemberResource.php b/app/Http/Resources/ProjectMemberResource.php new file mode 100644 index 0000000..9169159 --- /dev/null +++ b/app/Http/Resources/ProjectMemberResource.php @@ -0,0 +1,34 @@ + */ + public function toArray(Request $request): array + { + $member = $this->resource; + + return [ + 'id' => (int) $member->id, + 'company_id' => (int) $member->company_id, + 'project_id' => (int) $member->project_id, + 'user_id' => (int) $member->user_id, + 'rate' => $member->rate === null ? null : (int) $member->rate, + 'created_at' => $member->created_at?->toIso8601String(), + 'updated_at' => $member->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php new file mode 100644 index 0000000..574703a --- /dev/null +++ b/app/Http/Resources/ProjectResource.php @@ -0,0 +1,64 @@ +|null */ + private ?array $totals = null; + + /** + * Add the detail screen's totals, which the list deliberately leaves out. + * + * @param array $totals + */ + public function withTotals(array $totals): self + { + $this->totals = $totals; + + return $this; + } + + /** @return array */ + public function toArray(Request $request): array + { + $project = $this->resource; + + $data = [ + 'id' => (int) $project->id, + 'company_id' => (int) $project->company_id, + 'customer_id' => $project->customer_id === null ? null : (int) $project->customer_id, + 'name' => $project->name, + 'identifier' => $project->identifier, + 'description' => $project->description, + 'colour' => $project->colour, + 'status' => $project->status, + 'currency_id' => $project->currency_id === null ? null : (int) $project->currency_id, + 'default_rate' => $project->default_rate === null ? null : (int) $project->default_rate, + 'budget_minutes' => $project->budget_minutes === null ? null : (int) $project->budget_minutes, + 'due_date' => $project->due_date?->toDateString(), + 'creator_id' => $project->creator_id === null ? null : (int) $project->creator_id, + 'is_internal' => $project->isInternal(), + 'created_at' => $project->created_at?->toIso8601String(), + 'updated_at' => $project->updated_at?->toIso8601String(), + ]; + + if ($this->totals !== null) { + $data['totals'] = $this->totals; + } + + return $data; + } +} diff --git a/app/Http/Resources/TaskResource.php b/app/Http/Resources/TaskResource.php new file mode 100644 index 0000000..c692e13 --- /dev/null +++ b/app/Http/Resources/TaskResource.php @@ -0,0 +1,47 @@ + */ + public function toArray(Request $request): array + { + $task = $this->resource; + + return [ + 'id' => (int) $task->id, + 'company_id' => (int) $task->company_id, + 'project_id' => $task->project_id === null ? null : (int) $task->project_id, + 'customer_id' => $task->customer_id === null ? null : (int) $task->customer_id, + 'task_status_id' => (int) $task->task_status_id, + 'number' => (int) $task->number, + 'name' => $task->name, + 'description' => $task->description, + 'assignee_id' => $task->assignee_id === null ? null : (int) $task->assignee_id, + 'priority' => $task->priority, + 'due_date' => $task->due_date?->toDateString(), + 'estimated_minutes' => $task->estimated_minutes === null ? null : (int) $task->estimated_minutes, + 'billable' => (bool) $task->billable, + 'rate' => $task->rate === null ? null : (int) $task->rate, + 'board_position' => (string) $task->board_position, + 'closed_at' => $task->closed_at?->toIso8601String(), + 'creator_id' => $task->creator_id === null ? null : (int) $task->creator_id, + 'created_at' => $task->created_at?->toIso8601String(), + 'updated_at' => $task->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/TaskStatusResource.php b/app/Http/Resources/TaskStatusResource.php new file mode 100644 index 0000000..a4a90c7 --- /dev/null +++ b/app/Http/Resources/TaskStatusResource.php @@ -0,0 +1,35 @@ + */ + public function toArray(Request $request): array + { + $status = $this->resource; + + return [ + 'id' => (int) $status->id, + 'company_id' => (int) $status->company_id, + 'name' => $status->name, + 'colour' => $status->colour, + 'position' => (int) $status->position, + 'is_default' => (bool) $status->is_default, + 'is_closed' => (bool) $status->is_closed, + 'created_at' => $status->created_at?->toIso8601String(), + 'updated_at' => $status->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/TimeEntryResource.php b/app/Http/Resources/TimeEntryResource.php new file mode 100644 index 0000000..06c7023 --- /dev/null +++ b/app/Http/Resources/TimeEntryResource.php @@ -0,0 +1,46 @@ + */ + public function toArray(Request $request): array + { + $entry = $this->resource; + + return [ + 'id' => (int) $entry->id, + 'company_id' => (int) $entry->company_id, + 'task_id' => (int) $entry->task_id, + 'project_id' => $entry->project_id === null ? null : (int) $entry->project_id, + 'user_id' => (int) $entry->user_id, + 'started_at' => $entry->started_at?->toIso8601String(), + 'ended_at' => $entry->ended_at?->toIso8601String(), + 'duration_minutes' => (int) $entry->duration_minutes, + 'description' => $entry->description, + 'billable' => (bool) $entry->billable, + 'rate' => (int) $entry->rate, + 'amount' => (int) $entry->amount, + 'currency_id' => $entry->currency_id === null ? null : (int) $entry->currency_id, + 'is_running' => $entry->isRunning(), + 'invoice_id' => $entry->invoice_id === null ? null : (int) $entry->invoice_id, + 'invoice_item_id' => $entry->invoice_item_id === null ? null : (int) $entry->invoice_item_id, + 'invoiced_at' => $entry->invoiced_at?->toIso8601String(), + 'created_at' => $entry->created_at?->toIso8601String(), + 'updated_at' => $entry->updated_at?->toIso8601String(), + ]; + } +} diff --git a/routes/api.php b/routes/api.php index cb7d514..87fd5df 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,7 +1,72 @@ middleware(['api', 'auth:sanctum', 'company', 'bouncer'])->group(function (): void { - // Routes are added in the backend milestone (M3). See specs/tasks-projects.md "API surface". + Route::get('projects', [ProjectsController::class, 'index'])->name('tasks-projects.projects.index'); + Route::post('projects', [ProjectsController::class, 'store'])->name('tasks-projects.projects.store'); + Route::get('projects/{id}', [ProjectsController::class, 'show'])->name('tasks-projects.projects.show'); + Route::put('projects/{id}', [ProjectsController::class, 'update'])->name('tasks-projects.projects.update'); + Route::delete('projects/{id}', [ProjectsController::class, 'destroy'])->name('tasks-projects.projects.destroy'); + Route::post('projects/{id}/archive', [ProjectsController::class, 'archive'])->name('tasks-projects.projects.archive'); + Route::post('projects/{id}/unarchive', [ProjectsController::class, 'unarchive'])->name('tasks-projects.projects.unarchive'); + + Route::get('projects/{id}/members', [ProjectMembersController::class, 'index'])->name('tasks-projects.project-members.index'); + Route::post('projects/{id}/members', [ProjectMembersController::class, 'store'])->name('tasks-projects.project-members.store'); + Route::delete('projects/{id}/members/{userId}', [ProjectMembersController::class, 'destroy'])->name('tasks-projects.project-members.destroy'); + + Route::get('members', MembersController::class)->name('tasks-projects.members.index'); + + Route::get('tasks', [TasksController::class, 'index'])->name('tasks-projects.tasks.index'); + Route::post('tasks', [TasksController::class, 'store'])->name('tasks-projects.tasks.store'); + Route::get('tasks/{id}', [TasksController::class, 'show'])->name('tasks-projects.tasks.show'); + Route::put('tasks/{id}', [TasksController::class, 'update'])->name('tasks-projects.tasks.update'); + Route::delete('tasks/{id}', [TasksController::class, 'destroy'])->name('tasks-projects.tasks.destroy'); + Route::post('tasks/{id}/move', [TasksController::class, 'move'])->name('tasks-projects.tasks.move'); + + Route::get('board', BoardController::class)->name('tasks-projects.board.index'); + + Route::get('task-statuses', [TaskStatusesController::class, 'index'])->name('tasks-projects.task-statuses.index'); + Route::post('task-statuses', [TaskStatusesController::class, 'store'])->name('tasks-projects.task-statuses.store'); + Route::post('task-statuses/reorder', [TaskStatusesController::class, 'reorder'])->name('tasks-projects.task-statuses.reorder'); + Route::put('task-statuses/{id}', [TaskStatusesController::class, 'update'])->name('tasks-projects.task-statuses.update'); + Route::delete('task-statuses/{id}', [TaskStatusesController::class, 'destroy'])->name('tasks-projects.task-statuses.destroy'); + + Route::get('time-entries', [TimeEntriesController::class, 'index'])->name('tasks-projects.time-entries.index'); + Route::post('time-entries', [TimeEntriesController::class, 'store'])->name('tasks-projects.time-entries.store'); + Route::get('time-entries/{id}', [TimeEntriesController::class, 'show'])->name('tasks-projects.time-entries.show'); + Route::put('time-entries/{id}', [TimeEntriesController::class, 'update'])->name('tasks-projects.time-entries.update'); + Route::delete('time-entries/{id}', [TimeEntriesController::class, 'destroy'])->name('tasks-projects.time-entries.destroy'); + + Route::get('timer', [TimerController::class, 'show'])->name('tasks-projects.timer.show'); + Route::delete('timer', [TimerController::class, 'destroy'])->name('tasks-projects.timer.destroy'); + Route::post('timer/start', [TimerController::class, 'start'])->name('tasks-projects.timer.start'); + Route::post('timer/stop', [TimerController::class, 'stop'])->name('tasks-projects.timer.stop'); + + Route::get('billing/unbilled', [BillingController::class, 'unbilled'])->name('tasks-projects.billing.unbilled'); + Route::post('billing/prepare', [BillingController::class, 'prepare'])->name('tasks-projects.billing.prepare'); + Route::post('billing/confirm', [BillingController::class, 'confirm'])->name('tasks-projects.billing.confirm'); + + Route::get('reports/summary', [ReportsController::class, 'summary'])->name('tasks-projects.reports.summary'); + + Route::get('settings', SettingsController::class)->name('tasks-projects.settings.show'); }); From 300fafebb0b2a54e00fc9f2dd8e392a4221c66a1 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 21:02:08 +0200 Subject: [PATCH 3/3] test: cover every route with a feature test Ninety-eight tests over the HTTP surface, driven through the real router. `auth:sanctum`, `company` and `bouncer` are host middleware that do not exist under Testbench, so the harness stands in for all three: it loads the module's own route file, authenticates a generic user and sends the `company` header the host would have set. What the tests hold to: the per-company task number sequence, the customer denormalised from a project onto its tasks, closed_at set on entering a closed column and cleared on leaving one, a move landing between its neighbours, the four board columns seeded lazily and independently per company, rounding and the frozen rate on a saved entry, own versus all time through both the ability and the company setting, one running timer per user per company, unbilled time that skips stamped, non-billable, running and internal work, a confirmation that is safe to replay, and a summary that never adds two currencies together. ModuleRoutesTest pins the whole route table, its middleware stack and the slug prefix, so a route can neither escape the prefix nor lose the host stack unnoticed. --- tests/Feature/BillingApiTest.php | 320 ++++++++++++++++++++++++++ tests/Feature/MembersApiTest.php | 49 ++++ tests/Feature/ProjectsApiTest.php | 266 +++++++++++++++++++++ tests/Feature/ReportsApiTest.php | 188 +++++++++++++++ tests/Feature/SettingsApiTest.php | 72 ++++++ tests/Feature/TaskStatusesApiTest.php | 175 ++++++++++++++ tests/Feature/TasksApiTest.php | 284 +++++++++++++++++++++++ tests/Feature/TimeEntriesApiTest.php | 259 +++++++++++++++++++++ tests/Feature/TimerApiTest.php | 168 ++++++++++++++ tests/TestCase.php | 44 ++++ tests/Unit/ModuleRoutesTest.php | 125 ++++++++++ 11 files changed, 1950 insertions(+) create mode 100644 tests/Feature/BillingApiTest.php create mode 100644 tests/Feature/MembersApiTest.php create mode 100644 tests/Feature/ProjectsApiTest.php create mode 100644 tests/Feature/ReportsApiTest.php create mode 100644 tests/Feature/SettingsApiTest.php create mode 100644 tests/Feature/TaskStatusesApiTest.php create mode 100644 tests/Feature/TasksApiTest.php create mode 100644 tests/Feature/TimeEntriesApiTest.php create mode 100644 tests/Feature/TimerApiTest.php create mode 100644 tests/Unit/ModuleRoutesTest.php diff --git a/tests/Feature/BillingApiTest.php b/tests/Feature/BillingApiTest.php new file mode 100644 index 0000000..250bc46 --- /dev/null +++ b/tests/Feature/BillingApiTest.php @@ -0,0 +1,320 @@ +companyData->withMember(self::COMPANY, self::DEFAULT_USER, 'Ada Lovelace'); + $this->website = $this->makeProject(self::COMPANY, [ + 'name' => 'Website', + 'customer_id' => self::CUSTOMER, + 'currency_id' => self::CURRENCY, + ]); + $this->landing = $this->task('Landing page', $this->website); + $this->pricing = $this->task('Pricing page', $this->website); + } + + public function test_unbilled_collects_the_customers_billable_time_and_groups_it_four_ways(): void + { + $first = $this->entry($this->landing, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 90, '2026-09-02'); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER); + + $response->assertOk(); + $response->assertJsonPath('data.customer_id', self::CUSTOMER); + $response->assertJsonPath('data.minutes', 150); + $response->assertJsonPath('data.entry_ids', [(int) $first->id, (int) $second->id]); + $response->assertJsonPath('data.currencies', [ + ['currency_id' => self::CURRENCY, 'minutes' => 150, 'amount' => 15000], + ]); + $response->assertJsonPath('data.groups.task.0.label', 'Landing page'); + $response->assertJsonPath('data.groups.project.0.label', 'Website'); + $response->assertJsonPath('data.groups.member.0.label', 'Ada Lovelace'); + $response->assertJsonPath('data.groups.summary.0.minutes', 150); + } + + public function test_unbilled_skips_stamped_non_billable_running_and_internal_time(): void + { + $open = $this->entry($this->landing, 60, '2026-09-01'); + $this->entry($this->landing, 60, '2026-09-02', ['billable' => false]); + $this->entry($this->landing, 0, '2026-09-03', ['running_user_id' => self::DEFAULT_USER, 'ended_at' => null]); + $this->entry($this->landing, 60, '2026-09-04', ['invoice_id' => 77, 'invoice_item_id' => 101]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal tooling', 'customer_id' => null]); + $stray = $this->makeTask(self::COMPANY, ['name' => 'Stray', 'project_id' => $internal->id, 'customer_id' => self::CUSTOMER]); + $this->entry($stray, 60, '2026-09-05', ['project_id' => $internal->id]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertOk() + ->assertJsonPath('data.entry_ids', [(int) $open->id]); + } + + public function test_time_whose_invoice_vanished_from_the_host_comes_back_to_the_unbilled_list(): void + { + $orphan = $this->entry($this->landing, 60, '2026-09-01', ['invoice_id' => 88, 'invoice_item_id' => 101]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertOk() + ->assertJsonPath('data.entry_ids', [(int) $orphan->id]); + } + + public function test_unbilled_honours_the_date_range_and_needs_a_customer(): void + { + $this->entry($this->landing, 60, '2026-09-01'); + $inside = $this->entry($this->landing, 60, '2026-09-10'); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER.'&from=2026-09-05&to=2026-09-15') + ->assertOk() + ->assertJsonPath('data.entry_ids', [(int) $inside->id]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled') + ->assertStatus(422) + ->assertJsonValidationErrors(['customer_id']); + } + + public function test_prepare_returns_the_host_invoice_body_for_every_grouping(): void + { + Carbon::setTestNow('2026-09-15 08:00:00'); + $first = $this->entry($this->landing, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 90, '2026-09-02'); + $ids = [(int) $first->id, (int) $second->id]; + + $byTask = $this->prepare($ids, 'task'); + + $byTask->assertOk(); + $byTask->assertJsonPath('data.invoice_date', '2026-09-15'); + $byTask->assertJsonPath('data.customer_id', self::CUSTOMER); + $byTask->assertJsonPath('data.currency_id', self::CURRENCY); + $byTask->assertJsonPath('data.sub_total', 15000); + $byTask->assertJsonPath('data.total', 15000); + $byTask->assertJsonPath('data.items', [ + ['name' => 'Landing page', 'description' => null, 'quantity' => 1.0, 'price' => self::RATE, 'total' => 6000], + ['name' => 'Pricing page', 'description' => null, 'quantity' => 1.5, 'price' => self::RATE, 'total' => 9000], + ]); + $byTask->assertJsonPath('data.groups', [ + ['entry_ids' => [(int) $first->id]], + ['entry_ids' => [(int) $second->id]], + ]); + + $this->prepare($ids, 'project')->assertJsonPath('data.items.0.name', 'Website')->assertJsonPath('data.items.0.quantity', 2.5); + $this->prepare($ids, 'member')->assertJsonPath('data.items.0.name', 'Ada Lovelace'); + $this->prepare($ids, 'summary') + ->assertJsonPath('data.items.0.name', 'Time') + ->assertJsonPath('data.items.0.quantity', 2.5) + ->assertJsonPath('data.groups', [['entry_ids' => $ids]]); + } + + public function test_prepare_refuses_a_grouping_it_does_not_know_and_an_empty_selection(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01'); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/prepare', ['entry_ids' => [(int) $entry->id], 'grouping' => 'weekday']) + ->assertStatus(422) + ->assertJsonValidationErrors(['grouping']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/prepare', ['entry_ids' => [], 'grouping' => 'task']) + ->assertStatus(422) + ->assertJsonValidationErrors(['entry_ids']); + } + + public function test_prepare_refuses_a_selection_spanning_two_customers(): void + { + $ours = $this->entry($this->landing, 60, '2026-09-01'); + $theirs = $this->entry($this->task('Theirs', null, 43), 60, '2026-09-02'); + + $this->prepare([(int) $ours->id, (int) $theirs->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'mixed_billing_selection'); + } + + public function test_prepare_refuses_an_entry_of_another_company(): void + { + $foreignTask = $this->makeTask(self::OTHER_COMPANY, ['customer_id' => self::CUSTOMER]); + $foreign = $this->makeEntry(self::OTHER_COMPANY, (int) $foreignTask->id); + + $this->prepare([(int) $foreign->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'unknown_time_entries'); + } + + public function test_prepare_refuses_non_billable_time_and_time_with_no_customer(): void + { + $free = $this->entry($this->landing, 60, '2026-09-01', ['billable' => false]); + + $this->prepare([(int) $free->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'not_billable'); + + $internal = $this->makeProject(self::COMPANY, ['name' => 'Internal tooling', 'customer_id' => null]); + $task = $this->makeTask(self::COMPANY, ['name' => 'Internal work', 'project_id' => $internal->id]); + $entry = $this->entry($task, 60, '2026-09-02', ['project_id' => $internal->id]); + + $this->prepare([(int) $entry->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'not_billable'); + } + + public function test_prepare_refuses_time_that_is_already_on_a_live_invoice(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01', ['invoice_id' => 77, 'invoice_item_id' => 101]); + $this->companyData->withInvoices(self::COMPANY, 77); + + $this->prepare([(int) $entry->id], 'task') + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + } + + public function test_confirm_stamps_the_entries_and_is_safe_to_repeat(): void + { + $first = $this->entry($this->landing, 60, '2026-09-01'); + $second = $this->entry($this->pricing, 60, '2026-09-02'); + $payload = [ + 'invoice_id' => 77, + 'items' => [ + ['invoice_item_id' => 101, 'entry_ids' => [(int) $first->id]], + ['invoice_item_id' => 102, 'entry_ids' => [(int) $second->id]], + ], + ]; + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', $payload) + ->assertOk() + ->assertExactJson(['stamped' => 2]); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', $payload) + ->assertOk() + ->assertExactJson(['stamped' => 0]); + + self::assertSame(77, $first->fresh()?->invoice_id); + self::assertSame(101, $first->fresh()?->invoice_item_id); + + $this->companyData->withInvoices(self::COMPANY, 77); + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertJsonPath('data.entry_ids', []); + } + + public function test_confirm_refuses_an_entry_that_belongs_to_another_invoice(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01', ['invoice_id' => 77, 'invoice_item_id' => 101]); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', [ + 'invoice_id' => 78, + 'items' => [['invoice_item_id' => 201, 'entry_ids' => [(int) $entry->id]]], + ]) + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + } + + public function test_confirm_refuses_an_entry_of_another_company_and_stamps_nothing(): void + { + $ours = $this->entry($this->landing, 60, '2026-09-01'); + $foreignTask = $this->makeTask(self::OTHER_COMPANY); + $foreign = $this->makeEntry(self::OTHER_COMPANY, (int) $foreignTask->id); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', [ + 'invoice_id' => 77, + 'items' => [['invoice_item_id' => 101, 'entry_ids' => [(int) $ours->id, (int) $foreign->id]]], + ]) + ->assertStatus(422) + ->assertJsonPath('error', 'unknown_time_entries'); + + self::assertNull($ours->fresh()?->invoice_id); + } + + public function test_billing_needs_the_invoice_tasks_ability(): void + { + $entry = $this->entry($this->landing, 60, '2026-09-01'); + $this->authorization->deny(Authorizes::id(Abilities::INVOICE_TASKS)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/billing/unbilled?customer_id='.self::CUSTOMER) + ->assertForbidden(); + + $this->prepare([(int) $entry->id], 'task')->assertForbidden(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/billing/confirm', [ + 'invoice_id' => 77, + 'items' => [['invoice_item_id' => 101, 'entry_ids' => [(int) $entry->id]]], + ]) + ->assertForbidden(); + } + + /** @param list $entryIds */ + private function prepare(array $entryIds, string $grouping): TestResponse + { + return $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/billing/prepare', [ + 'entry_ids' => $entryIds, + 'grouping' => $grouping, + ]); + } + + private function task(string $name, ?Project $project, int $customerId = self::CUSTOMER): Task + { + return $this->makeTask(self::COMPANY, [ + 'name' => $name, + 'project_id' => $project?->id, + 'customer_id' => $project === null ? $customerId : $project->customer_id, + ]); + } + + /** @param array $attributes */ + private function entry(Task $task, int $minutes, string $day, array $attributes = []): TimeEntry + { + return $this->makeEntry(self::COMPANY, (int) $task->id, $attributes + [ + 'project_id' => $task->project_id, + 'user_id' => self::DEFAULT_USER, + 'started_at' => Carbon::parse($day.' 09:00:00'), + 'ended_at' => Carbon::parse($day.' 09:00:00')->addMinutes($minutes), + 'duration_minutes' => $minutes, + 'rate' => self::RATE, + 'amount' => (int) round($minutes / 60 * self::RATE), + 'currency_id' => self::CURRENCY, + ]); + } +} diff --git a/tests/Feature/MembersApiTest.php b/tests/Feature/MembersApiTest.php new file mode 100644 index 0000000..ede022d --- /dev/null +++ b/tests/Feature/MembersApiTest.php @@ -0,0 +1,49 @@ +companyData + ->withMember(self::COMPANY, 7, 'Ada Lovelace') + ->withMember(self::COMPANY, 8, 'Grace Hopper') + ->withMember(self::OTHER_COMPANY, 9, 'Someone Else'); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/members'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); + $response->assertJsonPath('data.0.id', 7); + $response->assertJsonPath('data.0.name', 'Ada Lovelace'); + $response->assertJsonPath('data.1.name', 'Grace Hopper'); + } + + public function test_a_company_without_members_returns_an_empty_list(): void + { + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/members') + ->assertOk() + ->assertExactJson(['data' => []]); + } + + public function test_the_picker_needs_the_project_view_ability(): void + { + $this->authorization->deny(Authorizes::id(Abilities::VIEW_PROJECT)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/members') + ->assertForbidden(); + } +} diff --git a/tests/Feature/ProjectsApiTest.php b/tests/Feature/ProjectsApiTest.php new file mode 100644 index 0000000..8d91684 --- /dev/null +++ b/tests/Feature/ProjectsApiTest.php @@ -0,0 +1,266 @@ +makeProject(self::COMPANY, ['name' => 'Alpha']); + $this->makeProject(self::COMPANY, ['name' => 'Beta']); + $this->makeProject(self::OTHER_COMPANY, ['name' => 'Someone else']); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects?limit=1'); + + $response->assertOk(); + $response->assertJsonPath('meta.total', 2); + $response->assertJsonPath('meta.per_page', 1); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('data.0.name', 'Alpha'); + } + + public function test_the_list_filters_by_status_customer_member_and_text(): void + { + $website = $this->makeProject(self::COMPANY, ['name' => 'Website', 'customer_id' => self::CUSTOMER]); + $this->makeProject(self::COMPANY, ['name' => 'Internal tooling']); + $archived = $this->makeProject(self::COMPANY, ['name' => 'Old site', 'status' => Project::STATUS_ARCHIVED]); + $this->makeMember(self::COMPANY, (int) $website->id, 12); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?status='.Project::STATUS_ARCHIVED) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.id', (int) $archived->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?customer_id='.self::CUSTOMER) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.id', (int) $website->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?member_id=12') + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.id', (int) $website->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects?search=tooling') + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.name', 'Internal tooling'); + } + + public function test_it_creates_a_project_for_the_header_company_and_stamps_the_creator(): void + { + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects', [ + 'name' => 'Website redesign', + 'customer_id' => self::CUSTOMER, + 'identifier' => 'WEB', + 'default_rate' => 12000, + 'budget_minutes' => 600, + 'due_date' => '2026-12-31', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.company_id', self::COMPANY); + $response->assertJsonPath('data.creator_id', self::DEFAULT_USER); + $response->assertJsonPath('data.status', Project::STATUS_ACTIVE); + $response->assertJsonPath('data.default_rate', 12000); + $response->assertJsonPath('data.due_date', '2026-12-31'); + $response->assertJsonPath('data.is_internal', false); + + self::assertSame(1, Project::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_project_without_a_customer_is_internal(): void + { + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects', ['name' => 'Internal tooling']) + ->assertCreated() + ->assertJsonPath('data.is_internal', true); + } + + public function test_it_rejects_a_project_without_a_name(): void + { + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects', ['default_rate' => -1]) + ->assertStatus(422) + ->assertJsonValidationErrors(['name', 'default_rate']); + } + + public function test_the_detail_carries_the_totals_the_list_leaves_out(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => self::CUSTOMER, 'currency_id' => 3]); + $status = $this->makeStatus(self::COMPANY, ['is_closed' => true]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id, 'task_status_id' => $status->id, 'closed_at' => now()]); + $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id, 'duration_minutes' => 90, 'amount' => 15000]); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects/'.$project->id); + + $response->assertOk(); + $response->assertJsonPath('data.totals.tasks.total', 1); + $response->assertJsonPath('data.totals.tasks.closed', 1); + $response->assertJsonPath('data.totals.logged_minutes', 90); + $response->assertJsonPath('data.totals.billable_amount', 15000); + $response->assertJsonPath('data.totals.unbilled_amount', 15000); + $response->assertJsonPath('data.totals.currency_id', 3); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects') + ->assertJsonMissingPath('data.0.totals'); + } + + public function test_it_updates_a_project(): void + { + $project = $this->makeProject(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['name' => 'Renamed', 'colour' => '#ff0000']) + ->assertOk() + ->assertJsonPath('data.name', 'Renamed') + ->assertJsonPath('data.colour', '#ff0000'); + } + + public function test_archive_and_unarchive_flip_the_status(): void + { + $project = $this->makeProject(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/archive') + ->assertOk() + ->assertJsonPath('data.status', Project::STATUS_ARCHIVED); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/unarchive') + ->assertOk() + ->assertJsonPath('data.status', Project::STATUS_ACTIVE); + } + + public function test_it_deletes_a_project(): void + { + $project = $this->makeProject(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(0, Project::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_project_with_invoiced_time_refuses_to_be_deleted(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => self::CUSTOMER]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $this->makeEntry(self::COMPANY, (int) $task->id, [ + 'project_id' => $project->id, + 'invoice_id' => 77, + 'invoice_item_id' => 88, + ]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertStatus(422) + ->assertJsonPath('error', 'project_in_use'); + } + + public function test_attaching_a_member_needs_the_user_to_be_a_company_member(): void + { + $project = $this->makeProject(self::COMPANY); + $this->companyData->withMember(self::COMPANY, 12, 'Ada Lovelace'); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/members', ['user_id' => 99]) + ->assertStatus(422) + ->assertJsonValidationErrors(['user_id']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/members', ['user_id' => 12, 'rate' => 9000]) + ->assertCreated() + ->assertJsonPath('data.user_id', 12) + ->assertJsonPath('data.rate', 9000); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects/'.$project->id.'/members') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.user_id', 12); + } + + public function test_detaching_a_member_leaves_their_time_alone(): void + { + $project = $this->makeProject(self::COMPANY); + $this->makeMember(self::COMPANY, (int) $project->id, 12); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + $entry = $this->makeEntry(self::COMPANY, (int) $task->id, ['project_id' => $project->id, 'user_id' => 12]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id.'/members/12') + ->assertOk(); + + self::assertSame(0, ProjectMember::query()->forCompany(self::COMPANY)->count()); + self::assertNotNull(TimeEntry::query()->find($entry->id)); + } + + public function test_a_project_of_another_company_is_not_found(): void + { + $project = $this->makeProject(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertNotFound(); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['name' => 'Mine now']) + ->assertNotFound(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/projects/'.$project->id) + ->assertNotFound(); + } + + public function test_every_action_refuses_without_its_ability(): void + { + $project = $this->makeProject(self::COMPANY); + $this->companyData->withMember(self::COMPANY, 12, 'Ada Lovelace'); + + $this->authorization->deny( + Authorizes::id(Abilities::VIEW_PROJECT), + Authorizes::id(Abilities::CREATE_PROJECT), + Authorizes::id(Abilities::EDIT_PROJECT), + Authorizes::id(Abilities::DELETE_PROJECT), + ); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects', ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects/'.$project->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/projects/'.$project->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/archive')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/projects/'.$project->id.'/members', ['user_id' => 12])->assertForbidden(); + } + + public function test_the_ability_is_checked_for_the_header_company_and_the_authenticated_user(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/projects')->assertOk(); + + self::assertSame([ + 'user_id' => self::DEFAULT_USER, + 'company_id' => self::COMPANY, + 'ability' => 'tasks-projects:view-project', + 'resource' => null, + ], $this->authorization->checks[0]); + } +} diff --git a/tests/Feature/ReportsApiTest.php b/tests/Feature/ReportsApiTest.php new file mode 100644 index 0000000..e113429 --- /dev/null +++ b/tests/Feature/ReportsApiTest.php @@ -0,0 +1,188 @@ +companyData + ->withMember(self::COMPANY, self::DEFAULT_USER, 'Ada Lovelace') + ->withMember(self::COMPANY, self::OTHER_USER, 'Grace Hopper'); + + $this->website = $this->makeProject(self::COMPANY, [ + 'name' => 'Website', + 'customer_id' => self::CUSTOMER, + 'currency_id' => 3, + ]); + $this->landing = $this->makeTask(self::COMPANY, [ + 'name' => 'Landing page', + 'project_id' => $this->website->id, + 'customer_id' => self::CUSTOMER, + ]); + } + + public function test_the_summary_totals_per_currency_and_splits_billable_from_the_rest(): void + { + $this->entry(60, '2026-09-01', 6000); + $this->entry(30, '2026-09-02', 3000, ['invoice_id' => 77, 'invoice_item_id' => 101]); + $this->entry(45, '2026-09-03', 0, ['billable' => false]); + $this->entry(60, '2026-09-04', 9000, ['currency_id' => 4]); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30'); + + $response->assertOk(); + $response->assertJsonPath('data.from', '2026-09-01'); + $response->assertJsonPath('data.to', '2026-09-30'); + $response->assertJsonPath('data.totals', [ + [ + 'currency_id' => 3, + 'minutes' => 135, + 'amount' => 9000, + 'billable_minutes' => 90, + 'billable_amount' => 9000, + 'unbilled_amount' => 6000, + ], + [ + 'currency_id' => 4, + 'minutes' => 60, + 'amount' => 9000, + 'billable_minutes' => 60, + 'billable_amount' => 9000, + 'unbilled_amount' => 9000, + ], + ]); + $response->assertJsonPath('data.by_billable.0.billable', true); + $response->assertJsonPath('data.by_billable.0.minutes', 90); + $response->assertJsonPath('data.by_billable.1.billable', false); + $response->assertJsonPath('data.by_billable.1.minutes', 45); + } + + public function test_the_summary_breaks_down_by_project_member_and_customer(): void + { + $this->entry(60, '2026-09-01', 6000); + $this->entry(120, '2026-09-02', 12000, ['user_id' => self::OTHER_USER]); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30'); + + $response->assertJsonPath('data.by_project.0.label', 'Website'); + $response->assertJsonPath('data.by_project.0.minutes', 180); + $response->assertJsonPath('data.by_member.0.label', 'Ada Lovelace'); + $response->assertJsonPath('data.by_member.1.label', 'Grace Hopper'); + $response->assertJsonPath('data.by_customer.0.customer_id', self::CUSTOMER); + $response->assertJsonPath('data.by_customer.0.minutes', 180); + } + + public function test_the_range_leaves_out_what_falls_outside_it(): void + { + $this->entry(60, '2026-08-31', 6000); + $this->entry(30, '2026-09-10', 3000); + $this->entry(60, '2026-10-01', 6000); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30') + ->assertJsonPath('data.totals.0.minutes', 30); + } + + public function test_the_range_defaults_to_the_current_month(): void + { + Carbon::setTestNow('2026-09-15 12:00:00'); + $this->entry(60, '2026-08-20', 6000); + $this->entry(30, '2026-09-10', 3000); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary') + ->assertOk() + ->assertJsonPath('data.from', '2026-09-01') + ->assertJsonPath('data.to', '2026-09-15') + ->assertJsonPath('data.totals.0.minutes', 30); + } + + public function test_a_viewer_with_only_their_own_time_reports_on_their_own_time(): void + { + $this->entry(60, '2026-09-01', 6000); + $this->entry(120, '2026-09-02', 12000, ['user_id' => self::OTHER_USER]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30') + ->assertJsonPath('data.totals.0.minutes', 180); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + + $response = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30'); + + $response->assertJsonPath('data.totals.0.minutes', 60); + $response->assertJsonPath('data.by_member', [[ + 'user_id' => self::DEFAULT_USER, + 'label' => 'Ada Lovelace', + 'currency_id' => 3, + 'minutes' => 60, + 'amount' => 6000, + 'billable_minutes' => 60, + 'billable_amount' => 6000, + 'unbilled_amount' => 6000, + ]]); + } + + public function test_the_summary_never_reaches_another_company(): void + { + $this->entry(60, '2026-09-01', 6000); + $foreign = $this->makeTask(self::OTHER_COMPANY); + $this->makeEntry(self::OTHER_COMPANY, (int) $foreign->id, ['duration_minutes' => 600, 'amount' => 60000]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary?from=2026-09-01&to=2026-09-30') + ->assertJsonPath('data.totals.0.minutes', 60); + } + + public function test_the_summary_needs_the_own_time_ability(): void + { + $this->authorization->deny(Authorizes::id(Abilities::VIEW_OWN_TIME)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/reports/summary') + ->assertForbidden(); + } + + /** @param array $attributes */ + private function entry(int $minutes, string $day, int $amount, array $attributes = []): TimeEntry + { + return $this->makeEntry(self::COMPANY, (int) $this->landing->id, $attributes + [ + 'project_id' => $this->website->id, + 'user_id' => self::DEFAULT_USER, + 'started_at' => Carbon::parse($day.' 09:00:00'), + 'ended_at' => Carbon::parse($day.' 09:00:00')->addMinutes($minutes), + 'duration_minutes' => $minutes, + 'rate' => 6000, + 'amount' => $amount, + 'currency_id' => 3, + ]); + } +} diff --git a/tests/Feature/SettingsApiTest.php b/tests/Feature/SettingsApiTest.php new file mode 100644 index 0000000..d7981d5 --- /dev/null +++ b/tests/Feature/SettingsApiTest.php @@ -0,0 +1,72 @@ +asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/settings'); + + $response->assertOk(); + $response->assertExactJson(['data' => [ + 'default_rate' => 0, + 'rounding_minutes' => ModuleSettings::DEFAULT_ROUNDING_MINUTES, + 'week_start' => ModuleSettings::DEFAULT_WEEK_START, + 'members_see_all_time' => false, + 'rounding_increments' => ModuleSettings::ROUNDING_INCREMENTS, + ]]); + } + + public function test_stored_values_come_back_typed_whatever_the_host_wrote(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'default_rate', '12000'); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', '15'); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'week_start', '0'); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'members_see_all_time', 'YES'); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/settings'); + + $response->assertOk(); + $response->assertJsonPath('data.default_rate', 12000); + $response->assertJsonPath('data.rounding_minutes', 15); + $response->assertJsonPath('data.week_start', 0); + $response->assertJsonPath('data.members_see_all_time', true); + } + + public function test_an_unusable_stored_value_falls_back_to_the_default(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 7); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/settings') + ->assertJsonPath('data.rounding_minutes', ModuleSettings::DEFAULT_ROUNDING_MINUTES); + } + + public function test_settings_are_per_company(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'default_rate', 12000); + + $this->asCompany(10) + ->getJson('/api/v1/tasks-projects/settings') + ->assertJsonPath('data.default_rate', 0); + } + + public function test_reading_the_settings_needs_the_project_view_ability(): void + { + $this->authorization->deny(Authorizes::id(Abilities::VIEW_PROJECT)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/settings') + ->assertForbidden(); + } +} diff --git a/tests/Feature/TaskStatusesApiTest.php b/tests/Feature/TaskStatusesApiTest.php new file mode 100644 index 0000000..34bbe8e --- /dev/null +++ b/tests/Feature/TaskStatusesApiTest.php @@ -0,0 +1,175 @@ +asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + + $response->assertOk(); + $response->assertJsonCount(4, 'data'); + $response->assertJsonPath('data.0.name', 'Backlog'); + $response->assertJsonPath('data.0.is_default', true); + $response->assertJsonPath('data.3.name', 'Done'); + $response->assertJsonPath('data.3.is_closed', true); + $response->assertJsonPath('data.0.position', 1); + $response->assertJsonPath('data.3.position', 4); + } + + public function test_asking_twice_does_not_seed_the_defaults_again(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertOk(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertJsonCount(4, 'data'); + + self::assertSame(count(TaskStatusService::DEFAULTS), TaskStatus::query()->forCompany(self::COMPANY)->count()); + } + + public function test_two_companies_get_their_own_independent_columns(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertJsonCount(4, 'data'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses', ['name' => 'Blocked'])->assertCreated(); + + $this->asCompany(self::OTHER_COMPANY) + ->getJson('/api/v1/tasks-projects/task-statuses') + ->assertJsonCount(4, 'data') + ->assertJsonMissing(['name' => 'Blocked']); + + self::assertSame(5, TaskStatus::query()->forCompany(self::COMPANY)->count()); + self::assertSame(4, TaskStatus::query()->forCompany(self::OTHER_COMPANY)->count()); + } + + public function test_a_created_status_lands_last_and_can_take_the_default_over(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses', [ + 'name' => 'Blocked', + 'colour' => '#ef4444', + 'is_default' => true, + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.position', 5); + $response->assertJsonPath('data.is_default', true); + + $defaults = TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->pluck('name')->all(); + self::assertSame(['Blocked'], $defaults); + } + + public function test_only_one_status_stays_the_default_after_an_update(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $review = TaskStatus::query()->forCompany(self::COMPANY)->where('name', 'Review')->firstOrFail(); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/task-statuses/'.$review->id, ['is_default' => true]) + ->assertOk() + ->assertJsonPath('data.is_default', true); + + self::assertSame( + ['Review'], + TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->pluck('name')->all(), + ); + } + + public function test_reorder_applies_the_wanted_order_and_returns_the_new_board(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $statuses = TaskStatus::query()->forCompany(self::COMPANY)->orderBy('position')->pluck('id', 'name')->all(); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses/reorder', [ + 'ids' => [$statuses['Done'], $statuses['Review']], + ]); + + $response->assertOk(); + $response->assertJsonPath('data.0.name', 'Done'); + $response->assertJsonPath('data.0.position', 1); + $response->assertJsonPath('data.1.name', 'Review'); + $response->assertJsonPath('data.2.name', 'Backlog'); + $response->assertJsonPath('data.3.name', 'In Progress'); + } + + public function test_reorder_refuses_an_id_from_another_company(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $foreign = $this->makeStatus(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/task-statuses/reorder', ['ids' => [$foreign->id]]) + ->assertNotFound(); + } + + public function test_a_status_holding_tasks_cannot_be_deleted(): void + { + $status = $this->makeStatus(self::COMPANY, ['name' => 'Backlog']); + $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false, 'is_closed' => true]); + $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/task-statuses/'.$status->id) + ->assertStatus(422) + ->assertJsonPath('error', 'status_in_use'); + } + + public function test_the_last_status_of_a_company_cannot_be_deleted(): void + { + $status = $this->makeStatus(self::COMPANY); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/task-statuses/'.$status->id) + ->assertStatus(422) + ->assertJsonPath('error', 'status_in_use'); + } + + public function test_an_empty_status_is_deleted(): void + { + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses'); + $review = TaskStatus::query()->forCompany(self::COMPANY)->where('name', 'Review')->firstOrFail(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/task-statuses/'.$review->id) + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(3, TaskStatus::query()->forCompany(self::COMPANY)->count()); + } + + public function test_a_status_of_another_company_is_not_found(): void + { + $foreign = $this->makeStatus(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/task-statuses/'.$foreign->id, ['name' => 'Mine now']) + ->assertNotFound(); + } + + public function test_reading_needs_view_task_and_writing_needs_manage_task_status(): void + { + $status = $this->makeStatus(self::COMPANY); + + $this->authorization->deny(Authorizes::id(Abilities::MANAGE_TASK_STATUS)); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertOk(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses', ['name' => 'Blocked'])->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/task-statuses/'.$status->id, ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/task-statuses/'.$status->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/task-statuses/reorder', ['ids' => [$status->id]])->assertForbidden(); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_TASK)); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/task-statuses')->assertForbidden(); + } +} diff --git a/tests/Feature/TasksApiTest.php b/tests/Feature/TasksApiTest.php new file mode 100644 index 0000000..8928d8e --- /dev/null +++ b/tests/Feature/TasksApiTest.php @@ -0,0 +1,284 @@ +asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'First']); + $second = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Second']); + $elsewhere = $this->asCompany(self::OTHER_COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Theirs']); + + $first->assertCreated()->assertJsonPath('data.number', 1); + $second->assertCreated()->assertJsonPath('data.number', 2); + $elsewhere->assertCreated()->assertJsonPath('data.number', 1); + } + + public function test_a_new_task_lands_in_the_default_column_and_takes_the_projects_customer(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => self::CUSTOMER]); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', [ + 'name' => 'Landing page', + 'project_id' => $project->id, + 'priority' => Task::PRIORITY_HIGH, + 'estimated_minutes' => 120, + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.customer_id', self::CUSTOMER); + $response->assertJsonPath('data.creator_id', self::DEFAULT_USER); + $response->assertJsonPath('data.billable', true); + $response->assertJsonPath('data.priority', Task::PRIORITY_HIGH); + $response->assertJsonPath('data.closed_at', null); + + $default = TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->firstOrFail(); + $response->assertJsonPath('data.task_status_id', (int) $default->id); + } + + public function test_a_standalone_task_can_carry_its_own_customer(): void + { + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Ad hoc call', 'customer_id' => self::CUSTOMER]) + ->assertCreated() + ->assertJsonPath('data.project_id', null) + ->assertJsonPath('data.customer_id', self::CUSTOMER); + } + + public function test_entering_a_closed_column_stamps_closed_at_and_leaving_it_clears_it(): void + { + $open = $this->makeStatus(self::COMPANY, ['name' => 'Backlog']); + $done = $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false, 'is_closed' => true]); + $task = $this->makeTask(self::COMPANY, ['task_status_id' => $open->id]); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['task_status_id' => $done->id]) + ->assertOk() + ->assertJsonPath('data.task_status_id', (int) $done->id); + + self::assertNotNull(Task::query()->findOrFail($task->id)->closed_at); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['task_status_id' => $open->id]) + ->assertOk() + ->assertJsonPath('data.closed_at', null); + } + + public function test_the_list_filters_by_project_assignee_status_due_date_and_text(): void + { + $project = $this->makeProject(self::COMPANY); + $status = $this->makeStatus(self::COMPANY); + $other = $this->makeStatus(self::COMPANY, ['name' => 'Done', 'position' => 2, 'is_default' => false]); + + $landing = $this->makeTask(self::COMPANY, [ + 'task_status_id' => $status->id, + 'project_id' => $project->id, + 'assignee_id' => 8, + 'name' => 'Landing page', + 'due_date' => '2026-09-10', + ]); + $pricing = $this->makeTask(self::COMPANY, [ + 'task_status_id' => $other->id, + 'name' => 'Pricing table', + 'due_date' => '2026-10-10', + ]); + + $this->assertListReturns([$landing->id], '?project_id='.$project->id); + $this->assertListReturns([$landing->id], '?assignee_id=8'); + $this->assertListReturns([$pricing->id], '?task_status_id='.$other->id); + $this->assertListReturns([$landing->id], '?due_before=2026-09-30'); + $this->assertListReturns([$pricing->id], '?due_after=2026-09-30'); + $this->assertListReturns([$pricing->id], '?search=Pricing'); + } + + public function test_the_list_is_paged_and_never_leaves_the_company(): void + { + $this->makeTask(self::COMPANY, ['name' => 'Mine']); + $this->makeTask(self::OTHER_COMPANY, ['name' => 'Theirs']); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks') + ->assertOk() + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('meta.per_page', 15) + ->assertJsonPath('data.0.name', 'Mine'); + } + + public function test_a_move_lands_between_its_new_neighbours(): void + { + $status = $this->makeStatus(self::COMPANY); + $target = $this->makeStatus(self::COMPANY, ['name' => 'In Progress', 'position' => 2, 'is_default' => false]); + + $first = $this->makeTask(self::COMPANY, ['task_status_id' => $target->id, 'board_position' => '1024.0000000000']); + $second = $this->makeTask(self::COMPANY, ['task_status_id' => $target->id, 'board_position' => '2048.0000000000']); + $dragged = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$dragged->id.'/move', [ + 'task_status_id' => $target->id, + 'before_id' => $first->id, + 'after_id' => $second->id, + ]); + + $response->assertOk(); + $response->assertJsonPath('data.task_status_id', (int) $target->id); + $response->assertJsonPath('data.board_position', '1536.0000000000'); + } + + public function test_a_move_to_the_end_of_a_column_appends(): void + { + $status = $this->makeStatus(self::COMPANY); + $last = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'board_position' => '2048.0000000000']); + $dragged = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'board_position' => '1024.0000000000']); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$dragged->id.'/move', [ + 'task_status_id' => $status->id, + 'before_id' => $last->id, + ]) + ->assertOk() + ->assertJsonPath('data.board_position', '3072.0000000000'); + } + + public function test_a_move_refuses_a_neighbour_outside_the_target_column(): void + { + $status = $this->makeStatus(self::COMPANY); + $target = $this->makeStatus(self::COMPANY, ['name' => 'In Progress', 'position' => 2, 'is_default' => false]); + $stranger = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + $dragged = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id]); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/tasks/'.$dragged->id.'/move', [ + 'task_status_id' => $target->id, + 'before_id' => $stranger->id, + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['before_id']); + } + + public function test_the_board_groups_tasks_into_columns_in_board_order(): void + { + $backlog = $this->makeStatus(self::COMPANY, ['name' => 'Backlog']); + $doing = $this->makeStatus(self::COMPANY, ['name' => 'In Progress', 'position' => 2, 'is_default' => false]); + + $second = $this->makeTask(self::COMPANY, ['task_status_id' => $backlog->id, 'name' => 'Second', 'board_position' => '2048.0000000000']); + $first = $this->makeTask(self::COMPANY, ['task_status_id' => $backlog->id, 'name' => 'First', 'board_position' => '1024.0000000000']); + $this->makeTask(self::COMPANY, ['task_status_id' => $doing->id, 'name' => 'Underway']); + $this->makeTask(self::OTHER_COMPANY, ['name' => 'Theirs']); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/board'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); + $response->assertJsonPath('data.0.status.name', 'Backlog'); + $response->assertJsonPath('data.0.tasks.0.id', (int) $first->id); + $response->assertJsonPath('data.0.tasks.1.id', (int) $second->id); + $response->assertJsonPath('data.1.status.name', 'In Progress'); + $response->assertJsonPath('data.1.tasks.0.name', 'Underway'); + } + + public function test_the_board_seeds_the_default_columns_and_filters_by_project_and_assignee(): void + { + $project = $this->makeProject(self::COMPANY); + + $response = $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/board'); + + $response->assertOk(); + $response->assertJsonCount(4, 'data'); + $response->assertJsonPath('data.0.status.name', 'Backlog'); + + $status = TaskStatus::query()->forCompany(self::COMPANY)->where('is_default', true)->firstOrFail(); + $mine = $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'project_id' => $project->id, 'assignee_id' => 8]); + $this->makeTask(self::COMPANY, ['task_status_id' => $status->id, 'name' => 'Unassigned']); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/board?project_id='.$project->id) + ->assertJsonCount(1, 'data.0.tasks') + ->assertJsonPath('data.0.tasks.0.id', (int) $mine->id); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/board?assignee_id=8') + ->assertJsonCount(1, 'data.0.tasks') + ->assertJsonPath('data.0.tasks.0.id', (int) $mine->id); + } + + public function test_deleting_a_task_takes_its_uninvoiced_time_with_it(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id) + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(0, Task::query()->forCompany(self::COMPANY)->count()); + } + + public function test_deleting_a_task_with_invoiced_time_is_refused(): void + { + $task = $this->makeTask(self::COMPANY); + $this->makeEntry(self::COMPANY, (int) $task->id, ['invoice_id' => 77, 'invoice_item_id' => 88]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id) + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + } + + public function test_a_task_of_another_company_is_not_found(): void + { + $task = $this->makeTask(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertNotFound(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['name' => 'Mine now'])->assertNotFound(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertNotFound(); + } + + public function test_every_action_refuses_without_its_ability(): void + { + $task = $this->makeTask(self::COMPANY); + + $this->authorization->deny( + Authorizes::id(Abilities::VIEW_TASK), + Authorizes::id(Abilities::CREATE_TASK), + Authorizes::id(Abilities::EDIT_TASK), + Authorizes::id(Abilities::DELETE_TASK), + ); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks', ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/tasks/'.$task->id, ['name' => 'Nope'])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/tasks/'.$task->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/tasks/'.$task->id.'/move', [ + 'task_status_id' => $task->task_status_id, + ])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/board')->assertForbidden(); + } + + /** @param list $expected */ + private function assertListReturns(array $expected, string $query): void + { + $ids = $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/tasks'.$query) + ->assertOk() + ->json('data.*.id'); + + self::assertSame(array_map(intval(...), $expected), $ids); + } +} diff --git a/tests/Feature/TimeEntriesApiTest.php b/tests/Feature/TimeEntriesApiTest.php new file mode 100644 index 0000000..aeb48f7 --- /dev/null +++ b/tests/Feature/TimeEntriesApiTest.php @@ -0,0 +1,259 @@ +taskOnProjectAt(6000); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 10:30:00', + 'description' => 'Pairing on the importer', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.user_id', self::DEFAULT_USER); + $response->assertJsonPath('data.duration_minutes', 90); + $response->assertJsonPath('data.rate', 6000); + $response->assertJsonPath('data.amount', 9000); + $response->assertJsonPath('data.is_running', false); + $response->assertJsonPath('data.description', 'Pairing on the importer'); + } + + public function test_the_company_rounding_increment_is_applied_when_the_entry_is_saved(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $task = $this->taskOnProjectAt(6000); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'started_at' => '2026-09-15 09:00:00', + 'ended_at' => '2026-09-15 09:20:00', + ]) + ->assertCreated() + ->assertJsonPath('data.duration_minutes', 15) + ->assertJsonPath('data.amount', 1500); + } + + public function test_the_rate_is_frozen_at_save_and_survives_a_later_project_rate_change(): void + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42, 'default_rate' => 6000]); + $task = $this->makeTask(self::COMPANY, ['project_id' => $project->id]); + + $entry = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task->id, + 'duration_minutes' => 60, + ])->assertCreated()->json('data.id'); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/projects/'.$project->id, ['default_rate' => 9000]) + ->assertOk(); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries/'.$entry) + ->assertOk() + ->assertJsonPath('data.rate', 6000) + ->assertJsonPath('data.amount', 6000); + } + + public function test_the_list_filters_and_never_shows_a_running_entry(): void + { + $task = $this->taskOnProjectAt(6000); + $this->makeEntry(self::COMPANY, $task, ['duration_minutes' => 30]); + $this->makeEntry(self::COMPANY, $task, ['billable' => false, 'duration_minutes' => 45]); + $this->makeEntry(self::COMPANY, $task, ['invoice_id' => 77, 'invoice_item_id' => 88]); + $this->makeEntry(self::COMPANY, $task, ['running_user_id' => self::DEFAULT_USER, 'ended_at' => null]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertOk() + ->assertJsonPath('meta.total', 3); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?billed=1') + ->assertJsonPath('meta.total', 1); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?billed=0') + ->assertJsonPath('meta.total', 2); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?billable=0') + ->assertJsonPath('meta.total', 1); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?task_id='.$task.'&from=2026-09-01&to=2026-09-01') + ->assertJsonPath('meta.total', 3); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?from=2026-09-02') + ->assertJsonPath('meta.total', 0); + } + + public function test_without_view_all_time_the_list_is_the_callers_own_rows(): void + { + $task = $this->taskOnProjectAt(6000); + $this->makeEntry(self::COMPANY, $task); + $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 2); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries?user_id='.self::OTHER_USER) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.user_id', self::DEFAULT_USER); + } + + public function test_the_company_setting_opens_the_timesheet_without_the_ability(): void + { + $task = $this->taskOnProjectAt(6000); + $this->makeEntry(self::COMPANY, $task); + $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'members_see_all_time', true); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 2); + } + + public function test_another_members_entry_is_invisible_without_the_ability_or_the_setting(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertOk(); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_ALL_TIME)); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertForbidden(); + } + + public function test_writing_over_another_members_entry_needs_edit_all_time(): void + { + $task = $this->taskOnProjectAt(6000); + $mine = $this->makeEntry(self::COMPANY, $task); + $theirs = $this->makeEntry(self::COMPANY, $task, ['user_id' => self::OTHER_USER]); + + $this->authorization->deny(Authorizes::id(Abilities::EDIT_ALL_TIME)); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/time-entries/'.$mine->id, ['duration_minutes' => 30]) + ->assertOk() + ->assertJsonPath('data.duration_minutes', 30); + + $this->asCompany(self::COMPANY) + ->putJson('/api/v1/tasks-projects/time-entries/'.$theirs->id, ['duration_minutes' => 30]) + ->assertForbidden(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/time-entries/'.$theirs->id) + ->assertForbidden(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'user_id' => self::OTHER_USER, + 'duration_minutes' => 30, + ]) + ->assertForbidden(); + } + + public function test_logging_time_for_someone_else_is_allowed_with_edit_all_time(): void + { + $task = $this->taskOnProjectAt(6000); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/time-entries', [ + 'task_id' => $task, + 'user_id' => self::OTHER_USER, + 'duration_minutes' => 60, + ]) + ->assertCreated() + ->assertJsonPath('data.user_id', self::OTHER_USER); + } + + public function test_an_invoiced_entry_cannot_be_deleted(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task, ['invoice_id' => 77, 'invoice_item_id' => 88]); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertStatus(422) + ->assertJsonPath('error', 'entries_already_invoiced'); + + self::assertNotNull(TimeEntry::query()->find($entry->id)); + } + + public function test_an_uninvoiced_entry_is_deleted(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id) + ->assertOk(); + + self::assertNull(TimeEntry::query()->find($entry->id)); + } + + public function test_an_entry_of_another_company_is_not_found(): void + { + $task = $this->makeTask(self::OTHER_COMPANY); + $entry = $this->makeEntry(self::OTHER_COMPANY, (int) $task->id); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertNotFound(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/time-entries/'.$entry->id, ['duration_minutes' => 1])->assertNotFound(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertNotFound(); + } + + public function test_everything_here_needs_view_own_time(): void + { + $task = $this->taskOnProjectAt(6000); + $entry = $this->makeEntry(self::COMPANY, $task); + + $this->authorization->deny(Authorizes::id(Abilities::VIEW_OWN_TIME)); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/time-entries')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/time-entries', ['task_id' => $task])->assertForbidden(); + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertForbidden(); + $this->asCompany(self::COMPANY)->putJson('/api/v1/tasks-projects/time-entries/'.$entry->id, ['duration_minutes' => 1])->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/time-entries/'.$entry->id)->assertForbidden(); + } + + private function taskOnProjectAt(int $rate): int + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42, 'default_rate' => $rate, 'currency_id' => 3]); + + return (int) $this->makeTask(self::COMPANY, ['project_id' => $project->id])->id; + } +} diff --git a/tests/Feature/TimerApiTest.php b/tests/Feature/TimerApiTest.php new file mode 100644 index 0000000..1d6e618 --- /dev/null +++ b/tests/Feature/TimerApiTest.php @@ -0,0 +1,168 @@ +asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertOk() + ->assertExactJson(['data' => null]); + } + + public function test_starting_opens_an_entry_that_carries_no_money_yet(): void + { + Carbon::setTestNow('2026-09-15 09:00:00'); + $task = $this->taskOnProjectAt(6000); + + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', [ + 'task_id' => $task, + 'description' => 'Fixing the importer', + ]); + + $response->assertCreated(); + $response->assertJsonPath('data.is_running', true); + $response->assertJsonPath('data.user_id', self::DEFAULT_USER); + $response->assertJsonPath('data.duration_minutes', 0); + $response->assertJsonPath('data.amount', 0); + $response->assertJsonPath('data.ended_at', null); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertOk() + ->assertJsonPath('data.description', 'Fixing the importer'); + } + + public function test_a_second_start_is_a_conflict(): void + { + $task = $this->taskOnProjectAt(6000); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertCreated(); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task]) + ->assertStatus(409) + ->assertJsonPath('error', 'timer_already_running'); + } + + public function test_stopping_rounds_the_elapsed_time_and_freezes_the_rate(): void + { + $this->settings->putCompany(self::COMPANY, ModuleSettings::PREFIX.'rounding_minutes', 15); + $task = $this->taskOnProjectAt(6000); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertCreated(); + + Carbon::setTestNow('2026-09-15 09:50:00'); + $response = $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop'); + + $response->assertOk(); + $response->assertJsonPath('data.is_running', false); + $response->assertJsonPath('data.duration_minutes', 45); + $response->assertJsonPath('data.rate', 6000); + $response->assertJsonPath('data.amount', 4500); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertExactJson(['data' => null]); + } + + public function test_the_stopped_entry_joins_the_timesheet(): void + { + $task = $this->taskOnProjectAt(6000); + + Carbon::setTestNow('2026-09-15 09:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task]); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 0); + + Carbon::setTestNow('2026-09-15 10:00:00'); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop')->assertOk(); + + $this->asCompany(self::COMPANY) + ->getJson('/api/v1/tasks-projects/time-entries') + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.duration_minutes', 60); + } + + public function test_discarding_throws_the_running_entry_away(): void + { + $task = $this->taskOnProjectAt(6000); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertCreated(); + + $this->asCompany(self::COMPANY) + ->deleteJson('/api/v1/tasks-projects/timer') + ->assertOk() + ->assertJson(['success' => true]); + + self::assertSame(0, TimeEntry::query()->forCompany(self::COMPANY)->count()); + } + + public function test_stopping_or_discarding_an_idle_timer_is_not_found(): void + { + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop')->assertNotFound(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/timer')->assertNotFound(); + } + + public function test_a_task_of_another_company_cannot_be_timed(): void + { + $task = $this->makeTask(self::OTHER_COMPANY); + + $this->asCompany(self::COMPANY) + ->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task->id]) + ->assertNotFound(); + } + + public function test_the_running_timer_belongs_to_one_company_at_a_time(): void + { + $here = $this->taskOnProjectAt(6000); + $there = (int) $this->makeTask(self::OTHER_COMPANY)->id; + + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $here])->assertCreated(); + + $this->asCompany(self::OTHER_COMPANY) + ->getJson('/api/v1/tasks-projects/timer') + ->assertExactJson(['data' => null]); + + $this->asCompany(self::OTHER_COMPANY) + ->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $there]) + ->assertCreated(); + + self::assertSame(1, TimeEntry::query()->forCompany(self::COMPANY)->whereNotNull('running_user_id')->count()); + self::assertSame(1, TimeEntry::query()->forCompany(self::OTHER_COMPANY)->whereNotNull('running_user_id')->count()); + } + + public function test_the_timer_needs_the_own_time_ability(): void + { + $task = $this->taskOnProjectAt(6000); + $this->authorization->deny(Authorizes::id(Abilities::VIEW_OWN_TIME)); + + $this->asCompany(self::COMPANY)->getJson('/api/v1/tasks-projects/timer')->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/start', ['task_id' => $task])->assertForbidden(); + $this->asCompany(self::COMPANY)->postJson('/api/v1/tasks-projects/timer/stop')->assertForbidden(); + $this->asCompany(self::COMPANY)->deleteJson('/api/v1/tasks-projects/timer')->assertForbidden(); + } + + private function taskOnProjectAt(int $rate): int + { + $project = $this->makeProject(self::COMPANY, ['customer_id' => 42, 'default_rate' => $rate, 'currency_id' => 3]); + + return (int) $this->makeTask(self::COMPANY, ['project_id' => $project->id])->id; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index aeb2c0c..d037519 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,12 +4,18 @@ namespace Modules\TasksProjects\Tests; +use Illuminate\Auth\GenericUser; +use Illuminate\Contracts\Debug\ExceptionHandler; +use Illuminate\Foundation\Exceptions\Handler; +use Illuminate\Routing\Router; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Auth; use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader; use InvoiceShelf\Modules\Contracts\Host\ModuleAuthorization; use InvoiceShelf\Modules\Contracts\Host\SettingsStore; use InvoiceShelf\Modules\InvoiceShelfModulesServiceProvider; use InvoiceShelf\Modules\Registry; +use Modules\TasksProjects\Http\DomainExceptionRenderer; use Modules\TasksProjects\Models\Project; use Modules\TasksProjects\Models\ProjectMember; use Modules\TasksProjects\Models\Task; @@ -23,6 +29,9 @@ abstract class TestCase extends Orchestra { + /** The user every request acts as until a test says otherwise. */ + public const DEFAULT_USER = 7; + protected MemorySettingsStore $settings; protected MemoryCompanyDataReader $companyData; @@ -40,6 +49,17 @@ protected function setUp(): void $this->app->instance(SettingsStore::class, $this->settings); $this->app->instance(CompanyDataReader::class, $this->companyData); $this->app->instance(ModuleAuthorization::class, $this->authorization); + + $handler = $this->app->make(ExceptionHandler::class); + if ($handler instanceof Handler) { + DomainExceptionRenderer::register($handler); + } + + // `auth:sanctum`, `company` and `bouncer` are host middleware and do not + // exist here, so the harness stands in for all three: it authenticates + // the caller itself and puts the company on the request as a header. + $this->withoutMiddleware(); + $this->actingAsUser(self::DEFAULT_USER); } protected function tearDown(): void @@ -78,6 +98,30 @@ protected function moduleSettings(): ModuleSettings return new ModuleSettings($this->settings); } + /** + * Load the module's own route file, the way the provider does in the host. + * + * @param Router $router + */ + protected function defineRoutes($router): void + { + require dirname(__DIR__).'/routes/api.php'; + } + + /** Authenticate the caller without a host user model. */ + protected function actingAsUser(int $userId): static + { + Auth::setUser(new GenericUser(['id' => $userId])); + + return $this; + } + + /** Send the `company` header the host middleware would have set. */ + protected function asCompany(int $companyId): static + { + return $this->withHeader('company', (string) $companyId); + } + /** @param array $attributes */ protected function makeStatus(int $companyId, array $attributes = []): TaskStatus { diff --git a/tests/Unit/ModuleRoutesTest.php b/tests/Unit/ModuleRoutesTest.php new file mode 100644 index 0000000..223fd52 --- /dev/null +++ b/tests/Unit/ModuleRoutesTest.php @@ -0,0 +1,125 @@ + */ + private const MIDDLEWARE = ['api', 'auth:sanctum', 'company', 'bouncer']; + + public function test_it_registers_the_documented_route_table(): void + { + $routes = array_map( + static fn (Route $route): array => [ + implode('|', array_values(array_diff($route->methods(), ['HEAD']))), + $route->uri(), + (string) $route->getName(), + ], + $this->moduleRoutes(), + ); + + self::assertSame([ + ['GET', 'api/v1/tasks-projects/projects', 'tasks-projects.projects.index'], + ['POST', 'api/v1/tasks-projects/projects', 'tasks-projects.projects.store'], + ['GET', 'api/v1/tasks-projects/projects/{id}', 'tasks-projects.projects.show'], + ['PUT', 'api/v1/tasks-projects/projects/{id}', 'tasks-projects.projects.update'], + ['DELETE', 'api/v1/tasks-projects/projects/{id}', 'tasks-projects.projects.destroy'], + ['POST', 'api/v1/tasks-projects/projects/{id}/archive', 'tasks-projects.projects.archive'], + ['POST', 'api/v1/tasks-projects/projects/{id}/unarchive', 'tasks-projects.projects.unarchive'], + ['GET', 'api/v1/tasks-projects/projects/{id}/members', 'tasks-projects.project-members.index'], + ['POST', 'api/v1/tasks-projects/projects/{id}/members', 'tasks-projects.project-members.store'], + ['DELETE', 'api/v1/tasks-projects/projects/{id}/members/{userId}', 'tasks-projects.project-members.destroy'], + ['GET', 'api/v1/tasks-projects/members', 'tasks-projects.members.index'], + ['GET', 'api/v1/tasks-projects/tasks', 'tasks-projects.tasks.index'], + ['POST', 'api/v1/tasks-projects/tasks', 'tasks-projects.tasks.store'], + ['GET', 'api/v1/tasks-projects/tasks/{id}', 'tasks-projects.tasks.show'], + ['PUT', 'api/v1/tasks-projects/tasks/{id}', 'tasks-projects.tasks.update'], + ['DELETE', 'api/v1/tasks-projects/tasks/{id}', 'tasks-projects.tasks.destroy'], + ['POST', 'api/v1/tasks-projects/tasks/{id}/move', 'tasks-projects.tasks.move'], + ['GET', 'api/v1/tasks-projects/board', 'tasks-projects.board.index'], + ['GET', 'api/v1/tasks-projects/task-statuses', 'tasks-projects.task-statuses.index'], + ['POST', 'api/v1/tasks-projects/task-statuses', 'tasks-projects.task-statuses.store'], + ['POST', 'api/v1/tasks-projects/task-statuses/reorder', 'tasks-projects.task-statuses.reorder'], + ['PUT', 'api/v1/tasks-projects/task-statuses/{id}', 'tasks-projects.task-statuses.update'], + ['DELETE', 'api/v1/tasks-projects/task-statuses/{id}', 'tasks-projects.task-statuses.destroy'], + ['GET', 'api/v1/tasks-projects/time-entries', 'tasks-projects.time-entries.index'], + ['POST', 'api/v1/tasks-projects/time-entries', 'tasks-projects.time-entries.store'], + ['GET', 'api/v1/tasks-projects/time-entries/{id}', 'tasks-projects.time-entries.show'], + ['PUT', 'api/v1/tasks-projects/time-entries/{id}', 'tasks-projects.time-entries.update'], + ['DELETE', 'api/v1/tasks-projects/time-entries/{id}', 'tasks-projects.time-entries.destroy'], + ['GET', 'api/v1/tasks-projects/timer', 'tasks-projects.timer.show'], + ['DELETE', 'api/v1/tasks-projects/timer', 'tasks-projects.timer.destroy'], + ['POST', 'api/v1/tasks-projects/timer/start', 'tasks-projects.timer.start'], + ['POST', 'api/v1/tasks-projects/timer/stop', 'tasks-projects.timer.stop'], + ['GET', 'api/v1/tasks-projects/billing/unbilled', 'tasks-projects.billing.unbilled'], + ['POST', 'api/v1/tasks-projects/billing/prepare', 'tasks-projects.billing.prepare'], + ['POST', 'api/v1/tasks-projects/billing/confirm', 'tasks-projects.billing.confirm'], + ['GET', 'api/v1/tasks-projects/reports/summary', 'tasks-projects.reports.summary'], + ['GET', 'api/v1/tasks-projects/settings', 'tasks-projects.settings.show'], + ], $routes); + } + + public function test_every_route_sits_behind_the_host_middleware_stack(): void + { + foreach ($this->moduleRoutes() as $route) { + foreach (self::MIDDLEWARE as $middleware) { + self::assertContains( + $middleware, + $route->middleware(), + "Route {$route->uri()} is missing the {$middleware} middleware.", + ); + } + } + } + + public function test_every_route_is_named_and_prefixed_by_the_module_slug(): void + { + foreach ($this->moduleRoutes() as $route) { + self::assertStringStartsWith('tasks-projects.', (string) $route->getName()); + } + } + + public function test_no_controller_of_the_module_is_reachable_outside_the_slug_prefix(): void + { + foreach ($this->app['router']->getRoutes()->getRoutes() as $route) { + $action = $route->getAction('controller'); + + if (! is_string($action) || ! str_starts_with($action, 'Modules\\TasksProjects\\')) { + continue; + } + + self::assertStringStartsWith(self::PREFIX.'/', $route->uri()); + } + } + + /** @return list */ + private function moduleRoutes(): array + { + $routes = []; + + foreach ($this->app['router']->getRoutes()->getRoutes() as $route) { + if (str_starts_with($route->uri(), self::PREFIX)) { + $routes[] = $route; + } + } + + self::assertNotSame([], $routes, 'The module registered no routes at all.'); + + return $routes; + } +}