Skip to content

feat: HTTP API and feature tests (M3) - #2

Closed
gdarko wants to merge 3 commits into
feat/data-and-servicesfrom
feat/api
Closed

gdarko wants to merge 3 commits into
feat/data-and-servicesfrom
feat/api

Conversation

@gdarko

@gdarko gdarko commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Milestone M3 of the Tasks and Projects spec: the HTTP surface over the services that landed in feat/data-and-services, plus a feature test for every route.

Based on feat/data-and-services; review that one first.

Endpoints

Thirty-seven routes, all under api/v1/tasks-projects behind ['api', 'auth:sanctum', 'company', 'bouncer'], all named tasks-projects.<resource>.<action>.

Method Path Ability
GET POST projects view-project / create-project
GET PUT DELETE projects/{id} view-project / edit-project / delete-project
POST projects/{id}/archive, projects/{id}/unarchive edit-project
GET POST projects/{id}/members edit-project
DELETE projects/{id}/members/{userId} edit-project
GET members view-project
GET POST tasks view-task / create-task
GET PUT DELETE tasks/{id} view-task / edit-task / delete-task
POST tasks/{id}/move edit-task
GET board view-task
GET task-statuses view-task
POST PUT DELETE task-statuses, task-statuses/{id}, task-statuses/reorder manage-task-status
GET POST time-entries view-own-time
GET PUT DELETE time-entries/{id} view-own-time, plus the rules below
GET DELETE timer view-own-time
POST timer/start, timer/stop view-own-time
GET billing/unbilled invoice-tasks
POST billing/prepare, billing/confirm invoice-tasks
GET reports/summary view-own-time, scoped by view-all-time
GET settings view-project

List filters: projects by status, customer_id, member_id, search; tasks by project_id, assignee_id, task_status_id, customer_id, due_before, due_after, search; time entries by user_id, project_id, task_id, from, to, billable, billed. Lists page through ?limit=, defaulted to 15 and clamped at 100, and return Laravel's standard data plus meta. The board, the column list and the member picker are not paged: they are whole-shape reads.

Writes go through the host for settings (PUT /api/v1/modules/tasks-projects/settings) and for invoices (POST /api/v1/invoices from the browser), so GET settings is read-only here and the module never writes to a host table.

Authorization

Support\Authorizes wraps Contracts\Host\ModuleAuthorization::allows(), namespacing the bare ability name as tasks-projects:{ability}, which is exactly the id Registry::registerAbility() stores and the role editor grants. A refusal raises the framework's AuthorizationException and renders 403.

Company and user come from Support\CompanyContext: the company from the company header the host middleware set, never from a request parameter, and the user through the framework contract, so no host model is imported. Every lookup goes through the services' findForCompany, so a row belonging to another company is a 404 and not a leak. There is no route model binding.

Time has three extra rules:

  • A list shows only user_id = <caller> unless the caller has view-all-time or the company has members_see_all_time turned on. The same test decides whether another member's entry is visible at all.
  • Editing or deleting someone else's entry needs edit-all-time, and so does logging time on their behalf.
  • reports/summary needs view-own-time and covers the caller's own time alone until view-all-time widens it.

Error mapping

The services throw rather than return errors, and everything they throw extends TasksProjectsException, so one renderable registered by the provider covers all of it and no controller carries a try/catch. The body is { "message": ..., "error": "<snake_case_key>" }.

Exception Status error
TimerAlreadyRunning 409 timer_already_running
MixedBillingSelection 422 mixed_billing_selection
EntriesAlreadyInvoiced 422 entries_already_invoiced
UnknownTimeEntries 422 unknown_time_entries
NotBillable 422 not_billable
StatusInUse 422 status_in_use
ProjectInUse 422 project_in_use

A running timer is a conflict rather than a validation failure because the first timer is still perfectly valid; the UI offers to stop it. ModelNotFoundException renders 404 and validation renders 422 through the framework's own handling.

Tests

223 tests, 882 assertions, green. 98 are new: ProjectsApiTest, MembersApiTest, TaskStatusesApiTest, TasksApiTest, TimeEntriesApiTest, TimerApiTest, BillingApiTest, ReportsApiTest, SettingsApiTest, and ModuleRoutesTest, which pins the whole route table, its middleware stack and the slug prefix.

auth:sanctum, company and bouncer are host middleware and do not exist under Testbench, so the harness stands in for all three: it loads routes/api.php in defineRoutes, drops middleware, authenticates a GenericUser and sends the company header. Booting the module provider instead would drag in the nwidart module config that Testbench has no reason to carry, so the provider's one piece of HTTP wiring, the exception renderable, is registered through the same helper the provider calls.

Worth knowing

  • Two list filters the API promises were missing from the services and are added here: a project text search over name and identifier, and the task list's due-date range. Both mirror the filters already there.
  • Pages are cut from the collection a service returns rather than by a second query, because the services own every query and hand back whole collections. One company's projects, tasks and time entries is the scale that fits comfortably.
  • billing/prepare is serialised with JSON_PRESERVE_ZERO_FRACTION, so quantity stays the two-decimal number of hours the preview showed instead of collapsing to an integer on the way out.
  • POST projects/{id}/members validates the user against CompanyDataReader::companyMembers. Form request validation runs before the controller's ability check, so a caller without edit-project sees a 422 on an unknown user id rather than a 403. Both are refusals; the ordering is Laravel's.
  • String rules follow the actual column widths where they are narrower than 255 (identifier 32, colour 16, priority 16), so a long value is a validation error rather than a database one.
  • Creates return 201 through Laravel's own resource response. Deletes return {"success": true}, matching the host.

https://claude.ai/code/session_01DCf36XDKprZifej8dc2r1E

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.
Thirty-seven routes under `api/v1/tasks-projects`, all behind the host
stack, all named `tasks-projects.<resource>.<action>`: 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.
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.
@gdarko

gdarko commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #14, which carries this stack consolidated into three commits on top of main.

@gdarko gdarko closed this Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant