feat: Add user authentication system and secure all routes

- Implement AuthController with login/logout functionality
- Create UsersModel with bcrypt password hashing
- Add AuthFilter to protect all application routes
- Create login page with error handling
- Add users database migration with email/username fields
- Rename ResultComments to TestComments for consistency
- Update all routes to require authentication filter
- Enhance EntryApiController with comment deletion and better error handling
- Update seeder to include demo users and improved test data
- Fix BaseController to handle auth sessions properly
- Update entry views (daily/monthly) with new API endpoints
- Update layout with logout button and user info display
- Refactor control test index view for better organization
This commit is contained in:
mahdahar 2026-02-09 11:12:12 +07:00
parent ef6be6522e
commit 87ff4c8d85
20 changed files with 761 additions and 291 deletions

View File

@ -1,26 +1,31 @@
<?php
use CodeIgniter\Router\RouteCollection;
use App\Filters\AuthFilter;
/**
* @var RouteCollection $routes
*/
$routes->get('/', 'PageController::dashboard');
$routes->get('login', 'Auth\AuthController::login');
$routes->post('login', 'Auth\AuthController::processLogin');
$routes->get('logout', 'Auth\AuthController::logout');
$routes->get('/master/dept', 'PageController::masterDept');
$routes->get('/master/test', 'PageController::masterTest');
$routes->get('/master/control', 'PageController::masterControl');
$routes->get('/master/control-tests', 'PageController::controlTests');
$routes->get('/dept', 'PageController::dept');
$routes->get('/test', 'PageController::test');
$routes->get('/control', 'PageController::control');
$routes->get('/entry', 'PageController::entry');
$routes->get('/entry/daily', 'PageController::entryDaily');
$routes->get('/entry/monthly', 'PageController::entryMonthly');
$routes->get('/report', 'PageController::report');
$routes->get('/report/merged', 'PageController::reportMerged');
$routes->get('/', 'PageController::dashboard', ['filter' => AuthFilter::class]);
$routes->group('api', function ($routes) {
$routes->get('/master/dept', 'PageController::masterDept', ['filter' => AuthFilter::class]);
$routes->get('/master/test', 'PageController::masterTest', ['filter' => AuthFilter::class]);
$routes->get('/master/control', 'PageController::masterControl', ['filter' => AuthFilter::class]);
$routes->get('/master/control-tests', 'PageController::controlTests', ['filter' => AuthFilter::class]);
$routes->get('/dept', 'PageController::dept', ['filter' => AuthFilter::class]);
$routes->get('/test', 'PageController::test', ['filter' => AuthFilter::class]);
$routes->get('/control', 'PageController::control', ['filter' => AuthFilter::class]);
$routes->get('/entry', 'PageController::entry', ['filter' => AuthFilter::class]);
$routes->get('/entry/daily', 'PageController::entryDaily', ['filter' => AuthFilter::class]);
$routes->get('/entry/monthly', 'PageController::entryMonthly', ['filter' => AuthFilter::class]);
$routes->get('/report', 'PageController::report', ['filter' => AuthFilter::class]);
$routes->get('/report/merged', 'PageController::reportMerged', ['filter' => AuthFilter::class]);
$routes->group('api', ['filter' => AuthFilter::class], function ($routes) {
$routes->get('dashboard/recent', 'Api\DashboardApiController::getRecent');
$routes->get('dept', 'Api\DeptApiController::index');
$routes->get('dept/(:num)', 'Api\DeptApiController::show/$1');
@ -50,7 +55,7 @@ $routes->group('api', function ($routes) {
$routes->post('entry/comment', 'Api\EntryApiController::saveComment');
});
$routes->group('api/master', function ($routes) {
$routes->group('api/master', ['filter' => AuthFilter::class], function ($routes) {
$routes->get('depts', 'Master\MasterDeptsController::index');
$routes->get('depts/(:num)', 'Master\MasterDeptsController::show/$1');
$routes->post('depts', 'Master\MasterDeptsController::create');
@ -70,7 +75,7 @@ $routes->group('api', function ($routes) {
$routes->delete('tests/(:num)', 'Master\MasterTestsController::delete/$1');
});
$routes->group('api/qc', function ($routes) {
$routes->group('api/qc', ['filter' => AuthFilter::class], function ($routes) {
$routes->get('control-tests', 'Qc\ControlTestsController::index');
$routes->get('control-tests/(:num)', 'Qc\ControlTestsController::show/$1');
$routes->post('control-tests', 'Qc\ControlTestsController::create');
@ -83,9 +88,9 @@ $routes->group('api/qc', function ($routes) {
$routes->patch('results/(:num)', 'Qc\ResultsController::update/$1');
$routes->delete('results/(:num)', 'Qc\ResultsController::delete/$1');
$routes->get('result-comments', 'Qc\ResultCommentsController::index');
$routes->get('result-comments/(:num)', 'Qc\ResultCommentsController::show/$1');
$routes->post('result-comments', 'Qc\ResultCommentsController::create');
$routes->patch('result-comments/(:num)', 'Qc\ResultCommentsController::update/$1');
$routes->delete('result-comments/(:num)', 'Qc\ResultCommentsController::delete/$1');
$routes->get('test-comments', 'Qc\TestCommentsController::index');
$routes->get('test-comments/(:num)', 'Qc\TestCommentsController::show/$1');
$routes->post('test-comments', 'Qc\TestCommentsController::create');
$routes->patch('test-comments/(:num)', 'Qc\TestCommentsController::update/$1');
$routes->delete('test-comments/(:num)', 'Qc\TestCommentsController::delete/$1');
});

View File

@ -8,7 +8,7 @@ use App\Models\Master\MasterControlsModel;
use App\Models\Master\MasterTestsModel;
use App\Models\Qc\ResultsModel;
use App\Models\Qc\ControlTestsModel;
use App\Models\Qc\ResultCommentsModel;
use App\Models\Qc\TestCommentsModel;
class EntryApiController extends BaseController
{
@ -26,7 +26,7 @@ class EntryApiController extends BaseController
$this->testModel = new MasterTestsModel();
$this->resultModel = new ResultsModel();
$this->controlTestModel = new ControlTestsModel();
$this->commentModel = new ResultCommentsModel();
$this->commentModel = new TestCommentsModel();
}
/**
@ -134,10 +134,29 @@ class EntryApiController extends BaseController
];
}
// Merge tests with existing values
// Get all test IDs for this control
$testIds = array_column($tests, 'testId');
// Fetch comments separately from test_comments table (comments are per test + date)
$commentsData = [];
if (!empty($testIds)) {
$comments = $this->commentModel->getByTestAndDateRange($testIds, $date, $date);
foreach ($comments as $c) {
$commentsData[$c['testId']] = $c['commentText'];
}
}
// Merge tests with existing values and comments
$data = [];
foreach ($tests as $t) {
$existing = $resultsByTest[$t['testId']] ?? null;
// Get comment from either the test_comments table or existing result
$comment = $commentsData[$t['testId']] ?? null;
if ($existing && isset($existing['resComment']) && $existing['resComment']) {
$comment = $existing['resComment']; // Existing result's comment takes precedence
}
$data[] = [
'controlTestId' => $t['id'],
'controlId' => $t['controlId'],
@ -146,7 +165,8 @@ class EntryApiController extends BaseController
'testUnit' => $t['testUnit'],
'mean' => $t['mean'],
'sd' => $t['sd'],
'existingResult' => $existing
'existingResult' => $existing,
'comment' => $comment
];
}
@ -244,8 +264,8 @@ class EntryApiController extends BaseController
// Get existing results for this month
$results = $this->resultModel->getByMonth((int) $testId, $month);
// Get comments for this test (via results)
$comments = $this->commentModel->getByTest((int) $testId);
// Get comments for this test (test_id + date based)
$comments = $this->commentModel->getByTestAndMonth((int) $testId, $month);
// Map results by control_id and day
$resultsByControl = [];
@ -253,15 +273,16 @@ class EntryApiController extends BaseController
$day = (int) date('j', strtotime($r['resDate']));
$resultsByControl[$r['controlId']][$day] = [
'resultId' => $r['id'],
'resValue' => $r['resValue']
'resValue' => $r['resValue'],
'resDate' => $r['resDate']
];
}
// Map comments by result_id
$commentsByResultId = [];
// Map comments by date (comments are now per test + date)
$commentsByDate = [];
foreach ($comments as $c) {
$commentsByResultId[$c['resultId']] = [
'commentId' => $c['resultCommentId'],
$commentsByDate[$c['commentDate']] = [
'commentId' => $c['testCommentId'],
'commentText' => $c['commentText']
];
}
@ -274,9 +295,10 @@ class EntryApiController extends BaseController
foreach ($resultsByDay as $day => $val) {
$resultWithComment = $val;
// Add comment if exists for this result
if (isset($commentsByResultId[$val['resultId']])) {
$resultWithComment['resComment'] = $commentsByResultId[$val['resultId']]['commentText'];
// Add comment if exists for this date (comments are per test + date)
$resultDate = date('Y-m-d', strtotime($val['resDate']));
if (isset($commentsByDate[$resultDate])) {
$resultWithComment['resComment'] = $commentsByDate[$resultDate]['commentText'];
} else {
$resultWithComment['resComment'] = null;
}
@ -406,7 +428,7 @@ class EntryApiController extends BaseController
try {
$input = $this->request->getJSON(true);
$required = ['resultId', 'comment'];
$required = ['testId', 'date', 'comment'];
foreach ($required as $field) {
if (!isset($input[$field])) {
return $this->failValidationErrors([$field => 'Required']);
@ -414,7 +436,8 @@ class EntryApiController extends BaseController
}
$commentData = [
'result_id' => $input['resultId'],
'test_id' => $input['testId'],
'comment_date' => $input['date'],
'comment_text' => trim($input['comment'])
];

View File

@ -8,7 +8,7 @@ use App\Models\Master\MasterControlsModel;
use App\Models\Master\MasterTestsModel;
use App\Models\Qc\ControlTestsModel;
use App\Models\Qc\ResultsModel;
use App\Models\Qc\ResultCommentsModel;
use App\Models\Qc\TestCommentsModel;
class ReportApiController extends BaseController
{
@ -26,7 +26,7 @@ class ReportApiController extends BaseController
$this->dictTestModel = new MasterTestsModel();
$this->controlTestModel = new ControlTestsModel();
$this->resultModel = new ResultsModel();
$this->commentModel = new ResultCommentsModel();
$this->commentModel = new TestCommentsModel();
}
public function getReport()
@ -47,7 +47,13 @@ class ReportApiController extends BaseController
$controlTest = $this->controlTestModel->getByControlAndTest($control['controlId'], $test);
$results = $this->resultModel->getByControlAndMonth($control['controlId'], $test, $dates);
$comment = $this->commentModel->getByControlTestMonth($control['controlId'], $test, $dates);
// Get all comments for this test in the month (comments are now per test + date)
$allComments = $this->commentModel->getByTestAndMonth($test, $dates);
// Index comments by date for easy lookup
$commentsByDate = [];
foreach ($allComments as $c) {
$commentsByDate[$c['commentDate']] = $c['commentText'];
}
$testInfo = $this->dictTestModel->find($test);
$outOfRangeCount = 0;
@ -97,7 +103,7 @@ class ReportApiController extends BaseController
'results' => $processedResults,
'values' => $values,
'test' => $testInfo,
'comment' => $comment,
'comments' => $commentsByDate,
'outOfRange' => $outOfRangeCount
];
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Controllers\Auth;
use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Auth\UsersModel;
class AuthController extends BaseController
{
use ResponseTrait;
protected $model;
public function __construct()
{
$this->model = new UsersModel();
}
public function login()
{
if ($this->session->get('isLoggedIn')) {
return redirect()->to('/');
}
return view('auth/login');
}
public function processLogin()
{
$input = $this->request->getJSON(true);
if (!$input) {
return $this->respond([
'status' => 'error',
'message' => 'Invalid request'
], 400);
}
$username = $input['username'] ?? '';
$password = $input['password'] ?? '';
$remember = $input['remember'] ?? false;
if (empty($username) || empty($password)) {
return $this->respond([
'status' => 'error',
'message' => 'Username and password are required'
], 400);
}
$user = $this->model->findByUsername($username);
if (!$user) {
return $this->respond([
'status' => 'error',
'message' => 'Invalid username or password'
], 401);
}
if (!password_verify($password, $user['password'])) {
return $this->respond([
'status' => 'error',
'message' => 'Invalid username or password'
], 401);
}
$this->session->set([
'isLoggedIn' => true,
'userId' => $user['userId'],
'username' => $user['username']
]);
if ($remember) {
$token = bin2hex(random_bytes(32));
$this->model->setRememberToken($user['userId'], $token);
set_cookie('remember_token', $token, 60 * 60 * 24 * 30);
}
return $this->respond([
'status' => 'success',
'message' => 'Login successful',
'redirect' => base_url('/')
], 200);
}
public function logout()
{
$userId = $this->session->get('userId');
if ($userId) {
$this->model->setRememberToken($userId, null);
}
delete_cookie('remember_token');
$this->session->destroy();
return redirect()->to('/login');
}
}

View File

@ -13,11 +13,11 @@ abstract class BaseController extends Controller
use ResponseTrait;
protected $session;
protected $helpers = ['form', 'url', 'cookie', 'json', 'stringcase'];
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
parent::initController($request, $response, $logger);
$this->session = \Config\Services::session();
$this->helpers = ['form', 'url', 'json', 'stringcase'];
}
}

View File

@ -3,16 +3,16 @@ namespace App\Controllers\Qc;
use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Qc\ResultCommentsModel;
use App\Models\Qc\TestCommentsModel;
class ResultCommentsController extends BaseController {
class TestCommentsController extends BaseController {
use ResponseTrait;
protected $model;
protected $rules;
public function __construct() {
$this->model = new ResultCommentsModel();
$this->model = new TestCommentsModel();
$this->rules = [];
}

View File

@ -88,23 +88,25 @@ class QualityControlSystem extends Migration
$this->forge->addForeignKey('test_id', 'master_tests', 'test_id', 'SET NULL', 'CASCADE');
$this->forge->createTable('results');
// result_comments
// test_comments
$this->forge->addField([
'result_comment_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'result_id' => ['type' => 'INT', 'unsigned' => true],
'test_comment_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'test_id' => ['type' => 'INT', 'unsigned' => true],
'comment_date' => ['type' => 'DATE', 'null' => true],
'comment_text' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('result_comment_id', true);
$this->forge->addForeignKey('result_id', 'results', 'result_id', 'CASCADE', 'CASCADE');
$this->forge->createTable('result_comments');
$this->forge->addKey('test_comment_id', true);
$this->forge->addUniqueKey(['test_id', 'comment_date']);
$this->forge->addForeignKey('test_id', 'master_tests', 'test_id', 'CASCADE', 'CASCADE');
$this->forge->createTable('test_comments');
}
public function down()
{
$this->forge->dropTable('result_comments', true);
$this->forge->dropTable('test_comments', true);
$this->forge->dropTable('results', true);
$this->forge->dropTable('control_tests', true);
$this->forge->dropTable('master_tests', true);

View File

@ -0,0 +1,33 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class Users extends Migration
{
public function up()
{
$this->forge->addField([
'user_id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
'username' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false],
'password' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false],
'remember_token' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
'deleted_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('user_id', true);
$this->forge->addUniqueKey('username');
$this->forge->createTable('master_users');
// Insert default admin user
$password = password_hash('admin123', PASSWORD_DEFAULT);
$this->db->query("INSERT INTO master_users (username, password, created_at, updated_at) VALUES ('admin', '{$password}', NOW(), NOW())");
}
public function down()
{
$this->forge->dropTable('master_users', true);
}
}

View File

@ -13,7 +13,7 @@ class CmodQcSeeder extends Seeder
$this->seedTests();
$this->seedControlTests();
$this->seedResults();
$this->seedResultComments();
$this->seedTestComments();
}
protected function seedDepts()
@ -36,10 +36,14 @@ class CmodQcSeeder extends Seeder
['dept_id' => 2, 'control_name' => 'QC Low Hema', 'lot' => 'QC2024004', 'producer' => 'Streck', 'exp_date' => '2025-11-30'],
['dept_id' => 3, 'control_name' => 'QC Normal Immuno', 'lot' => 'QC2024005', 'producer' => 'Roche', 'exp_date' => '2025-10-31'],
['dept_id' => 4, 'control_name' => 'QC Normal Urine', 'lot' => 'QC2024006', 'producer' => 'Siemens', 'exp_date' => '2025-09-30'],
// New controls for January 2026
// January 2026 controls
['dept_id' => 1, 'control_name' => 'Trulab N', 'lot' => 'TN2026001', 'producer' => 'Trinity', 'exp_date' => '2026-12-31'],
['dept_id' => 1, 'control_name' => 'Trulab P', 'lot' => 'TP2026001', 'producer' => 'Trinity', 'exp_date' => '2026-12-31'],
['dept_id' => 1, 'control_name' => 'Cholestest', 'lot' => 'CT2026001', 'producer' => 'Roche', 'exp_date' => '2026-12-31'],
// February 2026 controls (new lots)
['dept_id' => 2, 'control_name' => 'QC Normal Hema', 'lot' => 'H202602', 'producer' => 'Streck', 'exp_date' => '2026-12-31'],
['dept_id' => 2, 'control_name' => 'QC Low Hema', 'lot' => 'HL202602', 'producer' => 'Streck', 'exp_date' => '2026-12-31'],
['dept_id' => 1, 'control_name' => 'Trulab N', 'lot' => 'TN202602', 'producer' => 'Trinity', 'exp_date' => '2026-12-31'],
];
$this->db->table('master_controls')->insertBatch($controls);
}
@ -87,6 +91,15 @@ class CmodQcSeeder extends Seeder
['control_id' => 8, 'test_id' => 2, 'mean' => 2.4, 'sd' => 0.12], // Trulab P - Creatinine
['control_id' => 8, 'test_id' => 4, 'mean' => 195, 'sd' => 12], // Trulab P - Cholesterol
['control_id' => 9, 'test_id' => 4, 'mean' => 180, 'sd' => 10], // Cholestest - Cholesterol
// February 2026 control-tests
['control_id' => 10, 'test_id' => 5, 'mean' => 7.6, 'sd' => 0.5], // QC Normal Hema (Feb) - WBC
['control_id' => 10, 'test_id' => 6, 'mean' => 4.7, 'sd' => 0.2], // QC Normal Hema (Feb) - RBC
['control_id' => 10, 'test_id' => 7, 'mean' => 14.8, 'sd' => 0.4], // QC Normal Hema (Feb) - HGB
['control_id' => 11, 'test_id' => 5, 'mean' => 3.4, 'sd' => 0.25], // QC Low Hema (Feb) - WBC
['control_id' => 11, 'test_id' => 6, 'mean' => 2.4, 'sd' => 0.14], // QC Low Hema (Feb) - RBC
['control_id' => 12, 'test_id' => 1, 'mean' => 92, 'sd' => 4.2], // Trulab N (Feb) - Glucose
['control_id' => 12, 'test_id' => 2, 'mean' => 0.95, 'sd' => 0.05], // Trulab N (Feb) - Creatinine
['control_id' => 12, 'test_id' => 4, 'mean' => 148, 'sd' => 9], // Trulab N (Feb) - Cholesterol
];
$this->db->table('control_tests')->insertBatch($controlTests);
}
@ -94,17 +107,19 @@ class CmodQcSeeder extends Seeder
protected function seedResults()
{
$faker = \Faker\Factory::create();
$resultDate = '2026-01-01';
$results = [];
$controlTests = $this->db->table('control_tests')->get()->getResultArray();
$resultCount = 0;
$maxResults = 150; // Increased for more test data
// January 2026 results (days 1-31)
foreach ($controlTests as $ct) {
$numResults = $faker->numberBetween(3, 4);
$numResults = $faker->numberBetween(3, 5);
for ($i = 0; $i < $numResults && $resultCount < 50; $i++) {
$resDate = date('Y-m-d', strtotime($resultDate . ' +' . $faker->numberBetween(0, 20) . ' days'));
for ($i = 0; $i < $numResults && $resultCount < $maxResults; $i++) {
$day = $faker->numberBetween(1, 31);
$resDate = "2026-01-" . str_pad($day, 2, '0', STR_PAD_LEFT);
$value = $ct['mean'] + ($faker->randomFloat(2, -2.5, 2.5) * $ct['sd']);
$results[] = [
@ -118,60 +133,101 @@ class CmodQcSeeder extends Seeder
$resultCount++;
}
}
// February 2026 results (days 1-28)
foreach ($controlTests as $ct) {
$numResults = $faker->numberBetween(3, 5);
for ($i = 0; $i < $numResults && $resultCount < $maxResults; $i++) {
$day = $faker->numberBetween(1, 28);
$resDate = "2026-02-" . str_pad($day, 2, '0', STR_PAD_LEFT);
$value = $ct['mean'] + ($faker->randomFloat(2, -2.5, 2.5) * $ct['sd']);
$results[] = [
'control_id' => $ct['control_id'],
'test_id' => $ct['test_id'],
'res_date' => $resDate,
'res_value' => round($value, 2),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
$resultCount++;
}
}
$this->db->table('results')->insertBatch($results);
}
protected function seedResultComments()
protected function seedTestComments()
{
// Get all results to associate comments with specific results
$results = $this->db->table('results')->get()->getResultArray();
$faker = \Faker\Factory::create();
$comments = [];
if (empty($results)) {
return;
}
// Get all tests
$tests = $this->db->table('master_tests')->get()->getResultArray();
// Map control_id + test_id to result_ids
$resultMap = [];
foreach ($results as $result) {
$key = $result['control_id'] . '_' . $result['test_id'];
$resultMap[$key][] = $result['result_id'];
}
// Comments data with control_id + test_id for mapping
$commentsData = [
['control_id' => 1, 'test_id' => 1, 'comment_text' => 'Slight drift observed, instrument recalibrated on 01/15'],
['control_id' => 2, 'test_id' => 4, 'comment_text' => 'High cholesterol values noted, lot change recommended'],
['control_id' => 3, 'test_id' => 5, 'comment_text' => 'WBC controls stable throughout the month'],
['control_id' => 4, 'test_id' => 6, 'comment_text' => 'RBC QC intermittent shift, probe cleaned'],
['control_id' => 5, 'test_id' => 8, 'comment_text' => 'TSH assay maintenance performed on 01/10'],
['control_id' => 6, 'test_id' => 10, 'comment_text' => 'Urine protein controls within range'],
['control_id' => 1, 'test_id' => 2, 'comment_text' => 'Creatinine QC stable, no issues'],
['control_id' => 2, 'test_id' => 1, 'comment_text' => 'Glucose high QC showed consistent elevation, reagent lot changed'],
['control_id' => 3, 'test_id' => 7, 'comment_text' => 'Hemoglobin QC performance acceptable'],
['control_id' => 5, 'test_id' => 9, 'comment_text' => 'Free T4 calibration curve verified'],
// New control comments for January 2026
['control_id' => 7, 'test_id' => 1, 'comment_text' => 'Trulab N Glucose stable throughout January'],
['control_id' => 7, 'test_id' => 2, 'comment_text' => 'Trulab N Creatinine within acceptable range'],
['control_id' => 7, 'test_id' => 4, 'comment_text' => 'Trulab N Cholesterol performance satisfactory'],
['control_id' => 8, 'test_id' => 1, 'comment_text' => 'Trulab P Glucose elevated, monitoring continued'],
['control_id' => 8, 'test_id' => 2, 'comment_text' => 'Trulab P Creatinine QC stable'],
['control_id' => 8, 'test_id' => 4, 'comment_text' => 'Trulab P Cholesterol consistent with expected values'],
['control_id' => 9, 'test_id' => 4, 'comment_text' => 'Cholestest performance verified, no issues'],
// Comment templates for different test types
$commentTemplates = [
'QC stable throughout the period',
'Slight drift observed, monitoring continued',
'Calibration verified, values within range',
'Reagent lot changed, new QC run initiated',
'Instrument maintenance performed',
'Shift detected, corrective action taken',
'Control values consistent with previous lot',
'Temperature check completed, within specs',
'New lot validated successfully',
'Periodic check satisfactory',
];
$comments = [];
foreach ($commentsData as $data) {
$key = $data['control_id'] . '_' . $data['test_id'];
if (isset($resultMap[$key]) && !empty($resultMap[$key])) {
// Attach comment to the first matching result
// Generate comments for Jan 2026 (days 1-31)
foreach ($tests as $test) {
$numComments = $faker->numberBetween(3, 6);
$usedDates = [];
for ($i = 0; $i < $numComments; $i++) {
// Pick a random date in January
do {
$day = $faker->numberBetween(1, 31);
$dateKey = "2026-01-" . str_pad($day, 2, '0', STR_PAD_LEFT);
} while (in_array($dateKey, $usedDates));
$usedDates[] = $dateKey;
$comments[] = [
'result_id' => $resultMap[$key][0],
'comment_text' => $data['comment_text'],
'test_id' => $test['test_id'],
'comment_date' => $dateKey,
'comment_text' => $faker->randomElement($commentTemplates),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
}
}
$this->db->table('result_comments')->insertBatch($comments);
// Generate comments for Feb 2026 (days 1-28)
foreach ($tests as $test) {
$numComments = $faker->numberBetween(2, 5);
$usedDates = [];
for ($i = 0; $i < $numComments; $i++) {
// Pick a random date in February
do {
$day = $faker->numberBetween(1, 28);
$dateKey = "2026-02-" . str_pad($day, 2, '0', STR_PAD_LEFT);
} while (in_array($dateKey, $usedDates));
$usedDates[] = $dateKey;
$comments[] = [
'test_id' => $test['test_id'],
'comment_date' => $dateKey,
'comment_text' => $faker->randomElement($commentTemplates) . ' (Feb)',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
}
}
$this->db->table('test_comments')->insertBatch($comments);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
use App\Models\Auth\UsersModel;
class AuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$session = Services::session();
$uri = service('uri');
$currentPath = $uri->getPath();
// Skip auth filter for login/logout routes
$excludedPaths = ['login', 'logout'];
if (in_array($currentPath, $excludedPaths)) {
return;
}
// Check if user is logged in
if (!$session->get('isLoggedIn')) {
// Check for remember token
$rememberToken = $_COOKIE['remember_token'] ?? null;
if ($rememberToken) {
$usersModel = new UsersModel();
$user = $usersModel->findByRememberToken($rememberToken);
if ($user) {
// Auto-login with remember token
$session->set([
'isLoggedIn' => true,
'userId' => $user['user_id'],
'username' => $user['username']
]);
return;
}
}
return redirect()->to('/login');
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// Do nothing
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models\Auth;
use App\Models\BaseModel;
class UsersModel extends BaseModel
{
protected $table = 'master_users';
protected $primaryKey = 'user_id';
protected $allowedFields = ['username', 'password', 'remember_token'];
protected $useSoftDeletes = true;
protected $useTimestamps = true;
public function findByUsername($username)
{
return $this->where('username', $username)->first();
}
public function verifyPassword($userId, $password)
{
$user = $this->find($userId);
if (!$user) {
return false;
}
return password_verify($password, $user['password']);
}
public function setRememberToken($userId, $token)
{
return $this->update($userId, ['remember_token' => $token]);
}
public function findByRememberToken($token)
{
return $this->where('remember_token', $token)->first();
}
}

View File

@ -1,152 +0,0 @@
<?php
namespace App\Models\Qc;
use App\Models\BaseModel;
class ResultCommentsModel extends BaseModel {
protected $table = 'result_comments';
protected $primaryKey = 'result_comment_id';
protected $allowedFields = [
'result_id',
'comment_text',
'created_at',
'updated_at',
'deleted_at'
];
protected $useTimestamps = true;
protected $useSoftDeletes = true;
public function search($keyword = null) {
if ($keyword) {
return $this->groupStart()
->like('comment_text', $keyword)
->groupEnd()
->findAll();
}
return $this->findAll();
}
/**
* Get comments by result_id
*/
public function getByResult(int $resultId): ?array {
return $this->where('result_id', $resultId)
->where('deleted_at', null)
->first();
}
/**
* Get all comments for a control+test combination (via results)
*/
public function getByControlTest(int $controlId, int $testId): ?array {
// First get result IDs for this control+test
$db = \Config\Database::connect();
$results = $db->table('results')
->select('result_id')
->where('control_id', $controlId)
->where('test_id', $testId)
->where('deleted_at', null)
->get()
->getResultArray();
if (empty($results)) {
return null;
}
$resultIds = array_column($results, 'result_id');
return $this->whereIn('result_id', $resultIds)
->where('deleted_at', null)
->orderBy('created_at', 'DESC')
->findAll();
}
/**
* Get all comments for a test (via results)
*/
public function getByTest(int $testId): array {
$db = \Config\Database::connect();
$results = $db->table('results')
->select('result_id')
->where('test_id', $testId)
->where('deleted_at', null)
->get()
->getResultArray();
if (empty($results)) {
return [];
}
$resultIds = array_column($results, 'result_id');
return $this->whereIn('result_id', $resultIds)
->where('deleted_at', null)
->findAll();
}
/**
* Get comments by control, test, and month (for reports)
*/
public function getByControlTestMonth(int $controlId, int $testId, string $month): ?array {
$db = \Config\Database::connect();
$results = $db->table('results')
->select('result_id')
->where('control_id', $controlId)
->where('test_id', $testId)
->where('res_date >=', $month . '-01')
->where('res_date <=', $month . '-31')
->where('deleted_at', null)
->get()
->getResultArray();
if (empty($results)) {
return null;
}
$resultIds = array_column($results, 'result_id');
$comments = $this->whereIn('result_id', $resultIds)
->where('deleted_at', null)
->orderBy('created_at', 'DESC')
->findAll();
return $comments ?: null;
}
/**
* Get comments for multiple results
*/
public function getByResultIds(array $resultIds): array {
if (empty($resultIds)) {
return [];
}
return $this->whereIn('result_id', $resultIds)
->where('deleted_at', null)
->findAll();
}
/**
* Upsert comment for a result
*/
public function upsertComment(array $data): int {
if (!isset($data['result_id'])) {
return 0;
}
$existing = $this->where('result_id', $data['result_id'])
->where('deleted_at', null)
->first();
if ($existing) {
if (empty($data['comment_text'])) {
// If text is empty, soft delete
$this->update($existing['result_comment_id'], ['deleted_at' => date('Y-m-d H:i:s')]);
return $existing['result_comment_id'];
}
$this->update($existing['result_comment_id'], $data);
return $existing['result_comment_id'];
} else {
if (empty($data['comment_text'])) {
return 0; // Don't insert empty comments
}
return $this->insert($data, true);
}
}
}

View File

@ -39,9 +39,9 @@ class ResultsModel extends BaseModel {
r.test_id as testId,
r.res_date as resDate,
r.res_value as resValue,
rc.comment_text as resComment
tc.comment_text as resComment
');
$builder->join('result_comments rc', 'rc.result_id = r.result_id AND rc.deleted_at IS NULL', 'left');
$builder->join('test_comments tc', 'tc.test_id = r.test_id AND tc.comment_date = r.res_date AND tc.deleted_at IS NULL', 'left');
$builder->where('r.res_date', $date);
$builder->where('r.control_id', $controlId);
$builder->where('r.deleted_at', null);
@ -60,9 +60,9 @@ class ResultsModel extends BaseModel {
r.test_id as testId,
r.res_date as resDate,
r.res_value as resValue,
rc.comment_text as resComment
tc.comment_text as resComment
');
$builder->join('result_comments rc', 'rc.result_id = r.result_id AND rc.deleted_at IS NULL', 'left');
$builder->join('test_comments tc', 'tc.test_id = r.test_id AND tc.comment_date = r.res_date AND tc.deleted_at IS NULL', 'left');
$builder->where('r.test_id', $testId);
$builder->where('r.res_date >=', $month . '-01');
$builder->where('r.res_date <=', $month . '-31');
@ -81,9 +81,9 @@ class ResultsModel extends BaseModel {
r.result_id as id,
r.res_date as resDate,
r.res_value as resValue,
rc.comment_text as resComment
tc.comment_text as resComment
');
$builder->join('result_comments rc', 'rc.result_id = r.result_id AND rc.deleted_at IS NULL', 'left');
$builder->join('test_comments tc', 'tc.test_id = r.test_id AND tc.comment_date = r.res_date AND tc.deleted_at IS NULL', 'left');
$builder->where('r.control_id', $controlId);
$builder->where('r.test_id', $testId);
$builder->where('r.res_date >=', $month . '-01');

View File

@ -0,0 +1,137 @@
<?php
namespace App\Models\Qc;
use App\Models\BaseModel;
class TestCommentsModel extends BaseModel {
protected $table = 'test_comments';
protected $primaryKey = 'test_comment_id';
protected $allowedFields = [
'test_id',
'comment_date',
'comment_text',
'created_at',
'updated_at',
'deleted_at'
];
protected $useTimestamps = true;
protected $useSoftDeletes = true;
public function search($keyword = null) {
if ($keyword) {
return $this->groupStart()
->like('comment_text', $keyword)
->groupEnd()
->findAll();
}
return $this->findAll();
}
/**
* Get comment by test_id and date
*/
public function getByTestAndDate(int $testId, string $date): ?array {
return $this->where('test_id', $testId)
->where('comment_date', $date)
->where('deleted_at', null)
->first();
}
/**
* Get all comments for a test
*/
public function getByTest(int $testId): array {
return $this->where('test_id', $testId)
->where('deleted_at', null)
->orderBy('comment_date', 'DESC')
->findAll();
}
/**
* Get comments for test(s) within a date range
* @param int|array $testId Single test ID or array of test IDs
*/
public function getByTestAndDateRange($testId, string $startDate, string $endDate): array {
$builder = $this->where('comment_date >=', $startDate)
->where('comment_date <=', $endDate)
->where('deleted_at', null);
if (is_array($testId)) {
$builder->whereIn('test_id', $testId);
} else {
$builder->where('test_id', $testId);
}
return $builder->orderBy('comment_date', 'ASC')
->findAll();
}
/**
* Get comments by month for a test
*/
public function getByTestAndMonth(int $testId, string $month): array {
return $this->where('test_id', $testId)
->where('comment_date >=', $month . '-01')
->where('comment_date <=', $month . '-31')
->where('deleted_at', null)
->orderBy('comment_date', 'ASC')
->findAll();
}
/**
* Get all comments for multiple tests
*/
public function getByTestIds(array $testIds): array {
if (empty($testIds)) {
return [];
}
return $this->whereIn('test_id', $testIds)
->where('deleted_at', null)
->orderBy('comment_date', 'DESC')
->findAll();
}
/**
* Upsert comment for a test + date combination
*/
public function upsertComment(array $data): int {
if (!isset($data['test_id']) || !isset($data['comment_date'])) {
return 0;
}
$existing = $this->where('test_id', $data['test_id'])
->where('comment_date', $data['comment_date'])
->where('deleted_at', null)
->first();
if ($existing) {
if (empty($data['comment_text'])) {
// If text is empty, soft delete
$this->update($existing['test_comment_id'], ['deleted_at' => date('Y-m-d H:i:s')]);
return $existing['test_comment_id'];
}
$this->update($existing['test_comment_id'], $data);
return $existing['test_comment_id'];
} else {
if (empty($data['comment_text'])) {
return 0; // Don't insert empty comments
}
return $this->insert($data, true);
}
}
/**
* Delete comment by test_id and date
*/
public function deleteByTestAndDate(int $testId, string $date): bool {
$existing = $this->where('test_id', $testId)
->where('comment_date', $date)
->where('deleted_at', null)
->first();
if ($existing) {
return $this->delete($existing['test_comment_id']);
}
return false;
}
}

171
app/Views/auth/login.php Normal file
View File

@ -0,0 +1,171 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en" data-theme="autumn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - TinyQC</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.7.2/css/all.min.css">
<script>
const BASEURL = '<?= base_url(''); ?>';
</script>
</head>
<body class="bg-base-200 min-h-screen flex items-center justify-center p-4" x-data="loginData()">
<div class="w-full max-w-md">
<div class="card bg-base-100 shadow-xl border border-base-300">
<div class="card-body p-8">
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary mb-4 shadow-lg shadow-primary/20">
<i class="fa-solid fa-flask text-white text-2xl"></i>
</div>
<h1 class="text-2xl font-bold text-base-content">TinyQC</h1>
<p class="text-sm text-base-content/60 mt-1">QC Management System</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<label class="input input-bordered w-full flex items-center gap-2" :class="{ 'input-error': errors.username }">
<i class="fa-solid fa-user text-sm opacity-70"></i>
<input type="text" x-model="form.username" placeholder="Username" class="grow" required autofocus>
</label>
<span x-show="errors.username" x-text="errors.username" class="text-error text-xs mt-1"></span>
<label class="input input-bordered w-full flex items-center gap-2" :class="{ 'input-error': errors.password }">
<i class="fa-solid fa-lock text-sm opacity-70"></i>
<input :type="showPassword ? 'text' : 'password'" x-model="form.password" placeholder="Password" class="grow" required>
<button type="button" @click="showPassword = !showPassword" class="opacity-70 hover:opacity-100">
<i class="fa-solid" :class="showPassword ? 'fa-eye-slash' : 'fa-eye'"></i>
</button>
</label>
<span x-show="errors.password" x-text="errors.password" class="text-error text-xs mt-1"></span>
<div class="flex items-center justify-between">
<label class="label cursor-pointer flex items-center gap-2">
<input type="checkbox" x-model="form.remember" class="checkbox checkbox-primary checkbox-sm">
<span class="label-text text-sm">Remember me</span>
</label>
</div>
<div x-show="errorMessage" x-transition
class="alert alert-error alert-sm text-sm py-2">
<i class="fa-solid fa-circle-exclamation text-sm"></i>
<span x-text="errorMessage"></span>
</div>
<button type="submit" class="btn btn-primary w-full" :class="{ 'loading': loading }"
:disabled="loading">
<span x-show="!loading">
<i class="fa-solid fa-sign-in-alt text-sm mr-2"></i>Sign In
</span>
<span x-show="loading">Signing in...</span>
</button>
</form>
<div class="divider text-xs text-base-content/50">or</div>
<div class="text-center">
<button @click="toggleTheme()" class="btn btn-ghost btn-sm">
<i class="fa-solid fa-sun text-lg text-warning" x-show="currentTheme === 'autumn'"></i>
<i class="fa-solid fa-moon text-lg text-neutral-content" x-show="currentTheme === 'dracula'"></i>
<span class="ml-2" x-text="currentTheme === 'autumn' ? 'Dark Mode' : 'Light Mode'"></span>
</button>
</div>
</div>
</div>
<p class="text-center text-sm text-base-content/50 mt-6">
&copy; <?= date('Y') ?> TinyQC - PT.Summit
</p>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('loginData', () => ({
loading: false,
showPassword: false,
errorMessage: '',
errors: {
username: '',
password: ''
},
form: {
username: '',
password: '',
remember: false
},
get currentTheme() {
return localStorage.getItem('theme') || 'autumn';
},
set currentTheme(value) {
localStorage.setItem('theme', value);
document.documentElement.setAttribute('data-theme', value);
},
init() {
document.documentElement.setAttribute('data-theme', this.currentTheme);
},
toggleTheme() {
this.currentTheme = this.currentTheme === 'autumn' ? 'dracula' : 'autumn';
},
validateForm() {
this.errors = { username: '', password: '' };
let isValid = true;
if (!this.form.username.trim()) {
this.errors.username = 'Username is required';
isValid = false;
}
if (!this.form.password) {
this.errors.password = 'Password is required';
isValid = false;
}
return isValid;
},
async handleSubmit() {
this.errorMessage = '';
if (!this.validateForm()) {
return;
}
this.loading = true;
try {
const response = await fetch(`${BASEURL}login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
username: this.form.username,
password: this.form.password,
remember: this.form.remember
})
});
const data = await response.json();
if (data.status === 'success') {
window.location.href = data.redirect || BASEURL;
} else {
this.errorMessage = data.message || 'Invalid username or password';
}
} catch (error) {
this.errorMessage = 'An error occurred. Please try again.';
} finally {
this.loading = false;
}
}
}));
});
</script>
</body>
</html>

View File

@ -243,8 +243,10 @@ document.addEventListener('alpine:init', () => {
if (test.existingResult && test.existingResult.resValue !== null) {
this.resultsData[test.testId] = test.existingResult.resValue;
}
if (test.existingResult && test.existingResult.resComment) {
this.commentsData[test.testId] = test.existingResult.resComment;
// Get comment from either the new comment field or existing result
const comment = test.comment || (test.existingResult && test.existingResult.resComment);
if (comment) {
this.commentsData[test.testId] = comment;
}
}
} catch (e) {
@ -288,7 +290,7 @@ document.addEventListener('alpine:init', () => {
for (const item of savedIds) {
const comment = this.commentsData[item.testId];
if (comment) {
await this.saveComment(item.resultId, comment);
await this.saveComment(item.testId, this.date, comment);
}
}
@ -309,13 +311,14 @@ document.addEventListener('alpine:init', () => {
}
},
async saveComment(resultId, commentText) {
async saveComment(testId, date, commentText) {
try {
const response = await fetch(`${BASEURL}api/entry/comment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resultId: resultId,
testId: testId,
date: date,
comment: commentText
})
});

View File

@ -446,14 +446,12 @@ document.addEventListener('alpine:init', () => {
const json = await response.json();
if (json.status === 'success') {
// Save comments using the returned result ID map
const resultIdMap = json.data.resultIdMap || {};
// Save comments for changed entries
for (const key in this.commentsData) {
if (this.originalComments[key] !== this.commentsData[key]) {
const resultId = resultIdMap[key];
if (resultId) {
await this.saveComment(resultId, this.commentsData[key]);
}
const [controlId, day] = key.split('_');
const date = `${this.month}-${day.padStart(2, '0')}`;
await this.saveComment(this.selectedTest, date, this.commentsData[key]);
}
}
@ -470,13 +468,14 @@ document.addEventListener('alpine:init', () => {
}
},
async saveComment(resultId, commentText) {
async saveComment(testId, date, commentText) {
try {
const response = await fetch(`${BASEURL}api/entry/comment`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
resultId: resultId,
testId: testId,
date: date,
comment: commentText
})
});

View File

@ -45,22 +45,20 @@
<i class="fa-solid fa-sun text-warning" x-show="currentTheme === themeConfig.light"></i>
<i class="fa-solid fa-moon text-neutral-content" x-show="currentTheme === themeConfig.dark"></i>
</button>
<div class="dropdown dropdown-end" x-data="{ dropdownOpen: false }">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar placeholder"
@click="dropdownOpen = !dropdownOpen">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar placeholder">
<div
class="bg-primary text-primary-content rounded-full w-10 h-10 flex items-center justify-center">
<span><?= $pageData['userInitials'] ?? 'DR' ?></span>
<span><?= $pageData['userInitials'] ?? strtoupper(substr(session()->get('username', 'U'), 0, 1)) ?></span>
</div>
</div>
<ul tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow-xl bg-base-100 rounded-box w-52 border border-base-300"
x-show="dropdownOpen" @click.outside="dropdownOpen = false" x-transition>
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow-xl bg-base-100 rounded-box w-52 border border-base-300">
<li class="menu-title px-4 py-2">
<span
class="text-base-content font-bold"><?= $pageData['userName'] ?? 'Lab User' ?></span>
class="text-base-content font-bold"><?= $pageData['userName'] ?? ucfirst(session()->get('username', 'User')) ?></span>
<span
class="text-xs text-primary font-medium"><?= $pageData['userRole'] ?? 'Administrator' ?></span>
class="text-xs text-primary font-medium"><?= $pageData['userRole'] ?? 'User' ?></span>
</li>
<div class="divider my-0 h-px opacity-10"></div>
<li><a class="hover:bg-base-200"><i class="fa-solid fa-user text-primary"></i> Profile</a>

View File

@ -107,7 +107,7 @@
x-text="formatNumber(item.sd)"></td>
<td class="text-right pr-4">
<div
class="flex justify-end gap-1 opacity-0 group-hover/row:opacity-100 transition-opacity">
class="flex justify-end gap-1">
<button
class="btn btn-xs btn-square btn-ghost text-amber-600"
@click="showForm(item.controlTestId)">

View File

@ -134,9 +134,9 @@
"methods": ["index", "show", "create", "update", "delete"]
},
{
"name": "ResultCommentsController",
"name": "TestCommentsController",
"namespace": "App\\Controllers\\Qc",
"file": "app/Controllers/Qc/ResultCommentsController.php",
"file": "app/Controllers/Qc/TestCommentsController.php",
"methods": ["index", "show", "create", "update", "delete"]
}
],
@ -186,11 +186,11 @@
"pk": "control_test_id"
},
{
"name": "ResultCommentsModel",
"name": "TestCommentsModel",
"namespace": "App\\Models\\Qc",
"file": "app/Models/Qc/ResultCommentsModel.php",
"table": "result_comments",
"pk": "result_comment_id"
"file": "app/Models/Qc/TestCommentsModel.php",
"table": "test_comments",
"pk": "test_comment_id"
}
],
"apiEndpoints": [
@ -219,11 +219,11 @@
{ "method": "POST", "path": "/api/qc/results", "controller": "Qc\\ResultsController::create" },
{ "method": "PATCH", "path": "/api/qc/results/(:num)", "controller": "Qc\\ResultsController::update/$1" },
{ "method": "DELETE", "path": "/api/qc/results/(:num)", "controller": "Qc\\ResultsController::delete/$1" },
{ "method": "GET", "path": "/api/qc/result-comments", "controller": "Qc\\ResultCommentsController::index" },
{ "method": "GET", "path": "/api/qc/result-comments/(:num)", "controller": "Qc\\ResultCommentsController::show/$1" },
{ "method": "POST", "path": "/api/qc/result-comments", "controller": "Qc\\ResultCommentsController::create" },
{ "method": "PATCH", "path": "/api/qc/result-comments/(:num)", "controller": "Qc\\ResultCommentsController::update/$1" },
{ "method": "DELETE", "path": "/api/qc/result-comments/(:num)", "controller": "Qc\\ResultCommentsController::delete/$1" }
{ "method": "GET", "path": "/api/qc/test-comments", "controller": "Qc\\TestCommentsController::index" },
{ "method": "GET", "path": "/api/qc/test-comments/(:num)", "controller": "Qc\\TestCommentsController::show/$1" },
{ "method": "POST", "path": "/api/qc/test-comments", "controller": "Qc\\TestCommentsController::create" },
{ "method": "PATCH", "path": "/api/qc/test-comments/(:num)", "controller": "Qc\\TestCommentsController::update/$1" },
{ "method": "DELETE", "path": "/api/qc/test-comments/(:num)", "controller": "Qc\\TestCommentsController::delete/$1" }
],
"routes": [
{ "method": "GET", "path": "/", "controller": "PageController::dashboard" },