Files
Axia4/public_html/_incl/db.php
copilot-swe-agent[bot] 0c362fd40b feat: SQLite DB with migrations replaces all JSON file storage
- Add db.php with PDO singleton, migration runner, and all helper functions
- Add migrations/001_initial_schema.sql (full schema)
- Add migrations/002_import_json.php (one-time JSON → DB importer)
- Add _incl/switch_tenant.php POST endpoint for tenant/centro switching
- Update tools.auth.php: DB-backed login, cookie auth, session reload, init_active_centro()
- Update all sysadmin pages (users, centros, aularios, invitations, reset_password) to use DB
- Update aulatek/index.php, aulario.php, supercafe.php, supercafe_edit.php to use DB
- Update aulatek/comedor.php and api/comedor.php to use DB
- Update aulatek/paneldiario.php: aulario config + comedor data from DB
- Update aulatek/proyectos.php: aulario config + sharing metadata from DB
- Update club/cal.php, index.php, edit_data.php, upload/upload.php to use DB
- Update account/index.php: rich profile, tenant list, aula list, session info, permissions
- Update pre-body.php account dropdown: shows active org + inline tenant switcher
- Update DATA_STRUCTURE.md to document DB approach and migration system

Co-authored-by: naielv <109038805+naielv@users.noreply.github.com>
2026-03-06 22:00:48 +00:00

590 lines
21 KiB
PHP

