Conversation
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.
Contributor
Author
|
Superseded by #14, which carries this stack consolidated into three commits on top of main. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-projectsbehind['api', 'auth:sanctum', 'company', 'bouncer'], all namedtasks-projects.<resource>.<action>.GETPOSTprojectsview-project/create-projectGETPUTDELETEprojects/{id}view-project/edit-project/delete-projectPOSTprojects/{id}/archive,projects/{id}/unarchiveedit-projectGETPOSTprojects/{id}/membersedit-projectDELETEprojects/{id}/members/{userId}edit-projectGETmembersview-projectGETPOSTtasksview-task/create-taskGETPUTDELETEtasks/{id}view-task/edit-task/delete-taskPOSTtasks/{id}/moveedit-taskGETboardview-taskGETtask-statusesview-taskPOSTPUTDELETEtask-statuses,task-statuses/{id},task-statuses/reordermanage-task-statusGETPOSTtime-entriesview-own-timeGETPUTDELETEtime-entries/{id}view-own-time, plus the rules belowGETDELETEtimerview-own-timePOSTtimer/start,timer/stopview-own-timeGETbilling/unbilledinvoice-tasksPOSTbilling/prepare,billing/confirminvoice-tasksGETreports/summaryview-own-time, scoped byview-all-timeGETsettingsview-projectList filters: projects by
status,customer_id,member_id,search; tasks byproject_id,assignee_id,task_status_id,customer_id,due_before,due_after,search; time entries byuser_id,project_id,task_id,from,to,billable,billed. Lists page through?limit=, defaulted to 15 and clamped at 100, and return Laravel's standarddataplusmeta. 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/invoicesfrom the browser), soGET settingsis read-only here and the module never writes to a host table.Authorization
Support\AuthorizeswrapsContracts\Host\ModuleAuthorization::allows(), namespacing the bare ability name astasks-projects:{ability}, which is exactly the idRegistry::registerAbility()stores and the role editor grants. A refusal raises the framework'sAuthorizationExceptionand renders 403.Company and user come from
Support\CompanyContext: the company from thecompanyheader 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:
user_id = <caller>unless the caller hasview-all-timeor the company hasmembers_see_all_timeturned on. The same test decides whether another member's entry is visible at all.edit-all-time, and so does logging time on their behalf.reports/summaryneedsview-own-timeand covers the caller's own time alone untilview-all-timewidens it.Error mapping
The services throw rather than return errors, and everything they throw extends
TasksProjectsException, so onerenderableregistered by the provider covers all of it and no controller carries a try/catch. The body is{ "message": ..., "error": "<snake_case_key>" }.errorTimerAlreadyRunningtimer_already_runningMixedBillingSelectionmixed_billing_selectionEntriesAlreadyInvoicedentries_already_invoicedUnknownTimeEntriesunknown_time_entriesNotBillablenot_billableStatusInUsestatus_in_useProjectInUseproject_in_useA running timer is a conflict rather than a validation failure because the first timer is still perfectly valid; the UI offers to stop it.
ModelNotFoundExceptionrenders 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, andModuleRoutesTest, which pins the whole route table, its middleware stack and the slug prefix.auth:sanctum,companyandbouncerare host middleware and do not exist under Testbench, so the harness stands in for all three: it loadsroutes/api.phpindefineRoutes, drops middleware, authenticates aGenericUserand sends thecompanyheader. 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
billing/prepareis serialised withJSON_PRESERVE_ZERO_FRACTION, soquantitystays the two-decimal number of hours the preview showed instead of collapsing to an integer on the way out.POST projects/{id}/membersvalidates the user againstCompanyDataReader::companyMembers. Form request validation runs before the controller's ability check, so a caller withoutedit-projectsees a 422 on an unknown user id rather than a 403. Both are refusals; the ordering is Laravel's.identifier32,colour16,priority16), so a long value is a validation error rather than a database one.{"success": true}, matching the host.https://claude.ai/code/session_01DCf36XDKprZifej8dc2r1E