Files
fitme3070.pt/CLAUDE.md
T

13 KiB

fitme3070 — architecture

Backoffice + public site for a gym (ginásio) chain, plain PHP 8.4, no framework. This file exists because the pattern below was inherited from a CodeIgniter 4 port and had never been written down as a decision (audit 2026-09-24, item 6.1) — it describes what the codebase actually does, kept deliberately, not a design proposal for a rewrite.

Request lifecycle

Every page is its own controller-and-view combined. A page in views/admin/*.php or site/*.php does, in order: bootstrap (config/db.php + config/functions.php), an auth guard, read $_POST/$_GET and call model functions, then fall through into its own HTML. There is no separate template layer and no front controller — 71+ PHP files are directly reachable by URL (docroot is the repo root; .htaccess denies partials, shared/platform-tools/*.php, and *.md, but doesn't allowlist).

This is a known, accepted gap, not an oversight — see "What's not done" below. Introducing a real front controller/router for the app's own pages would touch most of views/, .htaccess's friendly-URL block, and every hardcoded base_url('views/...') call; it's a genuine rewrite, tracked separately, not bundled into this doc.

Data layer: config/functions_*.php as models

SQL lives in config/functions_*.php, one file per domain (functions_team.php, functions_galerias.php, functions_gyms.php, functions_memberships.php, …) plus functions_db.php for cross-cutting users queries. Functions are named db_<noun>_<verb>() (db_gyms_create, db_team_update, db_gallery_list). Views never write SQL directly — if you're adding a query, it goes in the matching functions_*.php, not inline in the page. (Audit 6.3, closed 2026-09-25 — grep -rl '\$pdo->\(query\|prepare\|exec\)' views/ should always return nothing.)

config/functions.php is the loader: every page requires config/db.php (opens $pdo, starts the session, connects to platform-tools) then config/functions.php (requires every functions_*.php in order). Read that file's own header comment before adding a new one — order matters for a few (auth.php before anything that calls require_login(), etc.).

Auth

Login/session/roles run on shared/platform-tools/functions_auth.php ('auth' => 'shared' in config/db.php, migrated 2026-09-25 — audit 1.3). config/auth.php keeps the app's own guard names (require_login(), require_admin(), require_staff(), require_sysadmin(), is_admin(), etc.) as a thin layer over the shared primitives, so none of the ~30 call sites in views/ had to change. Role ladder: sysadmin(100) > admin(80) > staff(40) > client(10, portal-only). "Manter sessão iniciada" is the shared auth_remember_* module.

The login-attempt throttle (config/functions_throttle.php) stays the app's own, deliberately not the shared one — see the comment in config/db.php for why (avoids double-counting the same form with two independent throttles). A throttled login feeds platform_ip_record_offence(), so repeat offenders get fleet-wide IP-banned.

Soft delete / trash

Deletes are soft (deleted_at/deleted_by) via trash_delete() in config/functions_trash.php. Gestão → Lixeira lists everything deleted across entity types and only allows permanent purge after 30 days (enforced in the DELETE's WHERE, not just hidden in the UI). Adding a new deletable entity means adding it to trash_types() in that file.

CSRF

csrf_check() (config/functions_csrf.php) refuses any non-POST request outright — state changes must be POST forms carrying csrf_field()'s token. There is no PRG (Post-Redirect-Get) convention beyond that; several admin pages still re-render the form directly on POST rather than redirecting (audit flags this as inconsistent, not yet unified — tracked separately, low priority).

Audit log

Models call audit_row() before a write and audit_change() after (diffs before/after automatically); ad-hoc events elsewhere use audit('entity.verb', [...]). Both come from shared/platform-tools via config/functions_audit.php. Read via Sistema → Auditoria (platform.php?tool=audit).

Multi-gym

Since 2026-09-25, one install serves several physical gyms: the gyms table, one URL folder per gym (/3070/, /<postal-code>/), content rows carry gym_id, and the backoffice has an "a editar" gym switcher (admin_gym_id() / admin_gym() in config/functions_gyms.php). / and /home 301 to the default gym (is_default); the other published gyms appear as highlighted buttons (.gym-switch-btn) at the right end of the public navbar (site/includes/navbar.php). site/gym_chooser.php is no longer used. Each gym has a brand colour (gyms.brand_color, #RRGGBB, migration migration_20261013_gym_brand_color.sql; gym 1 = green #C2D500, gym 2 = red): set in Gestão → Ginásios, emitted as :root overrides by gym_brand_css() in site/includes/include.php. New components must use --fitme-accent* / --fitme-on-accent, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. Each gym can have a logo (path, same migration); the navbar button for another gym is drawn in that gym's brand colour (gym_brand_vars() inline) with the logo as a CSS mask in the text colour. An unpublished gym 404s publicly but previews for staff.

platform-tools

shared/platform-tools is a git submodule (pinned branch = main in .gitmodules), not vendored — see PLATFORM_TOOLS.md for why it went vendored→submodule→ back, and don't repeat that mistake without reading it first. config/db.php wires it via platform_connect(): shared auth (above), fleet IP-ban guard (runs on every request), the sysadmin router (platform.php / api/platform.php — Backups, Actualizações, Auditoria, IP bans, Geo, Origem das visitas, all one front controller, just for those pages, not the app's own).

Deploy

Push to master → Gitea webhook → api/platform.php?endpoint=auto_update → the VPS pulls, syncs the submodule, applies pending database/migration_*.sql files, done in seconds. No staging branch; the VPS checkout is what's live. deploy.sh (on the VPS, not in this repo) is the manual fallback. See PLATFORM_TOOLS.md.

Dependencies

composer.json/composer.lock are real (composer install works, composer audit/outdated are meaningful) — added 2026-09-25, matching the fotografia platform's setup. vendor/PHPMailer/ is the one hand-vendored exception, gitignore- allowlisted: shared/platform-tools/functions_mail.php hard-codes that exact path fleet-wide, so Composer's own vendor/phpmailer/phpmailer/ exists in parallel but isn't what actually gets require_once'd. See ASSETS.md for frontend asset versions. Both the public site and the backoffice load Bootstrap 5.3.3 (unified 2026-09-25 — the public site ran 4.3.1 before that); jQuery is one shared copy, not duplicated.

What's not done (tracked, not silently missing)

The full audit and its live status live in a separate report, not in this repo (CLAUDE_GITEA/audits/fitme3070-auditoria-2026-09-24.html on the maintainer's machine). As of 2026-09-25: no real front controller (6.2, above), no central listing registry, no DataTables on backoffice listings, no srcset (deliberate — uploads are already close to their CSS display size, see the audit item), views/admin/galerias/ still has separate create/edit (deliberately — they're not actually a duplicated pair, see the audit item). None of these are urgent; none should be "fixed" as a side effect of an unrelated change — each is its own piece of work.

Dev test accounts (DEV ONLY)

In the development database there must always exist two logins for testing: sysadmin@mail.pt (role sysadmin) and admin@mail.pt (role admin), password 123. Ensure/repair them with php scripts/dev_accounts.php (CLI only, idempotent upsert with password_hash, active flags set). The script fails closed: it refuses unless .env has APP_ENV=dev AND DB_HOST is 192.168.1.3:3181 or localhost. It must never be run, deployed or adapted for production, and these accounts must never exist in a production database.

Brand feed (2026-10-09)

  • api/brand.php is a one-line stub to shared/platform-tools/api/brand.php (public JSON: name, logos, colours; README "Brand feed"). Declared in platform_connect(['brand' => ...]) in config/db.php; colours are the :root tokens (--fitme-accent #c2d500, --fitme-dark #0b0b0d, --fitme-accent-dark #a6b600). logo_light = light-coloured logo (logo_crossfit_white.svg), logo_dark = logo_crossfit.svg.
  • site_logo_rel() (config/functions.php) resolves logos for both the feed and load_email_template(): setting file in uploads/public/logo/ (convention), then legacy assets/img/logo/, then the static logo. views/sysadmin/system_settings.php now uploads logos to uploads/public/logo/ (dir created on demand, 0755; uploads/.htaccess blocks script execution); old files in assets/img/logo/ still resolve as fallback and in the settings preview. Brand block in config/db.php already prefers the setting file via site_logo_rel(), else the static SVGs.

Dev toolbar partilhada (2026-10-09)

  • DEV ONLY, do platform-tools (functions_dev.php, README "Dev tooling"; produção segue main, que não tem o ficheiro: tudo degrada para "não é dev").
  • Ficheiros: config/dev_guard.php (shim), includes/dev_toolbar.php (incluído no fim de includes/footer.php), dev_login.php (na RAIZ: o site não tem public/; .htaccess tem a regra ^dev_login/?$ com -f), scripts/dev_accounts.php (platform_dev_accounts_run).
  • Contas admin@mail.pt / sysadmin@mail.pt (pw 123). O login usa auth_login() + fit_stamp_login_ua() como o login real; CSRF do site = campo _csrf / $_SESSION['csrf_token'] (verificação sem exit).

Ambiente de dev: moldura e login de um clique (2026-10-13)

  • includes/env_ribbon.php (moldura às riscas + etiqueta DEV, só fora de APP_ENV=prod) incluído no <head> de includes/header.php, includes/auth_header.php e site/includes/include.php.
  • A barra de login de dev (includes/dev_toolbar.php) vai também em includes/auth_footer.php e site/includes/footer.php. Botões Cliente / Staff / Admin / Sysadmin (client@mail.pt e staff@mail.pt criados por scripts/dev_accounts.php; o painel do staff é views/staff/dashboard.php, criado em 2026-10-13).
  • Auditoria da sidebar: docs/audits/2026-10-13-fitme3070-sidebar.md (em Y:/GitTea; aplicada, ver «Barra lateral» abaixo).

Barra lateral (2026-10-13)

  • Padrão da frota (.claude/skills/sysadmin-navbar-audit). CSS todo em assets/css/backoffice.css (tokens --bo-*, casca, mini, view transitions; carregado com ?v=filemtime por _asset_versioned); já não há <style> inline no header.php. Prefixo bo- nas peças novas; .sidebar, .topbar, .nav-link mantêm-se.
  • Cabeçalho fixo [☰][marca][↗ site público] (↗ abre base_url('/') em nova aba, único sítio), utilizador logo abaixo (avatar com iniciais, nome, crachá de papel, menu Perfil / Terminar sessão). A topbar fica só com o seletor «A editar», o tema e, em telemóvel, o botão que abre a gaveta. Um só manipulador (data-sidebar-toggle, em includes/footer.php): desktop alterna mini (80px, localStorage bo-sidebar-mini), telemóvel abre o off-canvas.
  • Menu em includes/header.php com os auxiliares de includes/nav_helpers.php: bo_nav_link() (ativo por sufixo do SCRIPT_NAME, aria-current, title + aria-label), bo_nav_group_start/end() (grupo recolhível, data-short = 4 letras), bo_dashboard_path(). Ordem: Painel (admin: views/admin/dashboard.php; staff: views/staff/dashboard.php; cliente: views/user/dashboard.php), Gestão (subgrupos Site, Horários, Comercial, Legal), Utilizadores, Sistema por último. Cada ligação mantém o seu is_admin()/is_sysadmin().
  • Estado sem flash: script no <head> põe html.bo-mini e data-bo-groups; estado dos grupos em localStorage bo-nav-groups; scroll da lista em sessionStorage bo-nav-scroll. Grupo novo = chave nova na lista :is(...) de grupos fechados em backoffice.css.
  • Mini: categorias em texto (data-short), nunca ícones. Ícones: só Bootstrap Icons de contorno.
  • includes/navbar.php (barra antiga) foi apagado; system_settings.php já não o inclui.
  • views/staff/dashboard.php: saudação mínima (o staff ainda não alcança nada em views/admin/).
  • Gotcha de dev: a tabela trial_leads não existe na BD de dev, por isso views/admin/trials/index.php dá 500 (migração por aplicar; anterior a esta mudança).

Bootstrap re-tematizado (2026-10-13)

  • assets/css/bs-brand.css (carregado depois do Bootstrap em includes/header.php e includes/auth_header.php) sobrescreve as cores do Bootstrap só com variáveis --bs-* e as variáveis de componente (.btn-*, .form-check-input, .page-link…), claro e escuro. A cor de marca é --fitme-accent (fallback #C2D500); todos os tons derivam dela com color-mix(); --brand-on é o texto sobre a cor. Secondary/success/danger/warning/info têm valores próprios. Verificação: platform.php?tool=branding (Montra da marca) não deve mostrar nenhum azul por omissão. O site público ainda não carrega este ficheiro.