diff --git a/app/Config/Filters.php b/app/Config/Filters.php index efb99dfd8..561a41438 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -12,18 +12,10 @@ use CodeIgniter\Filters\InvalidChars; use CodeIgniter\Filters\PageCache; use CodeIgniter\Filters\PerformanceMetrics; use CodeIgniter\Filters\SecureHeaders; +use App\Filters\ApiAuth; class Filters extends BaseFilters { - /** - * Configures aliases for Filter classes to - * make reading things nicer and simpler. - * - * @var array> - * - * [filter_name => classname] - * or [filter_name => [classname1, classname2, ...]] - */ public array $aliases = [ 'csrf' => CSRF::class, 'toolbar' => DebugToolbar::class, @@ -34,6 +26,7 @@ class Filters extends BaseFilters 'forcehttps' => ForceHTTPS::class, 'pagecache' => PageCache::class, 'performance' => PerformanceMetrics::class, + 'apiauth' => ApiAuth::class, ]; /** @@ -70,7 +63,7 @@ class Filters extends BaseFilters public array $globals = [ 'before' => [ 'honeypot', - 'csrf' => ['except' => 'login'], + 'csrf' => ['except' => ['login', 'api/*']], 'invalidchars', ], 'after' => [ diff --git a/app/Config/Routes.php b/app/Config/Routes.php index b4fbf3221..7a5c7bfbf 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -39,3 +39,50 @@ $routes->add('reports/specific_customers', 'Reports::specific_customer_input'); $routes->add('reports/specific_employees', 'Reports::specific_employee_input'); $routes->add('reports/specific_discounts', 'Reports::specific_discount_input'); $routes->add('reports/specific_suppliers', 'Reports::specific_supplier_input'); + +$routes->group('office/api-keys', ['filter' => 'session'], static function(RouteCollection $routes): void { + $routes->get('/', 'ApiKeys::index'); + $routes->post('generate', 'ApiKeys::generate'); + $routes->post('revoke/(:num)', 'ApiKeys::revoke/$1'); + $routes->post('regenerate/(:num)', 'ApiKeys::regenerate/$1'); +}); + +$routes->group('api/v1', ['filter' => 'apiauth'], static function(RouteCollection $routes): void { + $routes->get('customers', 'Api\Customers::index'); + $routes->get('customers/(:num)', 'Api\Customers::show/$1'); + $routes->post('customers', 'Api\Customers::create'); + $routes->put('customers/(:num)', 'Api\Customers::update/$1'); + $routes->delete('customers/(:num)', 'Api\Customers::delete/$1'); + $routes->post('customers/batch-delete', 'Api\Customers::batchDelete'); + $routes->get('customers/suggest', 'Api\Customers::suggest'); + + $routes->get('suppliers', 'Api\Suppliers::index'); + $routes->get('suppliers/(:num)', 'Api\Suppliers::show/$1'); + $routes->post('suppliers', 'Api\Suppliers::create'); + $routes->put('suppliers/(:num)', 'Api\Suppliers::update/$1'); + $routes->delete('suppliers/(:num)', 'Api\Suppliers::delete/$1'); + $routes->post('suppliers/batch-delete', 'Api\Suppliers::batchDelete'); + $routes->get('suppliers/suggest', 'Api\Suppliers::suggest'); + + $routes->get('items', 'Api\Items::index'); + $routes->get('items/(:num)', 'Api\Items::show/$1'); + $routes->post('items', 'Api\Items::create'); + $routes->put('items/(:num)', 'Api\Items::update/$1'); + $routes->delete('items/(:num)', 'Api\Items::delete/$1'); + $routes->post('items/batch-delete', 'Api\Items::batchDelete'); + $routes->get('items/suggest', 'Api\Items::suggest'); + $routes->get('items/(:num)/quantities', 'Api\Items::quantities/$1'); + + $routes->get('inventory', 'Api\Inventory::index'); + $routes->post('inventory', 'Api\Inventory::create'); + $routes->post('inventory/bulk', 'Api\Inventory::create'); + + $routes->get('sales', 'Api\Sales::index'); + $routes->get('sales/(:num)', 'Api\Sales::show/$1'); + $routes->get('sales/(:num)/items', 'Api\Sales::items/$1'); + $routes->get('sales/(:num)/payments', 'Api\Sales::payments/$1'); + + $routes->get('receivings', 'Api\Receivings::index'); + $routes->get('receivings/(:num)', 'Api\Receivings::show/$1'); + $routes->get('receivings/(:num)/items', 'Api\Receivings::items/$1'); +}); diff --git a/app/Controllers/Api/BaseController.php b/app/Controllers/Api/BaseController.php new file mode 100644 index 000000000..5f013960d --- /dev/null +++ b/app/Controllers/Api/BaseController.php @@ -0,0 +1,129 @@ +employee = model(Employee::class); + $this->employeeId = $request->employeeId ?? 0; + } + + protected function hasPermission(string $moduleId): bool + { + return $this->employee->has_grant($moduleId, $this->employeeId); + } + + protected function respondSuccess(array $data = [], int $code = 200, string $message = 'Success'): ResponseInterface + { + $response = ['success' => true]; + + if ($message) { + $response['message'] = $message; + } + + $response = array_merge($response, $data); + + return $this->respond($response, $code); + } + + protected function respondCreated(array $data = [], string $message = 'Resource created'): ResponseInterface + { + return $this->respondSuccess($data, 201, $message); + } + + protected function respondError(string $message, int $code = 400): ResponseInterface + { + return $this->respond([ + 'success' => false, + 'message' => $message + ], $code); + } + + protected function respondNotFound(string $message = 'Resource not found'): ResponseInterface + { + return $this->respondError($message, 404); + } + + protected function respondUnauthorized(string $message = 'Unauthorized'): ResponseInterface + { + return $this->respondError($message, 403); + } + + protected function respondValidationError(array $errors): ResponseInterface + { + return $this->respond([ + 'success' => false, + 'message' => 'Validation failed', + 'errors' => $errors + ], 422); + } + + protected function getPagination(): array + { + $offset = (int) ($this->request->getGet('offset') ?? 0); + $limit = (int) ($this->request->getGet('limit') ?? 25); + $limit = min(max($limit, 1), 100); + $offset = max($offset, 0); + + return ['offset' => $offset, 'limit' => $limit]; + } + + protected function getSort(array $allowedFields, string $default = 'id', string $defaultOrder = 'asc'): array + { + $sort = $this->request->getGet('sort') ?? $default; + $order = strtolower($this->request->getGet('order') ?? $defaultOrder); + + if (!in_array($sort, $allowedFields)) { + $sort = $default; + } + + if (!in_array($order, ['asc', 'desc'])) { + $order = $defaultOrder; + } + + return ['sort' => $sort, 'order' => $order]; + } + + protected function toCamelCase(array $data): array + { + $result = []; + foreach ($data as $key => $value) { + $camelKey = lcfirst(str_replace('_', '', ucwords($key, '_'))); + $result[$camelKey] = $value; + } + return $result; + } + + protected function toSnakeCase(array $data): array + { + $result = []; + foreach ($data as $key => $value) { + $snakeKey = strtolower(preg_replace('/(?toCamelCase(array_merge($item, $additional)); + } + + protected function transformCollection(array $items): array + { + return array_map([$this, 'transformItem'], $items); + } +} \ No newline at end of file diff --git a/app/Controllers/Api/Customers.php b/app/Controllers/Api/Customers.php new file mode 100644 index 000000000..2891b4324 --- /dev/null +++ b/app/Controllers/Api/Customers.php @@ -0,0 +1,251 @@ +customerModel = model(Customer::class); + $this->personModel = model(Person::class); + } + + public function index(): ResponseInterface + { + if (!$this->hasPermission('customers')) { + return $this->respondUnauthorized(); + } + + $search = $this->request->getGet('search'); + $pagination = $this->getPagination(); + $sort = $this->getSort($this->allowedSortFields, 'last_name'); + + $builder = $this->customerModel->builder(); + $builder->select('customers.*, people.*'); + $builder->join('people', 'people.person_id = customers.person_id'); + $builder->where('customers.deleted', 0); + + if ($search) { + $builder->groupStart(); + $builder->like('people.first_name', $search); + $builder->orLike('people.last_name', $search); + $builder->orLike('people.email', $search); + $builder->orLike('customers.account_number', $search); + $builder->orLike('customers.company_name', $search); + $builder->groupEnd(); + } + + $total = $builder->countAllResults(false); + + $dbSort = $this->mapSortField($sort['sort']); + $builder->orderBy($dbSort, $sort['order']); + $builder->limit($pagination['limit'], $pagination['offset']); + + $customers = $builder->get()->getResultArray(); + + return $this->respondSuccess([ + 'total' => $total, + 'offset' => $pagination['offset'], + 'limit' => $pagination['limit'], + 'rows' => $this->transformCollection($customers) + ]); + } + + public function show($id = null): ResponseInterface + { + if (!$this->hasPermission('customers')) { + return $this->respondUnauthorized(); + } + + $customer = $this->customerModel->get_info($id); + + if (empty($customer) || $customer->deleted) { + return $this->respondNotFound('Customer not found'); + } + + $person = (array) $this->personModel->get_info($id); + $customer = (array) $customer; + $data = array_merge($person, $customer); + + return $this->respondSuccess($this->transformItem($data)); + } + + public function create(): ResponseInterface + { + if (!$this->hasPermission('customers')) { + return $this->respondUnauthorized(); + } + + $data = $this->request->getJSON(true); + if (empty($data)) { + $data = $this->request->getPost(); + } + + $data = $this->toSnakeCase($data); + + $rules = [ + 'first_name' => 'required|max_length[255]', + 'last_name' => 'required|max_length[255]', + ]; + + $snakeData = []; + foreach ($data as $key => $value) { + $snakeKey = strtolower(preg_replace('/(?employeeId; + + $personId = false; + $success = $this->personModel->save_value($personData); + + if ($success && isset($personData['person_id'])) { + $personId = $personData['person_id']; + $customerData['person_id'] = $personId; + $success = $this->customerModel->save_value($customerData); + } + + if ($success) { + return $this->respondCreated(['id' => $personId], 'Customer created successfully'); + } + + return $this->respondError('Failed to create customer'); + } + + public function update($id = null): ResponseInterface + { + if (!$this->hasPermission('customers')) { + return $this->respondUnauthorized(); + } + + $customer = $this->customerModel->get_info($id); + + if (empty($customer) || $customer->deleted) { + return $this->respondNotFound('Customer not found'); + } + + $data = $this->request->getJSON(true); + if (empty($data)) { + $data = $this->request->getRawInput(); + } + + $snakeData = []; + foreach ($data as $key => $value) { + $snakeKey = strtolower(preg_replace('/(?personModel->save_value($personData, $id); + } + + if (!empty($customerData)) { + $this->customerModel->save_value($customerData, $id); + } + + return $this->respondSuccess([], 200, 'Customer updated successfully'); + } + + public function delete($id = null): ResponseInterface + { + if (!$this->hasPermission('customers')) { + return $this->respondUnauthorized(); + } + + $customer = $this->customerModel->get_info($id); + + if (empty($customer) || $customer->deleted) { + return $this->respondNotFound('Customer not found'); + } + + $success = $this->customerModel->delete($id); + + if ($success) { + return $this->respondSuccess([], 200, 'Customer deleted successfully'); + } + + return $this->respondError('Failed to delete customer'); + } + + public function batchDelete(): ResponseInterface + { + if (!$this->hasPermission('customers')) { + return $this->respondUnauthorized(); + } + + $data = $this->request->getJSON(true); + $ids = $data['ids'] ?? []; + + if (empty($ids)) { + return $this->respondError('No customer IDs provided'); + } + + $success = $this->customerModel->delete_list($ids); + + if ($success) { + return $this->respondSuccess([], 200, 'Customers deleted successfully'); + } + + return $this->respondError('Failed to delete customers'); + } + + public function suggest(): ResponseInterface + { + if (!$this->hasPermission('customers')) { + return $this->respondUnauthorized(); + } + + $term = $this->request->getGet('term'); + $limit = (int) ($this->request->getGet('limit') ?? 25); + + if (empty($term)) { + return $this->respondSuccess(['suggestions' => []]); + } + + $suggestions = $this->customerModel->get_search_suggestions($term, $limit); + + return $this->respondSuccess(['suggestions' => $suggestions]); + } + + private function mapSortField(string $field): string + { + $map = [ + 'personId' => 'people.person_id', + 'lastName' => 'people.last_name', + 'firstName' => 'people.first_name', + 'email' => 'people.email', + 'companyName' => 'customers.company_name' + ]; + + return $map[$field] ?? 'people.last_name'; + } +} \ No newline at end of file diff --git a/app/Controllers/Api/Inventory.php b/app/Controllers/Api/Inventory.php new file mode 100644 index 000000000..fcc2871fe --- /dev/null +++ b/app/Controllers/Api/Inventory.php @@ -0,0 +1,212 @@ +inventory = model(InventoryModel::class); + $this->item = model(Item::class); + $this->itemQuantity = model(Item_quantity::class); + } + + public function index(): ResponseInterface + { + if (!$this->hasPermission('items')) { + return $this->respondUnauthorized(); + } + + $itemId = $this->request->getGet('itemId'); + $locationId = $this->request->getGet('locationId'); + $pagination = $this->getPagination(); + $sort = $this->getSort($this->allowedSortFields, 'trans_date'); + + $builder = $this->inventory->builder(); + + if ($itemId) { + $builder->where('trans_items', $itemId); + } + + if ($locationId) { + $builder->where('trans_location', $locationId); + } + + $total = $builder->countAllResults(false); + + $builder->orderBy($sort['sort'], $sort['order']); + $builder->limit($pagination['limit'], $pagination['offset']); + + $transactions = $builder->get()->getResultArray(); + + return $this->respondSuccess([ + 'total' => $total, + 'offset' => $pagination['offset'], + 'limit' => $pagination['limit'], + 'rows' => $this->transformCollection($transactions) + ]); + } + + public function create(): ResponseInterface + { + if (!$this->hasPermission('items')) { + return $this->respondUnauthorized(); + } + + $data = $this->request->getJSON(true); + + if (isset($data['adjustments']) && is_array($data['adjustments'])) { + return $this->bulkAdjust($data['adjustments']); + } + + return $this->singleAdjust($data); + } + + private function singleAdjust(array $data): ResponseInterface + { + if (empty($data['itemId'])) { + return $this->respondError('itemId is required'); + } + + if (!isset($data['quantity'])) { + return $this->respondError('quantity is required'); + } + + $mode = $data['mode'] ?? 'adjust'; + + if (!in_array($mode, ['adjust', 'set'])) { + return $this->respondError('mode must be "adjust" or "set"'); + } + + $item = $this->item->find($data['itemId']); + if (!$item || $item->deleted) { + return $this->respondNotFound('Item not found'); + } + + $locationId = $data['locationId'] ?? 1; + $comment = $data['comment'] ?? 'API inventory adjustment'; + $quantity = (float) $data['quantity']; + + if ($mode === 'set') { + $currentQty = $this->itemQuantity->get_item_quantity($data['itemId'], $locationId); + $currentQty = $currentQty ? (float) $currentQty->quantity : 0; + $adjustment = $quantity - $currentQty; + + if ($adjustment == 0) { + return $this->respondSuccess([ + 'itemId' => (int) $data['itemId'], + 'locationId' => (int) $locationId, + 'newQuantity' => $quantity, + 'mode' => $mode + ], 200, 'Quantity already at requested level'); + } + } else { + $adjustment = $quantity; + } + + $invData = [ + 'trans_date' => date('Y-m-d H:i:s'), + 'trans_items' => $data['itemId'], + 'trans_user' => $this->employeeId, + 'trans_location' => $locationId, + 'trans_comment' => $comment, + 'trans_inventory' => $adjustment + ]; + + $this->inventory->insert($invData); + $this->itemQuantity->change_quantity($data['itemId'], $locationId, $adjustment); + + $newQty = $this->itemQuantity->get_item_quantity($data['itemId'], $locationId); + + return $this->respondSuccess([ + 'itemId' => (int) $data['itemId'], + 'locationId' => (int) $locationId, + 'adjustment' => $adjustment, + 'newQuantity' => $newQty ? (float) $newQty->quantity : 0, + 'mode' => $mode + ], 200, 'Inventory adjusted successfully'); + } + + private function bulkAdjust(array $adjustments): ResponseInterface + { + $results = []; + $processed = 0; + $errors = []; + + $this->inventory->db->transStart(); + + foreach ($adjustments as $adjustment) { + $itemId = $adjustment['itemId'] ?? $adjustment['item_id'] ?? null; + + if (!$itemId) { + $errors[] = ['itemId' => null, 'success' => false, 'error' => 'itemId is required']; + continue; + } + + $item = $this->item->find($itemId); + if (!$item || $item->deleted) { + $errors[] = ['itemId' => $itemId, 'success' => false, 'error' => 'Item not found']; + continue; + } + + $mode = $adjustment['mode'] ?? 'adjust'; + $locationId = $adjustment['locationId'] ?? $adjustment['location_id'] ?? 1; + $quantity = (float) ($adjustment['quantity'] ?? 0); + $comment = $adjustment['comment'] ?? 'Bulk API inventory adjustment'; + + if ($mode === 'set') { + $currentQty = $this->itemQuantity->get_item_quantity($itemId, $locationId); + $currentQty = $currentQty ? (float) $currentQty->quantity : 0; + $adjustmentQty = $quantity - $currentQty; + } else { + $adjustmentQty = $quantity; + } + + $invData = [ + 'trans_date' => date('Y-m-d H:i:s'), + 'trans_items' => $itemId, + 'trans_user' => $this->employeeId, + 'trans_location' => $locationId, + 'trans_comment' => $comment, + 'trans_inventory' => $adjustmentQty + ]; + + $this->inventory->insert($invData); + $this->itemQuantity->change_quantity($itemId, $locationId, $adjustmentQty); + + $results[] = ['itemId' => $itemId, 'success' => true]; + $processed++; + } + + $this->inventory->db->transComplete(); + + $response = [ + 'processed' => $processed, + 'total' => count($adjustments), + 'results' => $results + ]; + + if (!empty($errors)) { + $response['errors'] = $errors; + $response['success'] = false; + $response['message'] = 'Some adjustments failed'; + } else { + $response['success'] = true; + $response['message'] = 'All adjustments processed successfully'; + } + + return $this->respondSuccess($response); + } +} \ No newline at end of file diff --git a/app/Controllers/Api/Items.php b/app/Controllers/Api/Items.php new file mode 100644 index 000000000..16b80ccc8 --- /dev/null +++ b/app/Controllers/Api/Items.php @@ -0,0 +1,237 @@ +itemModel = model(Item::class); + $this->itemQuantityModel = model(Item_quantity::class); + } + + public function index(): ResponseInterface + { + if (!$this->hasPermission('items')) { + return $this->respondUnauthorized(); + } + + $search = $this->request->getGet('search'); + $pagination = $this->getPagination(); + $sort = $this->getSort($this->allowedSortFields, 'name'); + $stockLocation = $this->request->getGet('stockLocation'); + + $builder = $this->itemModel->builder(); + $builder->where('deleted', 0); + + if ($search) { + $builder->groupStart(); + $builder->like('name', $search); + $builder->orLike('item_number', $search); + $builder->orLike('category', $search); + $builder->orLike('description', $search); + $builder->groupEnd(); + } + + $total = $builder->countAllResults(false); + + $dbSort = $this->mapSortField($sort['sort']); + $builder->orderBy($dbSort, $sort['order']); + $builder->limit($pagination['limit'], $pagination['offset']); + + $items = $builder->get()->getResultArray(); + + return $this->respondSuccess([ + 'total' => $total, + 'offset' => $pagination['offset'], + 'limit' => $pagination['limit'], + 'rows' => $this->transformCollection($items) + ]); + } + + public function show($id = null): ResponseInterface + { + if (!$this->hasPermission('items')) { + return $this->respondUnauthorized(); + } + + $item = $this->itemModel->find($id); + + if (!$item || $item->deleted) { + return $this->respondNotFound('Item not found'); + } + + return $this->respondSuccess($this->transformItem($item)); + } + + public function create(): ResponseInterface + { + if (!$this->hasPermission('items')) { + return $this->respondUnauthorized(); + } + + $data = $this->request->getJSON(true); + if (empty($data)) { + $data = $this->request->getPost(); + } + + $snakeData = $this->toSnakeCase($data); + + if (!empty($snakeData['item_number'])) { + if ($this->itemModel->item_number_exists($snakeData['item_number'])) { + return $this->respondError('Item number already exists', 409); + } + } + + $itemId = $this->itemModel->save_value($snakeData); + + if ($itemId) { + return $this->respondCreated(['id' => $itemId], 'Item created successfully'); + } + + return $this->respondError('Failed to create item'); + } + + public function update($id = null): ResponseInterface + { + if (!$this->hasPermission('items')) { + return $this->respondUnauthorized(); + } + + $item = $this->itemModel->find($id); + + if (!$item || $item->deleted) { + return $this->respondNotFound('Item not found'); + } + + $data = $this->request->getJSON(true); + if (empty($data)) { + $data = $this->request->getRawInput(); + } + + $snakeData = $this->toSnakeCase($data); + $snakeData['item_id'] = $id; + + $success = $this->itemModel->save_value($snakeData); + + if ($success) { + return $this->respondSuccess([], 200, 'Item updated successfully'); + } + + return $this->respondError('Failed to update item'); + } + + public function delete($id = null): ResponseInterface + { + if (!$this->hasPermission('items')) { + return $this->respondUnauthorized(); + } + + $item = $this->itemModel->find($id); + + if (!$item || $item->deleted) { + return $this->respondNotFound('Item not found'); + } + + $success = $this->itemModel->delete($id); + + if ($success) { + return $this->respondSuccess([], 200, 'Item deleted successfully'); + } + + return $this->respondError('Failed to delete item'); + } + + public function batchDelete(): ResponseInterface + { + if (!$this->hasPermission('items')) { + return $this->respondUnauthorized(); + } + + $data = $this->request->getJSON(true); + $ids = $data['ids'] ?? []; + + if (empty($ids)) { + return $this->respondError('No item IDs provided'); + } + + $success = $this->itemModel->delete_list($ids); + + if ($success) { + return $this->respondSuccess([], 200, 'Items deleted successfully'); + } + + return $this->respondError('Failed to delete items'); + } + + public function quantities($id = null): ResponseInterface + { + if (!$this->hasPermission('items')) { + return $this->respondUnauthorized(); + } + + $item = $this->itemModel->find($id); + + if (!$item || $item->deleted) { + return $this->respondNotFound('Item not found'); + } + + $locations = model('App\Models\Stock_location')->get_all(); + $quantities = []; + + foreach ($locations as $location) { + $qty = $this->itemQuantityModel->get_item_quantity($id, $location->location_id); + $quantities[] = [ + 'locationId' => (int) $location->location_id, + 'locationName' => $location->location_name, + 'quantity' => $qty ? (float) $qty->quantity : 0 + ]; + } + + return $this->respondSuccess([ + 'itemId' => (int) $id, + 'quantities' => $quantities + ]); + } + + public function suggest(): ResponseInterface + { + if (!$this->hasPermission('items')) { + return $this->respondUnauthorized(); + } + + $term = $this->request->getGet('term'); + $limit = (int) ($this->request->getGet('limit') ?? 25); + + if (empty($term)) { + return $this->respondSuccess(['suggestions' => []]); + } + + $suggestions = $this->itemModel->get_search_suggestions($term, $limit); + + return $this->respondSuccess(['suggestions' => $suggestions]); + } + + private function mapSortField(string $field): string + { + $map = [ + 'itemId' => 'item_id', + 'name' => 'name', + 'category' => 'category', + 'costPrice' => 'cost_price', + 'unitPrice' => 'unit_price' + ]; + + return $map[$field] ?? 'name'; + } +} \ No newline at end of file diff --git a/app/Controllers/Api/Suppliers.php b/app/Controllers/Api/Suppliers.php new file mode 100644 index 000000000..17c9b894b --- /dev/null +++ b/app/Controllers/Api/Suppliers.php @@ -0,0 +1,239 @@ +supplierModel = model(Supplier::class); + $this->personModel = model(Person::class); + } + + public function index(): ResponseInterface + { + if (!$this->hasPermission('suppliers')) { + return $this->respondUnauthorized(); + } + + $search = $this->request->getGet('search'); + $pagination = $this->getPagination(); + $sort = $this->getSort($this->allowedSortFields, 'companyName'); + + $builder = $this->supplierModel->builder(); + $builder->select('suppliers.*, people.*'); + $builder->join('people', 'people.person_id = suppliers.person_id'); + $builder->where('suppliers.deleted', 0); + + if ($search) { + $builder->groupStart(); + $builder->like('people.first_name', $search); + $builder->orLike('people.last_name', $search); + $builder->orLike('people.email', $search); + $builder->orLike('suppliers.account_number', $search); + $builder->orLike('suppliers.company_name', $search); + $builder->groupEnd(); + } + + $total = $builder->countAllResults(false); + + $dbSort = $this->mapSortField($sort['sort']); + $builder->orderBy($dbSort, $sort['order']); + $builder->limit($pagination['limit'], $pagination['offset']); + + $suppliers = $builder->get()->getResultArray(); + + return $this->respondSuccess([ + 'total' => $total, + 'offset' => $pagination['offset'], + 'limit' => $pagination['limit'], + 'rows' => $this->transformCollection($suppliers) + ]); + } + + public function show($id = null): ResponseInterface + { + if (!$this->hasPermission('suppliers')) { + return $this->respondUnauthorized(); + } + + $supplier = $this->supplierModel->get_info($id); + + if (empty($supplier) || $supplier->deleted) { + return $this->respondNotFound('Supplier not found'); + } + + $person = (array) $this->personModel->get_info($id); + $supplier = (array) $supplier; + $data = array_merge($person, $supplier); + + return $this->respondSuccess($this->transformItem($data)); + } + + public function create(): ResponseInterface + { + if (!$this->hasPermission('suppliers')) { + return $this->respondUnauthorized(); + } + + $data = $this->request->getJSON(true); + if (empty($data)) { + $data = $this->request->getPost(); + } + + $snakeData = []; + foreach ($data as $key => $value) { + $snakeKey = strtolower(preg_replace('/(?personModel->save_value($personData); + + if ($success && isset($personData['person_id'])) { + $personId = $personData['person_id']; + $supplierData['person_id'] = $personId; + $success = $this->supplierModel->save_value($supplierData); + } + + if ($success) { + return $this->respondCreated(['id' => $personId], 'Supplier created successfully'); + } + + return $this->respondError('Failed to create supplier'); + } + + public function update($id = null): ResponseInterface + { + if (!$this->hasPermission('suppliers')) { + return $this->respondUnauthorized(); + } + + $supplier = $this->supplierModel->get_info($id); + + if (empty($supplier) || $supplier->deleted) { + return $this->respondNotFound('Supplier not found'); + } + + $data = $this->request->getJSON(true); + if (empty($data)) { + $data = $this->request->getRawInput(); + } + + $snakeData = []; + foreach ($data as $key => $value) { + $snakeKey = strtolower(preg_replace('/(?personModel->save_value($personData, $id); + } + + if (!empty($supplierData)) { + $this->supplierModel->save_value($supplierData, $id); + } + + return $this->respondSuccess([], 200, 'Supplier updated successfully'); + } + + public function delete($id = null): ResponseInterface + { + if (!$this->hasPermission('suppliers')) { + return $this->respondUnauthorized(); + } + + $supplier = $this->supplierModel->get_info($id); + + if (empty($supplier) || $supplier->deleted) { + return $this->respondNotFound('Supplier not found'); + } + + $success = $this->supplierModel->delete($id); + + if ($success) { + return $this->respondSuccess([], 200, 'Supplier deleted successfully'); + } + + return $this->respondError('Failed to delete supplier'); + } + + public function batchDelete(): ResponseInterface + { + if (!$this->hasPermission('suppliers')) { + return $this->respondUnauthorized(); + } + + $data = $this->request->getJSON(true); + $ids = $data['ids'] ?? []; + + if (empty($ids)) { + return $this->respondError('No supplier IDs provided'); + } + + $success = $this->supplierModel->delete_list($ids); + + if ($success) { + return $this->respondSuccess([], 200, 'Suppliers deleted successfully'); + } + + return $this->respondError('Failed to delete suppliers'); + } + + public function suggest(): ResponseInterface + { + if (!$this->hasPermission('suppliers')) { + return $this->respondUnauthorized(); + } + + $term = $this->request->getGet('term'); + $limit = (int) ($this->request->getGet('limit') ?? 25); + + if (empty($term)) { + return $this->respondSuccess(['suggestions' => []]); + } + + $suggestions = $this->supplierModel->get_search_suggestions($term, $limit); + + return $this->respondSuccess(['suggestions' => $suggestions]); + } + + private function mapSortField(string $field): string + { + $map = [ + 'personId' => 'people.person_id', + 'lastName' => 'people.last_name', + 'companyName' => 'suppliers.company_name' + ]; + + return $map[$field] ?? 'suppliers.company_name'; + } +} \ No newline at end of file diff --git a/app/Controllers/ApiKeys.php b/app/Controllers/ApiKeys.php new file mode 100644 index 000000000..3ff3dca78 --- /dev/null +++ b/app/Controllers/ApiKeys.php @@ -0,0 +1,84 @@ +apiKeyModel = model(ApiKey::class); + } + + public function index(): void + { + $employeeId = $this->employee->get_logged_in_employee_info()->person_id; + $keys = $this->apiKeyModel->getKeysForEmployee($employeeId); + + echo view('api_keys/manage', [ + 'keys' => $keys, + 'employee_info' => $this->employee->get_logged_in_employee_info() + ]); + } + + public function generate(): void + { + $employeeId = $this->employee->get_logged_in_employee_info()->person_id; + $name = $this->request->getPost('name'); + $expiresAt = $this->request->getPost('expires_at') ?: null; + + $apiKey = $this->apiKeyModel->generateKey($employeeId, $name, $expiresAt); + + if ($apiKey) { + echo json_encode([ + 'success' => true, + 'message' => lang('Api_keys.key_generated'), + 'apiKey' => $apiKey, + 'keyPrefix' => substr($apiKey, 0, 12) . '...' + ]); + } else { + echo json_encode([ + 'success' => false, + 'message' => lang('Api_keys.key_generation_failed') + ]); + } + } + + public function revoke(int $apiKeyId): void + { + $employeeId = $this->employee->get_logged_in_employee_info()->person_id; + + $success = $this->apiKeyModel->revokeKey($apiKeyId, $employeeId); + + echo json_encode([ + 'success' => $success, + 'message' => $success ? lang('Api_keys.key_revoked') : lang('Api_keys.key_revoke_failed') + ]); + } + + public function regenerate(int $apiKeyId): void + { + $employeeId = $this->employee->get_logged_in_employee_info()->person_id; + + $newKey = $this->apiKeyModel->regenerateKey($apiKeyId, $employeeId); + + if ($newKey) { + echo json_encode([ + 'success' => true, + 'message' => lang('Api_keys.key_regenerated'), + 'apiKey' => $newKey, + 'keyPrefix' => substr($newKey, 0, 12) . '...' + ]); + } else { + echo json_encode([ + 'success' => false, + 'message' => lang('Api_keys.key_regeneration_failed') + ]); + } + } +} \ No newline at end of file diff --git a/app/Database/Migrations/20250310000000_ApiKeys.php b/app/Database/Migrations/20250310000000_ApiKeys.php new file mode 100644 index 000000000..ba6489ec1 --- /dev/null +++ b/app/Database/Migrations/20250310000000_ApiKeys.php @@ -0,0 +1,74 @@ +forge->addField([ + 'api_key_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true + ], + 'employee_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true + ], + 'key_hash' => [ + 'type' => 'VARCHAR', + 'constraint' => 64, + 'comment' => 'SHA-256 hash of the API key' + ], + 'key_prefix' => [ + 'type' => 'VARCHAR', + 'constraint' => 12, + 'comment' => 'First 12 chars for UI identification' + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'comment' => 'User-friendly key name' + ], + 'last_used' => [ + 'type' => 'DATETIME', + 'null' => true + ], + 'created' => [ + 'type' => 'DATETIME', + 'default' => new \CodeIgniter\Database\RawSql('CURRENT_TIMESTAMP') + ], + 'expires_at' => [ + 'type' => 'DATETIME', + 'null' => true, + 'comment' => 'Optional key expiration' + ], + 'disabled' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0 + ] + ]); + + $this->forge->addKey('api_key_id', true); + $this->forge->addKey('employee_id'); + $this->forge->addKey('key_hash'); + $this->forge->addForeignKey('employee_id', 'employees', 'person_id', 'CASCADE', 'CASCADE'); + + $this->forge->createTable('api_keys', true); + + $seeder = \Config\Database::seeder(); + $seeder->call('ApiKeysSeeder'); + } + + public function down(): void + { + $this->forge->dropTable('api_keys', true); + } +} \ No newline at end of file diff --git a/app/Database/Seeder/ApiKeysSeeder.php b/app/Database/Seeder/ApiKeysSeeder.php new file mode 100644 index 000000000..1b477655e --- /dev/null +++ b/app/Database/Seeder/ApiKeysSeeder.php @@ -0,0 +1,23 @@ +db->query(" + INSERT INTO `ospos_permissions` (`permission_id`, `module_id`) + VALUES ('api_keys', 'office') + ON DUPLICATE KEY UPDATE `permission_id` = 'api_keys' + "); + + $this->db->query(" + INSERT INTO `ospos_modules` (`module_id`, `name_lang_key`, `desc_lang_key`, `sort`) + VALUES ('api_keys', 'module_api_keys', 'module_desc_api_keys', 25) + ON DUPLICATE KEY UPDATE `module_id` = 'module_id' + "); + } +} \ No newline at end of file diff --git a/app/Filters/ApiAuth.php b/app/Filters/ApiAuth.php new file mode 100644 index 000000000..de8a11e4c --- /dev/null +++ b/app/Filters/ApiAuth.php @@ -0,0 +1,48 @@ +getHeaderLine('X-API-Key'); + + if (empty($apiKey)) { + return $this->unauthorized('API key required'); + } + + $apiKeyModel = model(ApiKey::class); + $employeeId = $apiKeyModel->validateKey($apiKey); + + if (!$employeeId) { + return $this->unauthorized('Invalid or expired API key'); + } + + $request->employeeId = $employeeId; + Services::set('apiEmployeeId', $employeeId); + + return $request; + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): mixed + { + return $response; + } + + private function unauthorized(string $message): ResponseInterface + { + return Services::response() + ->setStatusCode(401) + ->setJSON([ + 'success' => false, + 'message' => $message + ]); + } +} \ No newline at end of file diff --git a/app/Language/en/Api_keys.php b/app/Language/en/Api_keys.php new file mode 100644 index 000000000..7c0f93fd0 --- /dev/null +++ b/app/Language/en/Api_keys.php @@ -0,0 +1,29 @@ + 'API Keys', + 'module_desc_api_keys' => 'Manage API access keys for integrations.', + 'api_keys' => 'API Keys', + 'api_key' => 'API Key', + 'generate_key' => 'Generate API Key', + 'regenerate_key' => 'Regenerate', + 'revoke_key' => 'Revoke', + 'key_name' => 'Key Name', + 'key_prefix' => 'Key Prefix', + 'last_used' => 'Last Used', + 'created' => 'Created', + 'expires' => 'Expires', + 'never' => 'Never', + 'disabled' => 'Disabled', + 'key_generated' => 'API key generated successfully', + 'key_generation_failed' => 'Failed to generate API key', + 'key_revoked' => 'API key revoked successfully', + 'key_revoke_failed' => 'Failed to revoke API key', + 'key_regenerated' => 'API key regenerated successfully', + 'key_regeneration_failed' => 'Failed to regenerate API key', + 'copy_warning' => 'Copy this key now! It will not be shown again.', + 'no_keys' => 'No API keys have been generated yet.', + 'confirm_revoke' => 'Are you sure you want to revoke this API key? This action cannot be undone.', + 'confirm_regenerate' => 'Are you sure you want to regenerate this API key? The old key will immediately stop working.', + 'key_description' => 'API keys allow external applications to access your OSPOS data. Keep your keys secure and never share them publicly.', +]; \ No newline at end of file diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php new file mode 100644 index 000000000..61292e871 --- /dev/null +++ b/app/Models/ApiKey.php @@ -0,0 +1,145 @@ + $employeeId, + 'key_hash' => $keyHash, + 'key_prefix' => $keyPrefix, + 'name' => $name, + 'expires_at' => $expiresAt + ]; + + if ($this->insert($data)) { + return $apiKey; + } + + return false; + } + + public function validateKey(string $apiKey): int|false + { + if (!str_starts_with($apiKey, self::KEY_PREFIX)) { + return false; + } + + if (strlen($apiKey) !== strlen(self::KEY_PREFIX) + (self::KEY_BYTES * 2)) { + return false; + } + + $keyHash = hash('sha256', $apiKey); + + $builder = $this->builder(); + $builder->where('key_hash', $keyHash); + $builder->where('disabled', 0); + $builder->groupStart(); + $builder->where('expires_at IS NULL'); + $builder->orWhere('expires_at >', date('Y-m-d H:i:s')); + $builder->groupEnd(); + + $result = $builder->get()->getRow(); + + if ($result) { + $this->update($result->api_key_id, ['last_used' => date('Y-m-d H:i:s')]); + return (int) $result->employee_id; + } + + return false; + } + + public function getKeysForEmployee(int $employeeId): array + { + $builder = $this->builder(); + $builder->where('employee_id', $employeeId); + $builder->orderBy('created', 'DESC'); + + return $builder->get()->getResultArray(); + } + + public function revokeKey(int $apiKeyId, int $employeeId): bool + { + $builder = $this->builder(); + $builder->where('api_key_id', $apiKeyId); + $builder->where('employee_id', $employeeId); + + return $builder->update(['disabled' => 1]) !== false; + } + + public function regenerateKey(int $apiKeyId, int $employeeId): string|false + { + $existingKey = $this->builder() + ->getWhere([ + 'api_key_id' => $apiKeyId, + 'employee_id' => $employeeId + ]) + ->getRow(); + + if (!$existingKey) { + return false; + } + + $newKey = $this->generateKey( + $employeeId, + $existingKey->name, + $existingKey->expires_at + ); + + if ($newKey) { + $this->delete($apiKeyId); + return $newKey; + } + + return false; + } + + public function cleanupExpired(): int + { + $builder = $this->builder(); + $builder->where('disabled', 0); + $builder->where('expires_at <', date('Y-m-d H:i:s')); + $builder->where('expires_at IS NOT NULL'); + + $expiredKeys = $builder->get()->getResultArray(); + $count = 0; + + foreach ($expiredKeys as $key) { + if ($this->update($key['api_key_id'], ['disabled' => 1])) { + $count++; + } + } + + return $count; + } +} \ No newline at end of file diff --git a/tests/Filters/ApiAuthTest.php b/tests/Filters/ApiAuthTest.php new file mode 100644 index 000000000..30f616272 --- /dev/null +++ b/tests/Filters/ApiAuthTest.php @@ -0,0 +1,91 @@ +filter = new ApiAuth(); + $this->apiKeyModel = model(ApiKey::class); + } + + public function testBeforeWithNoApiKey(): void + { + $request = new IncomingRequest(Services::config(), Services::uri(), ''); + $request->setHeader('X-API-Key', ''); + + $result = $this->filter->before($request); + + $this->assertInstanceOf(Response::class, $result); + $this->assertEquals(401, $result->getStatusCode()); + + $body = json_decode($result->getBody(), true); + $this->assertFalse($body['success']); + $this->assertEquals('API key required', $body['message']); + } + + public function testBeforeWithInvalidApiKey(): void + { + $request = new IncomingRequest(Services::config(), Services::uri(), ''); + $request->setHeader('X-API-Key', 'ospos_invalidkey12345678901234567890123456789012345678'); + + $result = $this->filter->before($request); + + $this->assertInstanceOf(Response::class, $result); + $this->assertEquals(401, $result->getStatusCode()); + + $body = json_decode($result->getBody(), true); + $this->assertFalse($body['success']); + $this->assertEquals('Invalid or expired API key', $body['message']); + } + + public function testBeforeWithValidApiKey(): void + { + $employeeId = $this->testEmployeeId; + $rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key'); + + $request = new IncomingRequest(Services::config(), Services::uri(), ''); + $request->setHeader('X-API-Key', $rawKey); + + $result = $this->filter->before($request); + + $this->assertInstanceOf(IncomingRequest::class, $result); + $this->assertEquals($employeeId, $result->employeeId); + } + + public function testAfterReturnsResponse(): void + { + $request = new IncomingRequest(Services::config(), Services::uri(), ''); + $response = new Response(Services::config()); + + $result = $this->filter->after($request, $response); + + $this->assertInstanceOf(Response::class, $result); + } + + public function testEmployeeIdSetInService(): void + { + $employeeId = $this->testEmployeeId; + $rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key'); + + $request = new IncomingRequest(Services::config(), Services::uri(), ''); + $request->setHeader('X-API-Key', $rawKey); + + $this->filter->before($request); + + $this->assertEquals($employeeId, Services::get('apiEmployeeId')); + } +} \ No newline at end of file diff --git a/tests/Models/ApiKeyTest.php b/tests/Models/ApiKeyTest.php new file mode 100644 index 000000000..c626c50cf --- /dev/null +++ b/tests/Models/ApiKeyTest.php @@ -0,0 +1,192 @@ +apiKeyModel = new ApiKey(); + } + + public function testGenerateKey(): void + { + $employeeId = 1; + $name = 'Test API Key'; + + $rawKey = $this->apiKeyModel->generateKey($employeeId, $name); + + $this->assertNotFalse($rawKey); + $this->assertStringStartsWith('ospos_', $rawKey); + $this->assertEquals(70, strlen($rawKey)); // ospos_ prefix (6 chars) + 64 hex chars + + $keyInDb = $this->apiKeyModel->where('employee_id', $employeeId) + ->where('name', $name) + ->first(); + + $this->assertNotNull($keyInDb); + $this->assertEquals(substr($rawKey, 0, 12), $keyInDb->key_prefix); + $this->assertEquals(hash('sha256', $rawKey), $keyInDb->key_hash); + } + + public function testValidateKeySuccess(): void + { + $employeeId = 1; + $rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key'); + + $validatedEmployeeId = $this->apiKeyModel->validateKey($rawKey); + + $this->assertEquals($employeeId, $validatedEmployeeId); + } + + public function testValidateKeyInvalidFormat(): void + { + $result = $this->apiKeyModel->validateKey('invalid_key'); + $this->assertFalse($result); + + $result = $this->apiKeyModel->validateKey('ospos_short'); + $this->assertFalse($result); + + $result = $this->apiKeyModel->validateKey('otherprefix_' . str_repeat('a', 64)); + $this->assertFalse($result); + } + + public function testValidateKeyDisabled(): void + { + $employeeId = 1; + $rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key'); + + $keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first(); + $this->apiKeyModel->update($keyRecord->api_key_id, ['disabled' => 1]); + + $result = $this->apiKeyModel->validateKey($rawKey); + $this->assertFalse($result); + } + + public function testValidateKeyExpired(): void + { + $employeeId = 1; + $expiresAt = date('Y-m-d H:i:s', strtotime('-1 day')); + $rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key', $expiresAt); + + $result = $this->apiKeyModel->validateKey($rawKey); + $this->assertFalse($result); + } + + public function testValidateKeyNotExpired(): void + { + $employeeId = 1; + $expiresAt = date('Y-m-d H:i:s', strtotime('+1 day')); + $rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key', $expiresAt); + + $result = $this->apiKeyModel->validateKey($rawKey); + $this->assertEquals($employeeId, $result); + } + + public function testGetKeysForEmployee(): void + { + $employeeId = 1; + + $this->apiKeyModel->generateKey($employeeId, 'Key 1'); + $this->apiKeyModel->generateKey($employeeId, 'Key 2'); + $this->apiKeyModel->generateKey($employeeId, 'Key 3'); + + $keys = $this->apiKeyModel->getKeysForEmployee($employeeId); + + $this->assertCount(3, $keys); + } + + public function testRevokeKey(): void + { + $employeeId = 1; + $rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key'); + + $keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first(); + + $result = $this->apiKeyModel->revokeKey($keyRecord->api_key_id, $employeeId); + $this->assertTrue($result); + + $updatedKey = $this->apiKeyModel->find($keyRecord->api_key_id); + $this->assertEquals(1, $updatedKey->disabled); + + $validateResult = $this->apiKeyModel->validateKey($rawKey); + $this->assertFalse($validateResult); + } + + public function testRevokeKeyWrongEmployee(): void + { + $employeeId = 1; + $this->apiKeyModel->generateKey($employeeId, 'Test Key'); + + $keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first(); + + $result = $this->apiKeyModel->revokeKey($keyRecord->api_key_id, 999); + + $updatedKey = $this->apiKeyModel->find($keyRecord->api_key_id); + $this->assertEquals(0, $updatedKey->disabled); + } + + public function testRegenerateKey(): void + { + $employeeId = 1; + $oldKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key'); + + $keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first(); + $oldKeyId = $keyRecord->api_key_id; + + $newKey = $this->apiKeyModel->regenerateKey($oldKeyId, $employeeId); + + $this->assertNotFalse($newKey); + $this->assertNotEquals($oldKey, $newKey); + + $oldKeyValid = $this->apiKeyModel->validateKey($oldKey); + $this->assertFalse($oldKeyValid); + + $newKeyValid = $this->apiKeyModel->validateKey($newKey); + $this->assertEquals($employeeId, $newKeyValid); + + $oldKeyExists = $this->apiKeyModel->find($oldKeyId); + $this->assertNull($oldKeyExists); + } + + public function testKeyHashNotReversible(): void + { + $employeeId = 1; + $rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key'); + + $keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first(); + + $this->assertNotEquals($rawKey, $keyRecord->key_hash); + $this->assertEquals(64, strlen($keyRecord->key_hash)); + } + + public function testLastUsedUpdatesOnValidation(): void + { + $employeeId = 1; + $rawKey = $this->apiKeyModel->generateKey($employeeId, 'Test Key'); + + $keyRecord = $this->apiKeyModel->where('employee_id', $employeeId)->first(); + $this->assertNull($keyRecord->last_used); + + sleep(1); + + $this->apiKeyModel->validateKey($rawKey); + + $keyRecord = $this->apiKeyModel->find($keyRecord->api_key_id); + $this->assertNotNull($keyRecord->last_used); + } +} \ No newline at end of file