tinyqc/AGENTS.md

598 lines
19 KiB
Markdown
Raw Normal View History

# AGENTS.md - AI Agent Guidelines for [PROJECT NAME]
## AI Agent Guidelines
1. **Readability**: Write code that is easy to read and understand.
2. **Maintainability**: Write code that is easy to maintain and update.
3. **Performance**: Write code that is fast and efficient.
4. **Security**: Write code that is secure and protected against attacks.
5. **Testing**: Write code that is tested and verified.
## Technology Stack
| Layer | Technology |
|-------|------------|
| Backend | CodeIgniter 4 (PHP 8.1+) |
| Frontend | Alpine.js + TailwindCSS |
| Database | MySQL/MariaDB |
## Key Files & Locations
### Backend
```
app/Controllers/ # API & page controllers
app/Models/ # Eloquent-style models
app/Database/Migrations/ # Schema definitions
app/Config/Routes.php # All routes defined here
app/Helpers/ # Helper functions
```
### Frontend
```
app/Views/ # PHP views with Alpine.js
app/Views/layout/ # Base templates
public/ # Static assets (css, js)
```
## Coding Conventions
### PHP / CodeIgniter 4
1. **Controllers** extend `BaseController` and use `ResponseTrait`
2. **Models** extend `App\Models\BaseModel` (custom base with auto camel/snake conversion)
3. **Soft deletes** are enabled on all tables (`deleted_at`)
4. **Timestamps** are automatic (`created_at`, `updated_at`)
5. **Validation** happens in controllers, not models
6. **JSON API responses** follow this structure:
```php
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
```
7. **Use camelCase for input/output**, snake_case for database:
- Controllers convert camel→snake before insert/update
- Models convert snake→camel after fetch
- Use helper functions: `camel_to_snake()`, `camel_to_snake_array()`, `snake_to_camel()`
### Database
1. **Primary keys**: `{table_singular}_id` (e.g., `item_id`, `pat_id`)
2. **Foreign keys**: Match the referenced primary key name
3. **Naming**: All lowercase, underscores
4. **Soft deletes**: All tables have `deleted_at` DATETIME column
5. **Master data tables**: Prefix with `master_` (e.g., `master_items`)
6. **Timestamps**: `created_at`, `updated_at` DATETIME columns
7. **Unique constraints**: Add on code fields (e.g., `item_code`)
### Frontend / Alpine.js
1. **x-data** on container elements
2. **Fetch API** for AJAX calls (no jQuery)
3. **DaisyUI components** for UI elements
4. **camelCase** for JavaScript, **snake_case** for PHP/DB
5. **Modals** with x-show and x-transition
6. **ES6 modules** importing Alpine from `app.js`
### File Naming
| Component | Pattern | Example |
|-----------|---------|---------|
| Controller | `PascalCase + Controller` | `ItemsController` |
| Model | `PascalCase + Model` | `ItemsModel` |
| Migration | `YYYY-MM-DD-XXXXXX_Description.php` | `2026-01-15-000001_Items.php` |
| View | `module/action.php` | `items/index.php` |
| Helper | `snake_case + _helper.php` | `stringcase_helper.php` |
| Filter | `PascalCase + Filter.php` | `JwtAuthFilter.php` |
### Directory Structure
```
app/
├── Config/
│ ├── Routes.php
│ └── Filters.php
├── Controllers/
│ ├── BaseController.php
│ ├── ItemsController.php
│ └── Master/
│ └── ItemsController.php
├── Database/
│ └── Migrations/
├── Filters/
│ └── JwtAuthFilter.php
├── Helpers/
│ ├── stringcase_helper.php
│ └── utc_helper.php
├── Models/
│ ├── BaseModel.php
│ ├── ItemsModel.php
│ └── Master/
│ └── ItemsModel.php
└── Views/
├── layout/
│ ├── main_layout.php
│ └── form_layout.php
├── items/
│ ├── items_index.php
│ └── dialog_items_form.php
└── login.php
```
## Common Tasks
### Adding a New Master Data Entity
1. Create migration in `app/Database/Migrations/`
2. Create model in `app/Models/[Module]/`
3. Create controller in `app/Controllers/[Module]/`
4. Add routes in `app/Config/Routes.php`
5. Create view in `app/Views/[module]/`
### Adding a New API Endpoint
```php
// In Routes.php
$routes->get('api/resource', 'ResourceController::index');
$routes->get('api/resource/(:num)', 'ResourceController::show/$1');
$routes->post('api/resource', 'ResourceController::create');
$routes->patch('api/resource/(:num)', 'ResourceController::update/$1');
```
### Controller Template
```php
<?php
namespace App\Controllers\Module;
use CodeIgniter\API\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Module\ItemModel;
class ItemsController extends BaseController {
use ResponseTrait;
protected $model;
protected $rules;
public function __construct() {
$this->model = new ItemsModel();
$this->rules = [
'itemCode' => 'required|min_length[1]',
'itemName' => 'required',
];
}
public function index() {
$keyword = $this->request->getGet('keyword');
try {
$rows = $this->model->getItems($keyword);
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError('Exception: ' . $e->getMessage());
}
}
public function show($id = null) {
try {
$rows = $this->model->where('item_id', $id)->findAll();
if (empty($rows)) {
return $this->respond([
'status' => 'success',
'message' => 'data not found.'
], 200);
}
return $this->respond([
'status' => 'success',
'message' => 'fetch success',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function create() {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$id = $this->model->insert($input, true);
return $this->respondCreated([
'status' => 'success',
'message' => $id
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
public function update($id = null) {
$input = $this->request->getJSON(true);
$input = camel_to_snake_array($input);
if (!$this->validate($this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$this->model->update($id, $input);
return $this->respondCreated([
'status' => 'success',
'message' => 'update success',
'data' => $id
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}
```
### Model Template
```php
<?php
namespace App\Models\Module;
use App\Models\BaseModel;
class ItemsModel extends BaseModel {
protected $table = 'module_items';
protected $primaryKey = 'item_id';
protected $allowedFields = [
'item_code',
'item_name',
'created_at',
'updated_at',
'deleted_at'
];
protected $useTimestamps = true;
protected $useSoftDeletes = true;
public function getItems($keyword = null) {
if ($keyword) {
return $this->groupStart()
->like('item_code', $keyword)
->orLike('item_name', $keyword)
->groupEnd()
->findAll();
}
return $this->findAll();
}
}
```
### Migration Template
```php
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class ModuleItems extends Migration {
public function up() {
$this->forge->addField([
'item_id' => [
'type' => 'int',
'unsigned' => true,
'auto_increment' => true
],
'item_code' => [
'type' => 'VARCHAR',
'constraint' => 50
],
'item_name' => [
'type' => 'VARCHAR',
'constraint' => 150
],
'created_at' => [
'type' => 'DATETIME',
'null' => true
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true
]
]);
$this->forge->addKey('item_id', true);
$this->forge->addUniqueKey('item_code');
$this->forge->createTable('module_items');
}
public function down() {
$this->forge->dropTable('module_items', true);
}
}
```
### Routes Template
```php
<?php
use CodeIgniter\Router\RouteCollection;
$routes = get('/login', 'PagesController::login');
$routes->post('/login', 'AuthController::login');
$routes->get('/logout', 'AuthController::logout');
$routes->group('', ['filter' => 'jwt-auth'], function ($routes) {
$routes->get('/', 'PagesController::dashboard');
$routes->get('/module', 'PagesController::module');
$routes->get('/master/items', 'PagesController::masterItems');
});
$routes->group('api', function ($routes) {
$routes->get('module/items', 'Module\ItemsController::index');
$routes->get('module/items/(:num)', 'Module\ItemsController::show/$1');
$routes->post('module/items', 'Module\ItemsController::create');
$routes->patch('module/items/(:num)', 'Module\ItemsController::update/$1');
});
```
### View Template (Index with Alpine.js)
```php
<?= $this->extend("layout/main_layout"); ?>
<?= $this->section("content"); ?>
<main class="flex-1 p-6 overflow-auto bg-slate-50/50" x-data="items()">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-slate-800 tracking-tight">Items</h1>
<p class="text-slate-500 text-sm mt-1">Manage your items</p>
</div>
<button
class="btn btn-sm gap-2 shadow-sm border-0 bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-700 hover:to-blue-600 text-white transition-all duration-200"
@click="showForm()"
>
<i class="fa-solid fa-plus"></i> New Item
</button>
</div>
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-4 mb-6">
<div class="flex items-center gap-3">
<div class="relative flex-1 max-w-md">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm"></i>
<input
type="text"
placeholder="Search by name or code..."
class="w-full pl-10 pr-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
x-model="keyword"
@keyup.enter="fetchList()"
/>
</div>
<button
class="px-4 py-2.5 text-sm font-medium bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-all duration-200 flex items-center gap-2"
@click="fetchList()"
>
<i class="fa-solid fa-magnifying-glass text-xs"></i> Search
</button>
</div>
</div>
<div class="bg-white rounded-xl border border-slate-100 shadow-sm overflow-hidden">
<template x-if="list">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wider">
<tr>
<th class="py-3 px-5 font-semibold">Code</th>
<th class="py-3 px-5 font-semibold">Name</th>
<th class="py-3 px-5 font-semibold text-right">Action</th>
</tr>
</thead>
<tbody class="text-slate-600 divide-y divide-slate-100">
<template x-for="item in list" :key="item.itemId">
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="py-3 px-5">
<span class="font-mono text-xs bg-slate-100 text-slate-600 px-2 py-1 rounded" x-text="item.itemCode"></span>
</td>
<td class="py-3 px-5 font-medium text-slate-700" x-text="item.itemName"></td>
<td class="py-3 px-5 text-right">
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors"
@click="showForm(item.itemId)"
>
<i class="fa-solid fa-pencil"></i> Edit
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
<?= $this->include('module/items/dialog_items_form'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section("script"); ?>
<script type="module">
import Alpine from '<?= base_url('/assets/js/app.js'); ?>';
document.addEventListener('alpine:init', () => {
Alpine.data("items", () => ({
loading: false,
showModal: false,
errors: {},
error: null,
part: "items",
keyword: "",
list: null,
item: null,
form: {
itemCode: "",
itemName: "",
},
async fetchList() {
this.loading = true;
this.error = null;
this.list = null;
try {
const params = new URLSearchParams({ keyword: this.keyword });
const response = await fetch(`${window.BASEURL}api/module/${this.part}?${params}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load data");
const data = await response.json();
this.list = data.data;
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
async loadData(id) {
this.loading = true;
try {
const response = await fetch(`${window.BASEURL}api/module/${this.part}/${id}`, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to load item");
const data = await response.json();
this.form = data.data[0];
} catch (err) {
this.error = err.message;
this.form = {};
} finally {
this.loading = false;
}
},
async showForm(id = null) {
this.showModal = true;
if (id) {
await this.loadData(id);
} else {
this.form = {};
}
},
closeModal() {
this.showModal = false;
this.form = {};
},
validate() {
this.errors = {};
if (!this.form.itemCode) this.errors.itemCode = "Code is required.";
if (!this.form.itemName) this.errors.itemName = "Name is required.";
return Object.keys(this.errors).length === 0;
},
async save() {
if (!this.validate()) return;
this.loading = true;
let method = '';
let url = '';
if (this.form.itemId) {
method = 'patch';
url = `${BASEURL}api/module/${this.part}/${this.form.itemId}`;
} else {
method = 'post';
url = `${BASEURL}api/module/${this.part}`;
}
try {
const res = await fetch(url, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
const data = await res.json();
if (data.status === 'success') {
alert("Data saved successfully!");
this.closeModal();
this.list = null;
} else {
alert(data.message || "Something went wrong.");
}
} catch (err) {
console.error(err);
alert("Failed to save data.");
} finally {
this.loading = false;
}
}
}));
});
Alpine.start();
</script>
<?= $this->endSection(); ?>
```
### Running Migrations
```bash
php spark migrate # Run all pending
php spark migrate:rollback # Rollback last batch
php spark migrate:refresh # Rollback all + re-run
```
## Testing
```bash
# Run all tests
./vendor/bin/phpunit
# Run specific test file
./vendor/bin/phpunit tests/unit/SomeTest.php
```
## Things to Avoid
1. **Don't use jQuery** - Use Alpine.js or vanilla JS
2. **Don't over-engineer** - This is a "no-nonsense" project
3. **Don't skip soft deletes** - Always use `deleted_at`
4. **Don't hardcode** - Use `.env` for configuration
5. **Don't mix concerns** - Controllers handle HTTP, Models handle data
6. **Don't forget camel/snake conversion** - Frontend uses camelCase, DB uses snake_case
## Questions to Ask Before Making Changes
1. Does this follow the existing patterns?
2. Is there a simpler way to do this?
3. Did I add the route?
4. Did I handle errors gracefully?
5. Does the API response match the standard format?
6. Did I convert camelCase to snake_case before DB operations?
7. Did I convert snake_case to camelCase after fetching?
## Post-Change Requirements
**After every significant code change, update `README.md`:**
| Change Type | README Section to Update |
|-------------|--------------------------|
| New Controller | Project Structure, API Endpoints |
| New Model | Project Structure |
| New Migration/Table | Database Schema |
| New View | Project Structure |
| New Route | API Endpoints |
| New Command | Development Commands |
| Config Change | Setup |
Keep the README accurate and up-to-date with the actual codebase.