feat: add ERP REST API controllers and route registration (#192)

Add CRUD API endpoints for the ERP system:
- ErpRolesController: list/create/update/delete contact roles
- ErpDealsController: full deal management with stage/contact joins
- ErpActivitiesController: activity log CRUD per contact/deal
- ErpPipelineController: pipeline summary with stage counts and weighted values

Routes registered in plg_webservices_mokowaas:
- /v1/mokowaas/erp/roles
- /v1/mokowaas/erp/deals
- /v1/mokowaas/erp/activities
- /v1/mokowaas/erp/pipeline
This commit is contained in:
Jonathan Miller
2026-06-06 12:52:34 -05:00
parent 7cf3400f3c
commit 68dc420e62
5 changed files with 838 additions and 0 deletions
@@ -0,0 +1,193 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* ERP Activities API controller.
*
* GET /api/index.php/v1/mokowaas/erp/activities — List activities
* POST /api/index.php/v1/mokowaas/erp/activities — Create activity
* PATCH /api/index.php/v1/mokowaas/erp/activities/{id} — Update activity
* DELETE /api/index.php/v1/mokowaas/erp/activities/{id} — Delete activity
*
* @since 02.34.16
*/
class ErpActivitiesController extends BaseController
{
/**
* List activities with optional filters.
*/
public function displayList(): void
{
$this->requireAuth();
$app = Factory::getApplication();
$db = Factory::getDbo();
$input = $app->getInput();
$query = $db->getQuery(true)
->select('a.*, cd.name AS contact_name, u.name AS user_name')
->from($db->quoteName('#__mokowaas_erp_activities', 'a'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = a.contact_id')
->join('LEFT', $db->quoteName('#__users', 'u') . ' ON u.id = a.user_id')
->order('a.created DESC');
$contactId = $input->getInt('contact_id', 0);
if ($contactId)
{
$query->where($db->quoteName('a.contact_id') . ' = ' . $contactId);
}
$dealId = $input->getInt('deal_id', 0);
if ($dealId)
{
$query->where($db->quoteName('a.deal_id') . ' = ' . $dealId);
}
$type = $input->get('type', '', 'CMD');
if ($type)
{
$query->where($db->quoteName('a.type') . ' = ' . $db->quote($type));
}
$limit = $input->getInt('limit', 50);
$offset = $input->getInt('offset', 0);
$db->setQuery($query, $offset, $limit);
$items = $db->loadObjectList();
$this->sendJson(200, ['data' => $items, 'total' => \count($items)]);
}
/**
* Create a new activity.
*/
public function add(): void
{
$this->requireAuth();
$input = json_decode(Factory::getApplication()->getInput()->json->getRaw(), true);
$db = Factory::getDbo();
$contactId = (int) ($input['contact_id'] ?? 0);
$type = $input['type'] ?? 'note';
if (!$contactId)
{
$this->sendJson(400, ['error' => 'contact_id is required']);
return;
}
if (!\in_array($type, ['call', 'email', 'meeting', 'note', 'task'], true))
{
$this->sendJson(400, ['error' => 'type must be one of: call, email, meeting, note, task']);
return;
}
$obj = (object) [
'contact_id' => $contactId,
'deal_id' => ($input['deal_id'] ?? null) ? (int) $input['deal_id'] : null,
'type' => $type,
'subject' => $input['subject'] ?? '',
'body' => $input['body'] ?? null,
'due_date' => $input['due_date'] ?? null,
'completed' => (int) ($input['completed'] ?? 0),
'user_id' => (int) Factory::getUser()->id,
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokowaas_erp_activities', $obj, 'id');
$this->sendJson(201, ['data' => $obj, 'id' => $db->insertid()]);
}
/**
* Update an activity.
*/
public function edit(): void
{
$this->requireAuth();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$input = json_decode(Factory::getApplication()->getInput()->json->getRaw(), true);
$db = Factory::getDbo();
$allowed = ['subject', 'body', 'due_date', 'completed', 'type'];
$updates = [];
foreach ($allowed as $field)
{
if (isset($input[$field]))
{
$updates[$field] = $input[$field];
}
}
if (empty($updates))
{
$this->sendJson(400, ['error' => 'No valid fields to update']);
return;
}
$obj = (object) array_merge(['id' => $id], $updates);
$db->updateObject('#__mokowaas_erp_activities', $obj, 'id');
$this->sendJson(200, ['data' => $obj]);
}
/**
* Delete an activity.
*/
public function delete(): void
{
$this->requireAuth();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_erp_activities'))
->where($db->quoteName('id') . ' = ' . $id)
);
$db->execute();
$this->sendJson(200, ['message' => 'Activity deleted', 'id' => $id]);
}
private function requireAuth(): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('core.manage', 'com_mokowaas'))
{
$this->sendJson(403, ['error' => 'Not authorized']);
$this->app->close();
}
}
private function sendJson(int $code, array $data): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json', true);
$app->setHeader('Status', (string) $code, true);
echo json_encode($data, JSON_UNESCAPED_SLASHES);
}
}
@@ -0,0 +1,263 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* ERP Deals API controller.
*
* GET /api/index.php/v1/mokowaas/erp/deals — List deals
* GET /api/index.php/v1/mokowaas/erp/deals/{id} — Get single deal
* POST /api/index.php/v1/mokowaas/erp/deals — Create deal
* PATCH /api/index.php/v1/mokowaas/erp/deals/{id} — Update deal
* DELETE /api/index.php/v1/mokowaas/erp/deals/{id} — Delete deal
*
* @since 02.34.16
*/
class ErpDealsController extends BaseController
{
/**
* List deals with optional filters.
*/
public function displayList(): void
{
$this->requireAuth();
$app = Factory::getApplication();
$db = Factory::getDbo();
$input = $app->getInput();
$query = $db->getQuery(true)
->select('d.*, ps.title AS stage_title, ps.color AS stage_color, ps.probability AS stage_probability')
->select('cd.name AS contact_name')
->from($db->quoteName('#__mokowaas_erp_deals', 'd'))
->join('LEFT', $db->quoteName('#__mokowaas_erp_pipeline_stages', 'ps') . ' ON ps.id = d.stage_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id')
->order('d.created DESC');
$status = $input->get('status', '', 'CMD');
if ($status)
{
$query->where($db->quoteName('d.status') . ' = ' . $db->quote($status));
}
$stageId = $input->getInt('stage_id', 0);
if ($stageId)
{
$query->where($db->quoteName('d.stage_id') . ' = ' . $stageId);
}
$contactId = $input->getInt('contact_id', 0);
if ($contactId)
{
$query->where($db->quoteName('d.contact_id') . ' = ' . $contactId);
}
$assignedTo = $input->getInt('assigned_to', 0);
if ($assignedTo)
{
$query->where($db->quoteName('d.assigned_to') . ' = ' . $assignedTo);
}
$limit = $input->getInt('limit', 50);
$offset = $input->getInt('offset', 0);
$db->setQuery($query, $offset, $limit);
$items = $db->loadObjectList();
$this->sendJson(200, ['data' => $items, 'total' => \count($items)]);
}
/**
* Get a single deal by ID.
*/
public function displayItem(): void
{
$this->requireAuth();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('d.*, ps.title AS stage_title, ps.color AS stage_color')
->select('cd.name AS contact_name')
->from($db->quoteName('#__mokowaas_erp_deals', 'd'))
->join('LEFT', $db->quoteName('#__mokowaas_erp_pipeline_stages', 'ps') . ' ON ps.id = d.stage_id')
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = d.contact_id')
->where($db->quoteName('d.id') . ' = ' . $id)
);
$item = $db->loadObject();
if (!$item)
{
$this->sendJson(404, ['error' => 'Deal not found']);
return;
}
// Include activities for this deal
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_erp_activities'))
->where($db->quoteName('deal_id') . ' = ' . $id)
->order('created DESC'),
0, 20
);
$item->activities = $db->loadObjectList();
$this->sendJson(200, ['data' => $item]);
}
/**
* Create a new deal.
*/
public function add(): void
{
$this->requireAuth();
$input = json_decode(Factory::getApplication()->getInput()->json->getRaw(), true);
$db = Factory::getDbo();
$now = Factory::getDate()->toSql();
$title = trim($input['title'] ?? '');
$contactId = (int) ($input['contact_id'] ?? 0);
if (!$title || !$contactId)
{
$this->sendJson(400, ['error' => 'title and contact_id are required']);
return;
}
$obj = (object) [
'title' => $title,
'contact_id' => $contactId,
'stage_id' => (int) ($input['stage_id'] ?? 1),
'value' => (float) ($input['value'] ?? 0),
'currency' => $input['currency'] ?? 'USD',
'probability' => isset($input['probability']) ? (int) $input['probability'] : null,
'expected_close' => $input['expected_close'] ?? null,
'assigned_to' => (int) ($input['assigned_to'] ?? Factory::getUser()->id),
'status' => 'open',
'notes' => $input['notes'] ?? null,
'created' => $now,
'created_by' => (int) Factory::getUser()->id,
];
$db->insertObject('#__mokowaas_erp_deals', $obj, 'id');
$this->sendJson(201, ['data' => $obj, 'id' => $db->insertid()]);
}
/**
* Update a deal (including stage moves for pipeline drag-and-drop).
*/
public function edit(): void
{
$this->requireAuth();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$input = json_decode(Factory::getApplication()->getInput()->json->getRaw(), true);
$db = Factory::getDbo();
$allowed = [
'title', 'stage_id', 'value', 'currency', 'probability',
'expected_close', 'assigned_to', 'status', 'lost_reason', 'notes',
];
$updates = [];
foreach ($allowed as $field)
{
if (isset($input[$field]))
{
$updates[$field] = $input[$field];
}
}
if (empty($updates))
{
$this->sendJson(400, ['error' => 'No valid fields to update']);
return;
}
$updates['modified'] = Factory::getDate()->toSql();
// Auto-set closed date on won/lost
if (isset($updates['status']) && \in_array($updates['status'], ['won', 'lost'], true))
{
$updates['closed'] = Factory::getDate()->toSql();
}
$obj = (object) array_merge(['id' => $id], $updates);
$db->updateObject('#__mokowaas_erp_deals', $obj, 'id');
$this->sendJson(200, ['data' => $obj]);
}
/**
* Delete a deal and its activities.
*/
public function delete(): void
{
$this->requireAuth();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
// Delete linked activities first
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_erp_activities'))
->where($db->quoteName('deal_id') . ' = ' . $id)
);
$db->execute();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_erp_deals'))
->where($db->quoteName('id') . ' = ' . $id)
);
$db->execute();
$this->sendJson(200, ['message' => 'Deal deleted', 'id' => $id]);
}
private function requireAuth(): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('core.manage', 'com_mokowaas'))
{
$this->sendJson(403, ['error' => 'Not authorized']);
$this->app->close();
}
}
private function sendJson(int $code, array $data): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json', true);
$app->setHeader('Status', (string) $code, true);
echo json_encode($data, JSON_UNESCAPED_SLASHES);
}
}
@@ -0,0 +1,101 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* ERP Pipeline API controller.
*
* GET /api/index.php/v1/mokowaas/erp/pipeline — Pipeline summary with stages, deal counts, values
*
* @since 02.34.16
*/
class ErpPipelineController extends BaseController
{
/**
* Return pipeline summary: stages with deal counts and total values.
*/
public function displayList(): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('core.manage', 'com_mokowaas'))
{
$this->sendJson(403, ['error' => 'Not authorized']);
return;
}
$db = Factory::getDbo();
// Get all stages
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokowaas_erp_pipeline_stages'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('ordering') . ' ASC')
);
$stages = $db->loadObjectList();
// Get deal counts and values per stage (open deals only)
$db->setQuery(
$db->getQuery(true)
->select($db->quoteName('stage_id'))
->select('COUNT(*) AS ' . $db->quoteName('deal_count'))
->select('COALESCE(SUM(' . $db->quoteName('value') . '), 0) AS ' . $db->quoteName('total_value'))
->from($db->quoteName('#__mokowaas_erp_deals'))
->where($db->quoteName('status') . ' = ' . $db->quote('open'))
->group($db->quoteName('stage_id'))
);
$dealStats = [];
foreach ($db->loadObjectList() as $row)
{
$dealStats[(int) $row->stage_id] = $row;
}
// Merge stats into stages
foreach ($stages as &$stage)
{
$stats = $dealStats[(int) $stage->id] ?? null;
$stage->deal_count = $stats ? (int) $stats->deal_count : 0;
$stage->total_value = $stats ? (float) $stats->total_value : 0.00;
$stage->weighted_value = $stage->total_value * ($stage->probability / 100);
}
// Summary totals
$totalDeals = array_sum(array_column($stages, 'deal_count'));
$totalValue = array_sum(array_column($stages, 'total_value'));
$weightedValue = array_sum(array_column($stages, 'weighted_value'));
$this->sendJson(200, [
'stages' => $stages,
'summary' => [
'total_deals' => $totalDeals,
'total_value' => $totalValue,
'weighted_value' => round($weightedValue, 2),
],
]);
}
private function sendJson(int $code, array $data): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json', true);
$app->setHeader('Status', (string) $code, true);
echo json_encode($data, JSON_UNESCAPED_SLASHES);
}
}
@@ -0,0 +1,256 @@
<?php
/**
* @package MokoWaaS
* @subpackage com_mokowaas
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Moko\Component\MokoWaaS\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
/**
* ERP Roles API controller.
*
* GET /api/index.php/v1/mokowaas/erp/roles — List all roles
* GET /api/index.php/v1/mokowaas/erp/roles/{id} — Get single role
* POST /api/index.php/v1/mokowaas/erp/roles — Create role
* PATCH /api/index.php/v1/mokowaas/erp/roles/{id} — Update role
* DELETE /api/index.php/v1/mokowaas/erp/roles/{id} — Delete role
*
* @since 02.34.16
*/
class ErpRolesController extends BaseController
{
/**
* List all ERP roles, optionally filtered by role type or contact.
*/
public function displayList(): void
{
$this->requireAuth();
$app = Factory::getApplication();
$db = Factory::getDbo();
$input = $app->getInput();
$query = $db->getQuery(true)
->select('r.*, cd.name AS contact_name, cd.email_to AS contact_email')
->from($db->quoteName('#__mokowaas_erp_roles', 'r'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = r.contact_id')
->order('r.created DESC');
// Filters
$role = $input->get('role', '', 'CMD');
if ($role)
{
$query->where($db->quoteName('r.role') . ' = ' . $db->quote($role));
}
$status = $input->get('status', '', 'CMD');
if ($status)
{
$query->where($db->quoteName('r.status') . ' = ' . $db->quote($status));
}
$contactId = $input->getInt('contact_id', 0);
if ($contactId)
{
$query->where($db->quoteName('r.contact_id') . ' = ' . $contactId);
}
$limit = $input->getInt('limit', 50);
$offset = $input->getInt('offset', 0);
$db->setQuery($query, $offset, $limit);
$items = $db->loadObjectList();
$this->sendJson(200, ['data' => $items, 'total' => \count($items)]);
}
/**
* Get a single role by ID.
*/
public function displayItem(): void
{
$this->requireAuth();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->select('r.*, cd.name AS contact_name, cd.email_to AS contact_email')
->from($db->quoteName('#__mokowaas_erp_roles', 'r'))
->join('LEFT', $db->quoteName('#__contact_details', 'cd') . ' ON cd.id = r.contact_id')
->where($db->quoteName('r.id') . ' = ' . $id)
);
$item = $db->loadObject();
if (!$item)
{
$this->sendJson(404, ['error' => 'Role not found']);
return;
}
$this->sendJson(200, ['data' => $item]);
}
/**
* Create a new ERP role.
*/
public function add(): void
{
$this->requireAuth();
$app = Factory::getApplication();
$input = json_decode($app->getInput()->json->getRaw(), true);
$contactId = (int) ($input['contact_id'] ?? 0);
$role = $input['role'] ?? '';
if (!$contactId || !\in_array($role, ['customer', 'vendor', 'prospect'], true))
{
$this->sendJson(400, ['error' => 'contact_id and valid role (customer, vendor, prospect) are required']);
return;
}
$db = Factory::getDbo();
// Check duplicate
$db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokowaas_erp_roles'))
->where($db->quoteName('contact_id') . ' = ' . $contactId)
->where($db->quoteName('role') . ' = ' . $db->quote($role))
);
if ((int) $db->loadResult() > 0)
{
$this->sendJson(409, ['error' => 'Role already exists for this contact']);
return;
}
$prefixes = ['customer' => 'CU', 'vendor' => 'VN', 'prospect' => 'PR'];
$prefix = $prefixes[$role];
// Use ErpHelper if available, otherwise inline
$year = date('Y');
$like = $db->quote($prefix . $year . '-%');
$db->setQuery(
$db->getQuery(true)
->select('MAX(' . $db->quoteName('code') . ')')
->from($db->quoteName('#__mokowaas_erp_roles'))
->where($db->quoteName('role') . ' = ' . $db->quote($role))
->where($db->quoteName('code') . ' LIKE ' . $like)
);
$maxCode = $db->loadResult();
$seq = $maxCode ? ((int) end(explode('-', $maxCode)) + 1) : 1;
$code = sprintf('%s%s-%03d', $prefix, $year, $seq);
$obj = (object) [
'contact_id' => $contactId,
'role' => $role,
'status' => $input['status'] ?? ($role === 'prospect' ? 'lead' : 'active'),
'code' => $code,
'credit_limit' => $input['credit_limit'] ?? null,
'payment_terms_days' => (int) ($input['payment_terms_days'] ?? 30),
'notes' => $input['notes'] ?? null,
'created' => Factory::getDate()->toSql(),
'created_by' => (int) Factory::getUser()->id,
];
$db->insertObject('#__mokowaas_erp_roles', $obj, 'id');
$this->sendJson(201, ['data' => $obj, 'id' => $db->insertid()]);
}
/**
* Update an existing role.
*/
public function edit(): void
{
$this->requireAuth();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$input = json_decode(Factory::getApplication()->getInput()->json->getRaw(), true);
$db = Factory::getDbo();
$allowed = ['status', 'credit_limit', 'payment_terms_days', 'notes'];
$updates = [];
foreach ($allowed as $field)
{
if (isset($input[$field]))
{
$updates[$field] = $input[$field];
}
}
if (empty($updates))
{
$this->sendJson(400, ['error' => 'No valid fields to update']);
return;
}
$updates['modified'] = Factory::getDate()->toSql();
$obj = (object) array_merge(['id' => $id], $updates);
$db->updateObject('#__mokowaas_erp_roles', $obj, 'id');
$this->sendJson(200, ['data' => $obj]);
}
/**
* Delete a role.
*/
public function delete(): void
{
$this->requireAuth();
$id = Factory::getApplication()->getInput()->getInt('id', 0);
$db = Factory::getDbo();
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokowaas_erp_roles'))
->where($db->quoteName('id') . ' = ' . $id)
);
$db->execute();
$this->sendJson(200, ['message' => 'Role deleted', 'id' => $id]);
}
private function requireAuth(): void
{
$user = Factory::getApplication()->getIdentity();
if (!$user->authorise('core.manage', 'com_mokowaas'))
{
$this->sendJson(403, ['error' => 'Not authorized']);
$this->app->close();
}
}
private function sendJson(int $code, array $data): void
{
$app = Factory::getApplication();
$app->setHeader('Content-Type', 'application/json', true);
$app->setHeader('Status', (string) $code, true);
echo json_encode($data, JSON_UNESCAPED_SLASHES);
}
}
@@ -124,5 +124,30 @@ final class MokoWaaSApi extends CMSPlugin implements SubscriberInterface
'provision',
['component' => 'com_mokowaas']
);
// ERP routes
$router->createCRUDRoutes(
'v1/mokowaas/erp/roles',
'erproles',
['component' => 'com_mokowaas']
);
$router->createCRUDRoutes(
'v1/mokowaas/erp/deals',
'erpdeals',
['component' => 'com_mokowaas']
);
$router->createCRUDRoutes(
'v1/mokowaas/erp/activities',
'erpactivities',
['component' => 'com_mokowaas']
);
$router->createCRUDRoutes(
'v1/mokowaas/erp/pipeline',
'erppipeline',
['component' => 'com_mokowaas']
);
}
}