# Developer Guide — A&B Hub

See also `docs/ARCHITECTURE.md` (how the framework fits together) and
`docs/CODING_STANDARDS.md` (naming/style conventions) — this guide is
the practical "how do I add a feature" companion to both.

## Stack

PHP 8.3 (no framework — a small hand-rolled MVC core in `app/core/`),
MySQL 8, Apache/Nginx, vanilla JS + Bootstrap 5 on the frontend. One
external dependency: `phpmailer/phpmailer`. Everything else —
including PDF generation, real .xlsx generation, and ZIP file writing
— is implemented from scratch in this codebase. See "Why no
dependencies" below.

## Directory map

```
app/
  controllers/   One class per resource, thin — validates input, calls a service/model, returns a Response
  services/      Business logic that doesn't belong to one model (email sending, AI orchestration, PDF export...)
  models/        One class per table (or a small cluster of related tables), extends BaseModel
  middleware/    auth, csrf, permission:*, throttle:*, api_key
  core/          The framework itself: Router, Request, Response, Database, Validator, BaseModel, BaseController...
  views/         Plain PHP templates, one subfolder per module
  helpers/       Small stateless utilities (AjaxResponse, Paginator, UploadHelper)
config/          app.php, security.php, permissions.php, mail.php — never hardcode config values in code
database/
  schema.sql     Single source of truth — migrations are generated FROM this, not written separately
  migrations/    Auto-generated, numbered, one per phase's schema section
  seeders/       DatabaseSeeder orchestrates; one seeder class per concern
routes/
  web.php        Session-authenticated app routes
  api.php        JSON API routes — both internal (session-auth) and external (api_key-auth)
bin/             CLI scripts: migrate, seed, and the cron workers (email queue, automations, etc.)
deploy/          Apache vhost example, GRANTS.sql, backup script
```

## Adding a new feature end-to-end

1. **Schema**: add your table(s) to `database/schema.sql` under a new
   numbered section (see the existing sections for the comment-header
   convention), then `php database/build_migrations.php && php
   bin/migrate.php` to generate and apply the migration.
2. **Model**: extend `App\Core\BaseModel` — gives you `find()`,
   `insert()`, `update()`, `delete()`, `paginate()`, soft deletes, and
   audit stamps (`created_by`/`updated_by`) for free. Set
   `$fillable`, `$softDeletes`, `$auditStamps` per your table's needs.
3. **Controller**: extend `App\Core\BaseController` — gives you
   `$this->view()`, `$this->validateOrFail()`, `$this->currentUser()`,
   `$this->currentUserId()`.
4. **Routes**: add to `routes/web.php` with the right middleware —
   `['auth', 'permission:{module}.{action}']` for almost everything;
   see `config/permissions.php` for the module list.
5. **Views**: plain PHP + Bootstrap 5 classes already defined in
   `public/assets/css/style.css` (the `ab-*` prefixed classes). AJAX
   calls use the `abFetch()` helper (see any existing view for the
   pattern) which auto-injects the CSRF header.

## Key patterns worth knowing before you touch this code

- **Real prepared statements, not emulated.** `Database`'s PDO
  connection has `ATTR_EMULATE_PREPARES => false`. This means: you
  **cannot** reuse the same named placeholder twice in one query
  (`WHERE a = :x OR b = :x` will throw `Invalid parameter number` at
  execute time) — give each occurrence its own name
  (`:x1`, `:x2`) even when binding the identical value. This bit real
  development sessions more than once; it's the single most common
  mistake when writing a new query in this codebase.
- **Webhook signatures cover the POST body only**, never the query
  string (which is already part of the signed URL). Use
  `Request::bodyOnly()`, never `Request::all()` (which merges query +
  body), when verifying a webhook signature (see `VoiceAgentController`
  for the Twilio example).
- **Every external API client fails gracefully.** `OpenAiClient`,
  `TwilioClient`, `ElevenLabsClient`, `GoogleOAuthService`,
  `GoogleCalendarClient` all return `['success' => false, 'error' =>
  ...]` rather than throwing on a transport failure or missing
  credentials — callers must check `$result['success']`, never assume
  a network call succeeded.
- **`ReportService` is the single source of truth for numbers.** The
  Reports page, the Dashboard, and the AI Assistant's
  `generate_report` tool all call the same methods — if you need a new
  number displayed somewhere, add a method to `ReportService`, don't
  write a one-off query in a controller.
- **Caching is opt-in per call site**, via `App\Core\CacheService::
  remember($key, $ttlSeconds, $callback)` — file-based, no
  Redis/Memcached dependency. Only apply it to genuinely expensive,
  read-heavy, slightly-stale-is-fine queries (dashboard/report
  aggregates); never cache something a user just wrote and expects to
  see immediately.

## Why no third-party dependencies for exports (PDF/Excel/ZIP)

This codebase was built in a sandbox with no Composer/Packagist
access and no `zip` PHP extension. Rather than leave PDF/Excel export
unbuilt, `App\Core\ZipWriter` (a spec-correct PKZIP writer using only
the `zlib` extension's raw-deflate function), `App\Services\
XlsxExportService` (real OOXML on top of it), and `App\Services\
PdfExportService` (a plain PDF object-model writer) were built from
scratch and verified — during development — against independent
tooling (Python's `zipfile`/`openpyxl`, and `qpdf`/`pdftotext`/`pypdf`)
to confirm they produce genuinely valid, spec-correct files, not just
"the right file extension." A production deployment doesn't need to
change anything here; these have no runtime dependency beyond `zlib`,
which ships with PHP by default. If a real deployment later wants
richer spreadsheet features (formulas, multiple sheets, charts),
swapping in PhpSpreadsheet behind the same `XlsxExportService`
interface is a contained change — no controller or view would need to
know the difference.

## Testing conventions used while building this project

There is no PHPUnit suite (out of scope for this project's size), but
every service and controller in this codebase was verified by:

1. `php -l` on every changed file (syntax).
2. A standalone PHP script exercising the class directly against a
   real MySQL connection (business logic).
3. Real HTTP requests via `curl` against `php -S localhost:PORT -t
   public`, including full auth flows, for anything reachable from a
   browser.
4. For generated binary formats (PDF/XLSX/ZIP), independent
   verification with tooling this codebase does NOT itself depend on
   (Python's `zipfile`, `openpyxl`, `qpdf`, `pdftotext`) — never just
   "the bytes start with the right magic number."

If you're adding a new service, following the same four-step pattern
is the fastest way to catch the kind of subtle bug (duplicate named
placeholders, greedy regexes, encoding mismatches) that this project's
own development repeatedly found this way and nowhere else.