<?php
/**
* Axia4 Database Layer
*
* Provides a PDO SQLite connection and a lightweight migration runner.
* All application data previously stored as JSON files under /DATA is now
* persisted in /DATA/axia4.db.
*
* Usage: db() → returns the shared PDO instance (auto-migrates on first call).
*/
define('DB_PATH', '/DATA/axia4.db');
define('MIGRATIONS_DIR', __DIR__ . '/migrations');
// ── Connection ────────────────────────────────────────────────────────────────
function db(): PDO
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
if (!is_dir('/DATA')) {
mkdir('/DATA', 0755, true);
}
$pdo = new PDO('sqlite:' . DB_PATH);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA journal_mode = WAL');
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA synchronous = NORMAL');
db_migrate($pdo);
return $pdo;
}
// ── Migration runner ──────────────────────────────────────────────────────────
function db_migrate(PDO $pdo): void
{
$pdo->exec(
'CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime(\'now\'))
)'
);
$applied = $pdo->query('SELECT version FROM schema_migrations ORDER BY version')
->fetchAll(PDO::FETCH_COLUMN);
$files = glob(MIGRATIONS_DIR . '/*.{sql,php}', GLOB_BRACE) ?: [];
sort($files);
foreach ($files as $file) {
if (!preg_match('/^(\d+)/', basename($file), $m)) {
continue;
}
$version = (int) $m[1];
if (in_array($version, $applied, true)) {
continue;
}
if (str_ends_with($file, '.sql')) {
$pdo->exec((string) file_get_contents($file));
} elseif (str_ends_with($file, '.php')) {
// PHP migration receives the connection as $db
$db = $pdo;
require $file;
}
$pdo->prepare('INSERT INTO schema_migrations (version) VALUES (?)')->execute([$version]);
}
}
// ── Config helpers ────────────────────────────────────────────────────────────
function db_get_config(string $key, $default = null)
{
$stmt = db()->prepare('SELECT value FROM config WHERE key = ?');
$stmt->execute([$key]);
$row = $stmt->fetch();
if ($row === false) {
return $default;
}
$decoded = json_decode($row['value'], true);
return $decoded !== null ? $decoded : $row['value'];
}
function db_set_config(string $key, $value): void
{
db()->prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)')
->execute([$key, is_string($value) ? $value : json_encode($value)]);
}
function db_get_all_config(): array
{
$rows = db()->query('SELECT key, value FROM config')->fetchAll();
$result = [];
foreach ($rows as $row) {
$decoded = json_decode($row['value'], true);
$result[$row['key']] = ($decoded !== null) ? $decoded : $row['value'];
}
return $result;
}
// ── User helpers ──────────────────────────────────────────────────────────────
/** Find a user by username (always lower-cased). Returns DB row or null. */
function db_get_user(string $username): ?array
{
$stmt = db()->prepare('SELECT * FROM users WHERE username = ?');
$stmt->execute([strtolower($username)]);
$row = $stmt->fetch();
return $row !== false ? $row : null;
}
/** Return all user rows ordered by username. */
function db_get_all_users(): array
{
return db()->query('SELECT * FROM users ORDER BY username')->fetchAll();
}
/**
* Build the auth_data session array from a DB user row.
* Preserves the same format existing code expects:
* auth_data.permissions, auth_data.entreaulas.centro, .role, .aulas, .centros
*/
function db_build_auth_data(array $row): array
{
$permissions = json_decode($row['permissions'] ?? '[]', true) ?: [];
$meta = json_decode($row['meta'] ?? '{}', true) ?: [];
// Fetch all centro assignments for this user
$stmt = db()->prepare(
'SELECT centro_id, role, aulas
FROM user_centros
WHERE user_id = ?
ORDER BY centro_id'
);
$stmt->execute([$row['id']]);
$centro_rows = $stmt->fetchAll();
$ea = ['centro' => '', 'centros' => [], 'role' => '', 'aulas' => []];
if (!empty($centro_rows)) {
$first = $centro_rows[0];
$ea['centro'] = $first['centro_id']; // legacy compat
$ea['role'] = $first['role'];
$ea['aulas'] = json_decode($first['aulas'] ?? '[]', true) ?: [];
$ea['centros'] = array_column($centro_rows, 'centro_id');
$ea['centros_data'] = $centro_rows;
}
return array_merge($meta, [
'display_name' => $row['display_name'],
'email' => $row['email'],
'password_hash' => $row['password_hash'],
'permissions' => $permissions,
'entreaulas' => $ea,
'google_auth' => (bool) $row['google_auth'],
]);
}
/**
* Create or update a user.
* $data keys: username, display_name, email, password_hash, permissions[],
* google_auth, entreaulas{centro,centros[],role,aulas[]}, + any extra meta.
* Returns the user ID.
*/
function db_upsert_user(array $data): int
{
$pdo = db();
$username = strtolower((string) ($data['username'] ?? ''));
$existing = $pdo->prepare('SELECT id FROM users WHERE username = ?');
$existing->execute([$username]);
$existing_row = $existing->fetch();
$permissions = json_encode($data['permissions'] ?? []);
$meta_skip = ['username', 'display_name', 'email', 'password_hash',
'permissions', 'entreaulas', 'google_auth'];
$meta = [];
foreach ($data as $k => $v) {
if (!in_array($k, $meta_skip, true)) {
$meta[$k] = $v;
}
}
if ($existing_row) {
$user_id = (int) $existing_row['id'];
$upd = $pdo->prepare(
"UPDATE users SET
display_name = ?,
email = ?,
permissions = ?,
google_auth = ?,
meta = ?,
updated_at = datetime('now')
WHERE id = ?"
);
$upd->execute([
$data['display_name'] ?? '',
$data['email'] ?? '',
$permissions,
(int) ($data['google_auth'] ?? 0),
json_encode($meta),
$user_id,
]);
if (!empty($data['password_hash'])) {
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
->execute([$data['password_hash'], $user_id]);
}
} else {
$pdo->prepare(
'INSERT INTO users (username, display_name, email, password_hash, permissions, google_auth, meta)
VALUES (?, ?, ?, ?, ?, ?, ?)'
)->execute([
$username,
$data['display_name'] ?? '',
$data['email'] ?? '',
$data['password_hash'] ?? '',
$permissions,
(int) ($data['google_auth'] ?? 0),
json_encode($meta),
]);
$user_id = (int) $pdo->lastInsertId();
}
// Update centro assignments when entreaulas data is provided
if (array_key_exists('entreaulas', $data)) {
$ea = $data['entreaulas'] ?? [];
$pdo->prepare('DELETE FROM user_centros WHERE user_id = ?')->execute([$user_id]);
// Support both legacy single centro and new multi-centro
$centros = [];
if (!empty($ea['centros']) && is_array($ea['centros'])) {
$centros = $ea['centros'];
} elseif (!empty($ea['centro'])) {
$centros = [$ea['centro']];
}
$role = $ea['role'] ?? '';
$aulas = json_encode($ea['aulas'] ?? []);
$ins_centro = $pdo->prepare('INSERT OR IGNORE INTO centros (centro_id) VALUES (?)');
$ins_uc = $pdo->prepare(
'INSERT OR REPLACE INTO user_centros (user_id, centro_id, role, aulas) VALUES (?, ?, ?, ?)'
);
foreach ($centros as $cid) {
if ($cid === '') {
continue;
}
$ins_centro->execute([$cid]);
$ins_uc->execute([$user_id, $cid, $role, $aulas]);
}
}
return $user_id;
}
/** Delete a user and their centro assignments. */
function db_delete_user(string $username): void
{
db()->prepare('DELETE FROM users WHERE username = ?')->execute([strtolower($username)]);
}
// ── Centro helpers ────────────────────────────────────────────────────────────
function db_get_centros(): array
{
return db()->query('SELECT centro_id, name FROM centros ORDER BY centro_id')->fetchAll();
}
function db_get_centro_ids(): array
{
return db()->query('SELECT centro_id FROM centros ORDER BY centro_id')->fetchAll(PDO::FETCH_COLUMN);
}
// ── Aulario helpers ───────────────────────────────────────────────────────────
/** Get a single aulario config. Returns merged array (name, icon, + extra fields) or null. */
function db_get_aulario(string $centro_id, string $aulario_id): ?array
{
$stmt = db()->prepare(
'SELECT name, icon, extra FROM aularios WHERE centro_id = ? AND aulario_id = ?'
);
$stmt->execute([$centro_id, $aulario_id]);
$row = $stmt->fetch();
if ($row === false) {
return null;
}
$extra = json_decode($row['extra'] ?? '{}', true) ?: [];
return array_merge($extra, ['name' => $row['name'], 'icon' => $row['icon']]);
}
/** Get all aularios for a centro as aulario_id → config array. */
function db_get_aularios(string $centro_id): array
{
$stmt = db()->prepare(
'SELECT aulario_id, name, icon, extra FROM aularios WHERE centro_id = ? ORDER BY aulario_id'
);
$stmt->execute([$centro_id]);
$result = [];
foreach ($stmt->fetchAll() as $row) {
$extra = json_decode($row['extra'] ?? '{}', true) ?: [];
$result[$row['aulario_id']] = array_merge($extra, [
'name' => $row['name'],
'icon' => $row['icon'],
]);
}
return $result;
}
// ── SuperCafe helpers ─────────────────────────────────────────────────────────
function db_get_supercafe_menu(string $centro_id): array
{
$stmt = db()->prepare('SELECT data FROM supercafe_menu WHERE centro_id = ?');
$stmt->execute([$centro_id]);
$row = $stmt->fetch();
if ($row === false) {
return [];
}
return json_decode($row['data'], true) ?: [];
}
function db_set_supercafe_menu(string $centro_id, array $menu): void
{
db()->prepare('INSERT OR REPLACE INTO supercafe_menu (centro_id, data, updated_at) VALUES (?, ?, datetime(\'now\'))')
->execute([$centro_id, json_encode($menu, JSON_UNESCAPED_UNICODE)]);
}
/** Return all SC orders for a centro as an array of rows. */
function db_get_supercafe_orders(string $centro_id): array
{
$stmt = db()->prepare(
'SELECT * FROM supercafe_orders WHERE centro_id = ? ORDER BY created_at DESC'
);
$stmt->execute([$centro_id]);
return $stmt->fetchAll();
}
/** Return a single SC order by ref, or null. */
function db_get_supercafe_order(string $centro_id, string $order_ref): ?array
{
$stmt = db()->prepare(
'SELECT * FROM supercafe_orders WHERE centro_id = ? AND order_ref = ?'
);
$stmt->execute([$centro_id, $order_ref]);
$row = $stmt->fetch();
return $row !== false ? $row : null;
}
/** Create or update an SC order. */
function db_upsert_supercafe_order(
string $centro_id,
string $order_ref,
string $fecha,
string $persona,
string $comanda,
string $notas,
string $estado
): void {
db()->prepare(
'INSERT INTO supercafe_orders (centro_id, order_ref, fecha, persona, comanda, notas, estado)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(centro_id, order_ref) DO UPDATE SET
fecha = excluded.fecha,
persona = excluded.persona,
comanda = excluded.comanda,
notas = excluded.notas,
estado = excluded.estado'
)->execute([$centro_id, $order_ref, $fecha, $persona, $comanda, $notas, $estado]);
}
/** Generate the next order_ref for a centro (sc001, sc002, …). */
function db_next_supercafe_ref(string $centro_id): string
{
$stmt = db()->prepare(
"SELECT order_ref FROM supercafe_orders WHERE centro_id = ? ORDER BY id DESC LIMIT 1"
);
$stmt->execute([$centro_id]);
$last = $stmt->fetchColumn();
$n = 0;
if ($last && preg_match('/^sc(\d+)$/', $last, $m)) {
$n = (int) $m[1];
}
return 'sc' . str_pad($n + 1, 3, '0', STR_PAD_LEFT);
}
/** Count 'Deuda' orders for a persona in a centro. */
function db_supercafe_count_debts(string $centro_id, string $persona_key): int
{
$stmt = db()->prepare(
"SELECT COUNT(*) FROM supercafe_orders WHERE centro_id = ? AND persona = ? AND estado = 'Deuda'"
);
$stmt->execute([$centro_id, $persona_key]);
return (int) $stmt->fetchColumn();
}
// ── Comedor helpers ───────────────────────────────────────────────────────────
function db_get_comedor_menu_types(string $centro_id, string $aulario_id): array
{
$stmt = db()->prepare(
'SELECT data FROM comedor_menu_types WHERE centro_id = ? AND aulario_id = ?'
);
$stmt->execute([$centro_id, $aulario_id]);
$row = $stmt->fetch();
if ($row === false) {
return [];
}
return json_decode($row['data'], true) ?: [];
}
function db_set_comedor_menu_types(string $centro_id, string $aulario_id, array $types): void
{
db()->prepare(
'INSERT OR REPLACE INTO comedor_menu_types (centro_id, aulario_id, data) VALUES (?, ?, ?)'
)->execute([$centro_id, $aulario_id, json_encode($types, JSON_UNESCAPED_UNICODE)]);
}
function db_get_comedor_entry(string $centro_id, string $aulario_id, string $ym, string $day): array
{
$stmt = db()->prepare(
'SELECT data FROM comedor_entries WHERE centro_id = ? AND aulario_id = ? AND year_month = ? AND day = ?'
);
$stmt->execute([$centro_id, $aulario_id, $ym, $day]);
$row = $stmt->fetch();
if ($row === false) {
return [];
}
return json_decode($row['data'], true) ?: [];
}
function db_set_comedor_entry(string $centro_id, string $aulario_id, string $ym, string $day, array $data): void
{
db()->prepare(
'INSERT OR REPLACE INTO comedor_entries (centro_id, aulario_id, year_month, day, data) VALUES (?, ?, ?, ?, ?)'
)->execute([$centro_id, $aulario_id, $ym, $day, json_encode($data, JSON_UNESCAPED_UNICODE)]);
}
// ── Diario helpers ────────────────────────────────────────────────────────────
function db_get_diario_entry(string $centro_id, string $aulario_id, string $entry_date): array
{
$stmt = db()->prepare(
'SELECT data FROM diario_entries WHERE centro_id = ? AND aulario_id = ? AND entry_date = ?'
);
$stmt->execute([$centro_id, $aulario_id, $entry_date]);
$row = $stmt->fetch();
if ($row === false) {
return [];
}
return json_decode($row['data'], true) ?: [];
}
function db_set_diario_entry(string $centro_id, string $aulario_id, string $entry_date, array $data): void
{
db()->prepare(
'INSERT OR REPLACE INTO diario_entries (centro_id, aulario_id, entry_date, data) VALUES (?, ?, ?, ?)'
)->execute([$centro_id, $aulario_id, $entry_date, json_encode($data, JSON_UNESCAPED_UNICODE)]);
}
// ── Panel alumno helpers ──────────────────────────────────────────────────────
function db_get_panel_alumno(string $centro_id, string $aulario_id, string $alumno): array
{
$stmt = db()->prepare(
'SELECT data FROM panel_alumno WHERE centro_id = ? AND aulario_id = ? AND alumno = ?'
);
$stmt->execute([$centro_id, $aulario_id, $alumno]);
$row = $stmt->fetch();
if ($row === false) {
return [];
}
return json_decode($row['data'], true) ?: [];
}
function db_set_panel_alumno(string $centro_id, string $aulario_id, string $alumno, array $data): void
{
db()->prepare(
'INSERT OR REPLACE INTO panel_alumno (centro_id, aulario_id, alumno, data) VALUES (?, ?, ?, ?)'
)->execute([$centro_id, $aulario_id, $alumno, json_encode($data, JSON_UNESCAPED_UNICODE)]);
}
// ── Invitation helpers ────────────────────────────────────────────────────────
function db_get_all_invitations(): array
{
return db()->query('SELECT * FROM invitations ORDER BY code')->fetchAll();
}
function db_get_invitation(string $code): ?array
{
$stmt = db()->prepare('SELECT * FROM invitations WHERE code = ?');
$stmt->execute([strtoupper($code)]);
$row = $stmt->fetch();
return $row !== false ? $row : null;
}
function db_upsert_invitation(string $code, bool $active, bool $single_use): void
{
db()->prepare(
'INSERT OR REPLACE INTO invitations (code, active, single_use) VALUES (?, ?, ?)'
)->execute([strtoupper($code), (int) $active, (int) $single_use]);
}
function db_deactivate_invitation(string $code): void
{
db()->prepare('UPDATE invitations SET active = 0 WHERE code = ?')->execute([strtoupper($code)]);
}
function db_delete_invitation(string $code): void
{
db()->prepare('DELETE FROM invitations WHERE code = ?')->execute([strtoupper($code)]);
}
// ── Club helpers ──────────────────────────────────────────────────────────────
function db_get_club_config(): array
{
$stmt = db()->query('SELECT data FROM club_config WHERE id = 1');
$row = $stmt->fetch();
if ($row === false) {
return [];
}
return json_decode($row['data'], true) ?: [];
}
function db_set_club_config(array $config): void
{
db()->prepare('INSERT OR REPLACE INTO club_config (id, data) VALUES (1, ?)')
->execute([json_encode($config, JSON_UNESCAPED_UNICODE)]);
}
function db_get_all_club_events(): array
{
return db()->query('SELECT date_ref, data FROM club_events ORDER BY date_ref DESC')->fetchAll();
}
function db_get_club_event(string $date_ref): array
{
$stmt = db()->prepare('SELECT data FROM club_events WHERE date_ref = ?');
$stmt->execute([$date_ref]);
$row = $stmt->fetch();
if ($row === false) {
return [];
}
return json_decode($row['data'], true) ?: [];
}
function db_set_club_event(string $date_ref, array $data): void
{
db()->prepare('INSERT OR REPLACE INTO club_events (date_ref, data) VALUES (?, ?)')
->execute([$date_ref, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]);
}
// ── Multi-tenant helpers ──────────────────────────────────────────────────────
/** Return all centro IDs the authenticated user belongs to. */
function get_user_centros(?array $auth_data = null): array
{
$data = $auth_data ?? $_SESSION['auth_data'] ?? [];
$ea = $data['entreaulas'] ?? [];
if (!empty($ea['centros']) && is_array($ea['centros'])) {
return array_values($ea['centros']);
}
if (!empty($ea['centro'])) {
return [$ea['centro']];
}
return [];
}
/** Ensure $_SESSION['active_centro'] is set to a valid centro. */
function init_active_centro(?array $auth_data = null): void
{
$centros = get_user_centros($auth_data);
if (empty($centros)) {
$_SESSION['active_centro'] = null;
return;
}
if (!empty($_SESSION['active_centro']) && in_array($_SESSION['active_centro'], $centros, true)) {
return;
}
$_SESSION['active_centro'] = $centros[0];
}