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
| Spec | dedoc/scramble (/docs/api.json per deployment) |
| Aggregate | tenant-frontend/swagger/crm.json |
| Client | @hey-api/openapi-ts → src/api/generated/crm/ |
| Regen | npm run api:sync (export + generate) |
| Drift gate | npm 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)
| Method | Path | Controller | Permission |
|---|---|---|---|
| GET / POST | /contacts | ContactController@index/store | contacts.list / contacts.create |
| GET / PUT / DELETE | /contacts/{id} | ContactController@show/update/destroy | contacts.{view,update,delete} |
| PUT | /contacts/{id}/tags | ContactController@syncTags | contacts.update |
| GET / POST | /companies | CompanyController@index/store | companies.list / companies.create |
| GET / PUT / DELETE | /companies/{id} | CompanyController@show/update/destroy | companies.{view,update,delete} |
| GET / POST | /companies/{id}/addresses | CompanyController@fetchAddresses/createAddress | companies.update |
| GET / POST | /communication-logs | CommunicationLogController | communication_logs.{list,create} |
HR / Tasks / Projects
| Method | Path | Controller | Permission |
|---|---|---|---|
| GET | /tasks (with project_id) | TaskController@index | tasks.list |
| GET | /tasks/board (with project_id) | TaskController@board | tasks.list |
| POST / PUT / DELETE | /tasks/{id} | TaskController@store/update/destroy | tasks.{create,update,delete} |
| POST | /tasks/reorder | TaskController@reorder | tasks.update |
| GET / POST | /projects | ProjectController | projects.{list,create} |
| POST / DELETE | /projects/{id}/members[/{userId}] | ProjectController@addMember/removeMember | projects.update |
| GET / POST | /time-entries | TimeEntryController | time_entries.{list,create} |
| PUT / DELETE | /time-entries/{id} | TimeEntryController@update/destroy | time_entries.{update,delete} |
| GET | /time-entries/export | TimeEntryController@export | time_entries.export |
| GET / POST | /leave-requests | LeaveRequestController | leave_requests.{list,create} |
| POST | /leave-requests/{id}/{approve,reject} | LeaveRequestController | leave_requests.review |
The TimeEntry per-action permission split (MAGAS #50) lets a role give "view + record own time" without granting bulk export.
Documents
| Method | Path | Permission |
|---|---|---|
| GET / POST | /documents | documents.{list,create} |
| PUT / DELETE | /documents/{id} | documents.{update,delete} |
| POST | /documents/move | documents.update |
| POST | /documents/{id}/versions | documents.update |
| GET | /documents/{id}/download | documents.view |
| GET / POST | /document-folders | document_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:
| Controller | Endpoints |
|---|---|
ChatController | channel CRUD, message CRUD/update/delete/recall |
ChatFileController | POST /channels/{id}/upload, GET /channels/{id}/messages/{mid}/file |
ChatParticipantController | participant add/remove, POST /channels/{id}/read |
ChatBotMembershipController | bot add/remove |
ChatReactionController | POST /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
| Method | Path | Permission |
|---|---|---|
| GET / POST | /kb-articles | kb_articles.{list,create} |
| POST | /kb-articles/{id}/{publish,unpublish} | kb_articles.publish |
| POST | /kb-articles/{id}/comments | kb_articles.comment |
| GET / POST | /blog-posts | blog.posts.{list,create} |
| POST | /blog-posts/{id}/{publish,unpublish} | blog.posts.publish |
| GET / POST | /pages | pages.{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
| Method | Path | Auth |
|---|---|---|
POST /system/sms/accounts and friends | VerifySystemToken (constant-time, hash_equals; cross-repo #40) — Bearer NOT required | |
POST /webhooks/bot and friends | VerifyBotToken (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
?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