Skip to main content

CRM API

All CRM endpoints live under the /api/crm/ prefix and require a Bearer token validated by VerifyOAuthToken. Each route is also gated by CheckPermission via the can:permission_name middleware.

See permissions.md for the permission FQN syntax and the full catalogue. The full live permission list is generated by php artisan crm:list-permissions --fqn --json. CI runs the same command and diffs against the identity-side PolicySeeder so permission drift is caught at PR time (CRM audit cross-repo #38).

OpenAPI surface

Specdedoc/scramble (/docs/api.json per deployment)
Aggregatetenant-frontend/swagger/crm.json
Client@hey-api/openapi-tssrc/api/generated/crm/
Regennpm run api:sync (export + generate)
Drift gatenpm run api:check (CI fails if generated SDK is stale; cross-repo #37)

The frontend imports typed SDK functions, e.g.:

import { contactIndex } from '@/api/generated/crm/sdk.gen'
import { crmClient } from '@/api/client'

const { data } = await contactIndex({ client: crmClient, query: { per_page: 25 } })

Pinia stores never reach for raw axios — the crmClient (built on @hey-api/client-axios) carries the Bearer token and locale headers, and the generated SDK gives the call site request + response types.

Resource map

Core CRM (contacts, companies, communication)

MethodPathControllerPermission
GET / POST/contactsContactController@index/storecontacts.list / contacts.create
GET / PUT / DELETE/contacts/{id}ContactController@show/update/destroycontacts.{view,update,delete}
PUT/contacts/{id}/tagsContactController@syncTagscontacts.update
GET / POST/companiesCompanyController@index/storecompanies.list / companies.create
GET / PUT / DELETE/companies/{id}CompanyController@show/update/destroycompanies.{view,update,delete}
GET / POST/companies/{id}/addressesCompanyController@fetchAddresses/createAddresscompanies.update
GET / POST/communication-logsCommunicationLogControllercommunication_logs.{list,create}

HR / Tasks / Projects

MethodPathControllerPermission
GET/tasks (with project_id)TaskController@indextasks.list
GET/tasks/board (with project_id)TaskController@boardtasks.list
POST / PUT / DELETE/tasks/{id}TaskController@store/update/destroytasks.{create,update,delete}
POST/tasks/reorderTaskController@reordertasks.update
GET / POST/projectsProjectControllerprojects.{list,create}
POST / DELETE/projects/{id}/members[/{userId}]ProjectController@addMember/removeMemberprojects.update
GET / POST/time-entriesTimeEntryControllertime_entries.{list,create}
PUT / DELETE/time-entries/{id}TimeEntryController@update/destroytime_entries.{update,delete}
GET/time-entries/exportTimeEntryController@exporttime_entries.export
GET / POST/leave-requestsLeaveRequestControllerleave_requests.{list,create}
POST/leave-requests/{id}/{approve,reject}LeaveRequestControllerleave_requests.review

The TimeEntry per-action permission split (MAGAS #50) lets a role give "view + record own time" without granting bulk export.

Documents

MethodPathPermission
GET / POST/documentsdocuments.{list,create}
PUT / DELETE/documents/{id}documents.{update,delete}
POST/documents/movedocuments.update
POST/documents/{id}/versionsdocuments.update
GET/documents/{id}/downloaddocuments.view
GET / POST/document-foldersdocument_folders.{list,create}
POST / DELETE/document-folders/{id}/members[/{memberId}]document_folders.update

Folder access is enforced via DocumentController::ensureFolderAccess() (KRITIKUS #4, #8) — explicit-deny on missing folder, source+target write checks on move.

Chat

After the F3 split, the chat surface is spread across 5 controllers:

ControllerEndpoints
ChatControllerchannel CRUD, message CRUD/update/delete/recall
ChatFileControllerPOST /channels/{id}/upload, GET /channels/{id}/messages/{mid}/file
ChatParticipantControllerparticipant add/remove, POST /channels/{id}/read
ChatBotMembershipControllerbot add/remove
ChatReactionControllerPOST /channels/{id}/messages/{mid}/reactions

All chat endpoints gate on chat.access plus the verb-specific permission. File upload + serve has additional path-traversal hardening (MAGAS #49).

Knowledge Base + Content

MethodPathPermission
GET / POST/kb-articleskb_articles.{list,create}
POST/kb-articles/{id}/{publish,unpublish}kb_articles.publish
POST/kb-articles/{id}/commentskb_articles.comment
GET / POST/blog-postsblog.posts.{list,create}
POST/blog-posts/{id}/{publish,unpublish}blog.posts.publish
GET / POST/pagespages.{list,create}
POST/pages/{id}/{publish,unpublish}pages.publish

Publish/unpublish writes an ActivityLog row (compliance trail, MAGAS #51) with the impersonator id when applicable (cross-repo #36).

System

MethodPathAuth
POST /system/sms/accounts and friendsVerifySystemToken (constant-time, hash_equals; cross-repo #40) — Bearer NOT required
POST /webhooks/bot and friendsVerifyBotToken (per-(IP, token-prefix) RateLimiter; MAGAS #47)

Pagination

Every paginated index endpoint caps per_page at 100 (default 25) via min(max((int) $request->input('per_page', 25), 1), 100) — see MAGAS #60. Responses follow Laravel's pagination shape:

{
"data": [...],
"meta": { "current_page": 1, "last_page": 8, "per_page": 25, "total": 192 },
"links": { "first": "...", "last": "...", "prev": null, "next": "..." }
}

?search=foo is supported on Contact/Company/Task/Document/KbArticle index endpoints. Backend uses App\Support\Search::whereLike() which:

  • On Postgres: unaccent(lower(column)) LIKE unaccent(lower('%foo%')) (diacritic-insensitive — "arvai" matches "Árvai"; MAGAS #61)
  • On sqlite/mysql: falls back to ilike '%foo%'

Error shape

CheckPermission returns structured 4xx payloads (MAGAS #42):

{
"message": "Insufficient permissions.",
"error": "permission_denied",
"required_permission": "tenant.acme.crm.tasks.update",
"resource_type": "tasks",
"resource_id": "abc-123",
"user_id": "42"
}

503 identity_unavailable is returned when the identity backend is sick (timeout, 5xx, connect refused) — the SPA should NOT log out on this status (cross-repo #19, #31). 401 stays for genuine token rejection only.

Generated SDK regen workflow

# In tenant-frontend/, with every backend running locally on its
# expected port:
npm run api:sync # curl swagger/*.json + run openapi-ts

# CI:
npm run api:check # api:sync + git diff --exit-code; fail if stale