# Project conventions & gotchas

Laravel 12 + Vue 3/Inertia MCQ platform. Deployed to shared cPanel hosting
(`mcq.neurapy.com`). A few project-specific rules — most were learned the hard
way in production, so please keep to them.

## Database

- **Order "most recent" lists by `id`, not `created_at`.** Use `->latest('id')`
  / `->orderByDesc('id')`, never bare `->latest()` or `orderBy('created_at')`,
  on any table with large `TEXT`/`JSON`/`LONGTEXT` columns (e.g.
  `content_generation_run_items`, `quiz_attempt_items`, `questions`,
  `content_generation_runs`). `created_at` is not indexed, so ordering by it
  forces MySQL to *filesort full wide rows*, which overflows the shared host's
  small `sort_buffer_size` and throws `SQLSTATE[HY001] 1038 Out of sort memory`.
  InnoDB carries the PK in every secondary index, so `WHERE fk = ? ORDER BY id`
  reads straight from the index with no filesort. `id`-desc equals
  `created_at`-desc in practice.
- When adding a new ordered list on a wide table, order by an **indexed** column
  (usually `id`, or a column with a `(fk, col)` composite index).
- **Book pages are chapter-scoped.** Page identity is `(chapter_id, page_no)`,
  not book-wide — each chapter is uploaded as its own PDF starting at page 1, so
  page numbers repeat across chapters. Key `BookPage` upserts on
  `(book_id, chapter_id, page_no)` and scope page-overlap checks to the chapter.

## OpenAI / outbound HTTP

- The production host **resets the default IPv6 route** to `api.openai.com`
  (cURL 35 / `SSL_ERROR_ZERO_RETURN`). All OpenAI calls must go through
  `App\Support\OpenAiHttp::egressOptions()` (via `->withOptions(...)`), which
  forces IPv4 (`OPENAI_FORCE_IPV4`, default true) plus optional proxy/CA/TLS
  knobs. Any new OpenAI caller must include it.
- Wrap OpenAI HTTP calls with `->retry(...)` on `ConnectionException` for
  transient drops (see the existing services for the pattern).

## Payments (UPI)

- Amounts are **server-authoritative tiers** (`services.upi.allowed_amounts`),
  never free-text from the client. Transactions bind to `user_id`; the status
  endpoint is owner-scoped.
- `status`/`paid_at`/`user_id`/`verified_by_user_id` are **not** in `$fillable`
  — payment state is set server-side only. Admins confirm payment manually via
  the super-admin Payments ledger.

## Mass assignment

- `User` and `UpiTransaction` use explicit `$fillable` excluding sensitive
  columns (`role`, `status`, `email_verified_at`, payment state). Seeders that
  must set guarded columns use `forceFill()`, not `fill()`.

## Deployment

- cPanel Git deploy: `.cpanel.yml` → `scripts/cpanel-deploy.sh`. The repo root
  *is* the Laravel app (no `apps/web` nesting). The script normalizes file
  permissions (incl. `.htaccess` → 644) **before** migrations and runs
  migrations last, so a failed step can't leave the site serving an unreadable
  `.htaccess` (a hard 403).
- Local-only data (source `Books/`, DB dumps) lives outside the repo in
  `../mcq-local-data/` — never commit it.

## Verifying changes

- `vendor/`/`node_modules/` may be absent locally; `php -l` lints, but run
  `composer install && npm ci` before `php artisan test` / `npm run build` to
  actually execute tests and type-check the frontend.
