commit 4ee32aec6de05154a03ec31c75650ac709fdf1f4 Author: Soporte ZH Consultores Date: Mon May 11 19:10:36 2026 +0000 Initial commit — Donna portal v1 Setup completo del portal interno donna.aal-law.com: * Landing público con OAuth login + grid de apps autenticado * Flujo OAuth2 contra Mailcow (mail.aal-law.com) * Sesiones SSO compartidas en el dominio (cookie donna_session, 8h) * Whitelist de dominios aal-law.com / aalnlaw.com * App "Control de Escrituras" migrada desde ZH-Consultores/control-escrituras (mismo schema, mismo frontend, ahora con shared Auth/Db/Config libs) * Migraciones SQL para users, escrituras_tramites, escrituras_historial * Caddy on :80 con rewrite extless → .php * Backup diario MariaDB (systemd timer + script) con retención 14d * Documentación de arquitectura en README + infra files en docs/infra diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b9ee90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Secretos — NUNCA hacer commit +*.env +*.env.* +!*.env.example +config.local.php +*.local.php + +# Backups +*.bak +*.orig +*~ +.DS_Store +Thumbs.db + +# Logs (los logs viven en /var/log/donna/, no en el repo) +*.log + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# PHP +vendor/ +composer.lock + +# Node (por si en el futuro hay tooling JS) +node_modules/ + +# Datos efímeros +/var/ +/tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..05644ee --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# Donna — Portal interno AALN Law + +Portal interno de la firma **Arias, Abrego, López & Noriega** (aalnlaw.com) que aloja herramientas internas para el equipo. Primer app embarcada: **Control de Escrituras Públicas**. + +> Producción: + +--- + +## Arquitectura + +``` +Navegador + │ HTTPS + ▼ +Cloudflare → Caddy infra (SSL termination) + │ HTTP :80 + ▼ + ┌──────────────────────┐ + │ Caddy (este server) │ /opt/donna/public + │ rewrite *.php │ + └──────┬───────────────┘ + │ unix socket + ▼ + ┌──────────────────────┐ + │ PHP-FPM 8.4 │ + └──────┬───────────────┘ + ▼ + ┌──────────────────────┐ + │ MariaDB 11.8 │ DB: donna + └──────────────────────┘ +``` + +**Stack:** +- PHP 8.4-fpm (`/run/php/php8.4-fpm.sock`) +- Caddy 2.6 en `:80` +- MariaDB 11.8 (`127.0.0.1:3306`) +- Autenticación OAuth2 contra Mailcow (`mail.aal-law.com`) +- Backups diarios automáticos vía systemd timer + +--- + +## Estructura del repo + +``` +donna/ +├── public/ ← DocumentRoot +│ ├── index.php ← Landing + grid de apps +│ ├── auth/ +│ │ ├── login.php ← Inicia OAuth flow +│ │ ├── callback.php ← Recibe code de Mailcow +│ │ └── logout.php +│ └── escrituras/ +│ ├── index.php ← Auth-guard + sirve view +│ └── api.php ← CRUD trámites + historial +├── src/ ← Librerías PHP compartidas (fuera de DocumentRoot) +│ ├── Config.php ← Lee /etc/donna/donna.env +│ ├── Db.php ← PDO singleton +│ ├── Auth.php ← Sesión + OAuth guard + whitelist +│ ├── Oauth.php ← Cliente HTTP a Mailcow +│ └── Apps.php ← Catálogo de apps del menú +├── views/ ← Templates HTML servidos por PHP tras auth +│ └── escrituras.html +├── sql/ ← Migraciones (aplicar manualmente) +│ ├── 001_core.sql ← users +│ └── 002_escrituras.sql ← escrituras_tramites + escrituras_historial +├── bin/ +│ └── donna-backup.sh ← mysqldump diario, retención 14 días +├── docs/ ← Diseños y archivos de infra de referencia +├── donna.env.example ← Plantilla del archivo de secrets +└── README.md +``` + +**Fuera del repo (en el servidor):** +- `/etc/donna/donna.env` — secrets reales (`640 root:www-data`) +- `/etc/caddy/Caddyfile` — config del Caddy local +- `/etc/systemd/system/donna-backup.{service,timer}` — backups +- `/var/log/donna/` — access logs +- `/var/backups/donna/` — dumps SQL + +--- + +## Setup en un servidor nuevo + +```bash +# 1. Paquetes del SO (Debian 13) +apt install -y caddy php8.4-fpm php8.4-mysql php8.4-curl \ + php8.4-mbstring php8.4-intl php8.4-zip mariadb-server + +# 2. Base de datos +mariadb -e "CREATE DATABASE donna CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +mariadb -e "CREATE USER 'donna_app'@'localhost' IDENTIFIED BY 'XXX'; + GRANT ALL ON donna.* TO 'donna_app'@'localhost';" + +# 3. Código +git clone https://git.zhconsultores.com/ZH-Consultores/donna.git /opt/donna +chown -R www-data:www-data /opt/donna + +# 4. Migraciones +mariadb donna < /opt/donna/sql/001_core.sql +mariadb donna < /opt/donna/sql/002_escrituras.sql + +# 5. Secrets +mkdir -p /etc/donna +cp /opt/donna/donna.env.example /etc/donna/donna.env +# Editar /etc/donna/donna.env con valores reales (OAuth, DB_PASS, APP_SECRET) +chown root:www-data /etc/donna/donna.env +chmod 640 /etc/donna/donna.env + +# 6. Caddy y backups: ver docs/infra/ para Caddyfile y systemd units +systemctl reload caddy +systemctl enable --now donna-backup.timer +``` + +--- + +## OAuth2 con Mailcow + +1. En `https://mail.aal-law.com/admin` → **Access → OAuth2 Apps** +2. Crear app con `Redirect URI = https://donna.aal-law.com/auth/callback` +3. Copiar `Client ID` y `Client Secret` a `/etc/donna/donna.env` + +El flujo: +``` +/ ──login──▶ /auth/login.php ──redirect──▶ mail.aal-law.com/oauth/authorize + │ + ▼ ?code=...&state=... +/auth/callback.php ◀── redirect ◀── usuario autorizado + │ + ├── valida state (anti-CSRF) + ├── intercambia code → token + ├── lee profile (email, nombre) + ├── valida dominio @aal-law.com o @aalnlaw.com + ├── inserta/actualiza fila en `users` + └── redirige a / +``` + +Sesión: cookie `donna_session`, `path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, lifetime 8h. SSO automático entre todas las apps del portal. + +--- + +## Apps + +Las apps se registran en `src/Apps.php::catalog()`. El landing las renderiza automáticamente como cards. + +| Slug | Estado | Ruta | Tablas DB | +|---|---|---|---| +| `escrituras` | Live | `/escrituras/` | `escrituras_tramites`, `escrituras_historial` | +| `casos` | Coming soon | — | — | +| `agenda` | Coming soon | — | — | +| `clientes` | Coming soon | — | — | + +**Para agregar una app nueva:** +1. Crear `/opt/donna/public//index.php` con `Auth::requireAuth()` arriba. +2. Crear `/opt/donna/public//api.php` si necesita backend. +3. Prefijar todas las tablas DB con `_`. +4. Crear migración en `sql/00N_.sql`. +5. Registrar en `src/Apps.php::catalog()`. +6. Commit + push. + +--- + +## Identidad visual + +| Token | Valor | +|---|---| +| `--navy` | `#1B3A6B` | +| `--navy-d` | `#112446` | +| `--navy-l` | `#2D5090` | +| `--gold` | `#C9A84C` | +| `--gold-l` | `#E8C96A` | +| `--gold-d` | `#9E7C2E` | +| `--off` | `#F7F6F3` | +| Fuente headlines | DM Serif Display | +| Fuente UI | DM Sans | + +Gradient hero del landing: `#0E1F3F → #1B3A6B → #2D5090`. + +--- + +## Backups + +`donna-backup.timer` ejecuta diariamente a las 03:00 (con jitter de ±5min) y vuelca `donna` comprimido a `/var/backups/donna/donna-YYYY-MM-DD_HHMMSS.sql.gz`. Retención 14 días. + +Restaurar: +```bash +gunzip -c /var/backups/donna/donna-2026-MM-DD_HHMMSS.sql.gz | mariadb donna +``` + +--- + +## Operación + +| Acción | Comando | +|---|---| +| Reload Caddy | `systemctl reload caddy` | +| Reload PHP-FPM | `systemctl reload php8.4-fpm` | +| Ver logs Caddy | `tail -f /var/log/donna/access.log` | +| Ver errores PHP | `journalctl -u php8.4-fpm -f` | +| Backup manual | `/opt/donna/bin/donna-backup.sh` | +| Próximo backup | `systemctl list-timers donna-backup.timer` | + +--- + +## Licencia + +Software interno — sin licencia pública. Propiedad de **Arias, Abrego, López & Noriega**. diff --git a/bin/donna-backup.sh b/bin/donna-backup.sh new file mode 100755 index 0000000..b11881f --- /dev/null +++ b/bin/donna-backup.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Donna — daily MariaDB backup with 14-day retention +set -euo pipefail + +BACKUP_DIR="/var/backups/donna" +ENV_FILE="/etc/donna/donna.env" +RETENTION_DAYS=14 + +# shellcheck disable=SC1090 +. <(grep -E '^(DB_NAME|DB_USER|DB_PASS|DB_HOST|DB_PORT)=' "$ENV_FILE") + +mkdir -p "$BACKUP_DIR" +chmod 750 "$BACKUP_DIR" + +STAMP=$(date +%F_%H%M%S) +OUT="$BACKUP_DIR/donna-$STAMP.sql.gz" + +MYSQL_PWD="$DB_PASS" mariadb-dump \ + --host="${DB_HOST:-127.0.0.1}" \ + --port="${DB_PORT:-3306}" \ + --user="$DB_USER" \ + --single-transaction \ + --quick \ + --routines \ + --triggers \ + --events \ + --default-character-set=utf8mb4 \ + "$DB_NAME" \ + | gzip -9 > "$OUT" + +chmod 640 "$OUT" + +find "$BACKUP_DIR" -type f -name 'donna-*.sql.gz' -mtime "+$RETENTION_DAYS" -delete + +echo "[donna-backup] $(date -Is) wrote $OUT ($(du -h "$OUT" | cut -f1))" diff --git a/docs/infra/Caddyfile b/docs/infra/Caddyfile new file mode 100644 index 0000000..1fd5c0b --- /dev/null +++ b/docs/infra/Caddyfile @@ -0,0 +1,38 @@ +# Donna Portal — HTTP only on :80 +# El Caddy upstream hace terminación SSL y nos pasa X-Forwarded-Proto: https. + +:80 { + root * /opt/donna/public + + encode gzip zstd + + # Bloquear acceso directo a vistas privadas, código fuente, secretos, SQL, etc. + @hidden { + path /src/* /sql/* /bin/* /views/* /.env* /.git* + } + respond @hidden 404 + + # Rewrite /foo → /foo.php cuando el .php existe (para URLs sin extensión: /auth/callback) + @extlessphp { + not path *.php + not path */ + file {path}.php + } + rewrite @extlessphp {path}.php + + php_fastcgi unix//run/php/php8.4-fpm.sock { + env HTTPS on + } + + file_server { + index index.php + } + + log { + output file /var/log/donna/access.log { + roll_size 50mb + roll_keep 5 + } + format json + } +} diff --git a/docs/infra/donna-backup.service b/docs/infra/donna-backup.service new file mode 100644 index 0000000..73a3d5b --- /dev/null +++ b/docs/infra/donna-backup.service @@ -0,0 +1,11 @@ +[Unit] +Description=Donna MariaDB daily backup +After=mariadb.service +Wants=mariadb.service + +[Service] +Type=oneshot +User=root +ExecStart=/opt/donna/bin/donna-backup.sh +StandardOutput=journal +StandardError=journal diff --git a/docs/infra/donna-backup.timer b/docs/infra/donna-backup.timer new file mode 100644 index 0000000..42d2f7c --- /dev/null +++ b/docs/infra/donna-backup.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run Donna backup daily at 03:00 + +[Timer] +OnCalendar=*-*-* 03:00:00 +Persistent=true +RandomizedDelaySec=300 +Unit=donna-backup.service + +[Install] +WantedBy=timers.target diff --git a/donna.env.example b/donna.env.example new file mode 100644 index 0000000..0d718f4 --- /dev/null +++ b/donna.env.example @@ -0,0 +1,31 @@ +# Plantilla de /etc/donna/donna.env +# Copia este archivo a /etc/donna/donna.env y rellena con valores reales: +# sudo cp donna.env.example /etc/donna/donna.env +# sudo chown root:www-data /etc/donna/donna.env +# sudo chmod 640 /etc/donna/donna.env +# Nunca commitear el .env real. + +# --- OAuth2 Mailcow --- +OAUTH_CLIENT_ID=pega_aqui +OAUTH_CLIENT_SECRET=pega_aqui +OAUTH_REDIRECT_URI=https://donna.aal-law.com/auth/callback +OAUTH_AUTH_URL=https://mail.aal-law.com/oauth/authorize +OAUTH_TOKEN_URL=https://mail.aal-law.com/oauth/token +OAUTH_PROFILE_URL=https://mail.aal-law.com/oauth/profile +OAUTH_SCOPE=profile + +# --- App --- +APP_SECRET=genera_con_openssl_rand_hex_32 +APP_BASE_URL=https://donna.aal-law.com +SESSION_LIFETIME=28800 + +# --- Base de datos (MariaDB local) --- +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_NAME=donna +DB_USER=donna_app +DB_PASS=password_local_seguro + +# --- Autorización --- +# Dominios de correo permitidos para entrar (CSV) +ALLOWED_EMAIL_DOMAINS=aal-law.com,aalnlaw.com diff --git a/public/auth/callback.php b/public/auth/callback.php new file mode 100644 index 0000000..70aa7b3 --- /dev/null +++ b/public/auth/callback.php @@ -0,0 +1,69 @@ + +Acceso denegado — Donna + + +
+ +
AALN · Portal interno
+

Acceso denegado

+

{$safe}

+Volver al inicio +
+HTML; + exit; +} + +$state = $_GET['state'] ?? ''; +if (!is_string($state) || !Auth::checkState($state)) { + deny('Token de seguridad inválido o expirado. Intenta iniciar sesión de nuevo.'); +} + +$code = $_GET['code'] ?? ''; +if (!is_string($code) || $code === '') { + $err = $_GET['error_description'] ?? $_GET['error'] ?? 'Autorización cancelada.'; + deny((string)$err); +} + +$tokenResp = Oauth::exchangeCodeForToken($code); +if (empty($tokenResp['access_token'])) { + deny('No se pudo obtener el token de acceso. Reintenta más tarde.'); +} + +$profile = Oauth::fetchProfile($tokenResp['access_token']); +$email = $profile['email'] ?? $profile['username'] ?? ''; +$name = $profile['full_name'] ?? $profile['name'] ?? $email; + +if ($email === '') { + deny('No se pudo leer el perfil del usuario desde el servidor de correo.'); +} + +if (!Auth::emailDomainAllowed($email)) { + deny('Esta cuenta no está autorizada para Donna. Solo dominios corporativos de AALN.', 403); +} + +Auth::completeLogin($email, $name); + +$returnTo = Auth::popReturnTo(); +if (!str_starts_with($returnTo, '/')) $returnTo = '/'; + +header('Location: ' . $returnTo); +exit; diff --git a/public/auth/login.php b/public/auth/login.php new file mode 100644 index 0000000..74ba984 --- /dev/null +++ b/public/auth/login.php @@ -0,0 +1,11 @@ + true, 'data' => $data, 'user' => Auth::user()], JSON_UNESCAPED_UNICODE); + exit; +} + +function err(string $msg, int $code = 400): void { + http_response_code($code); + echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE); + exit; +} + +function body(): array { + return json_decode((string) file_get_contents('php://input'), true) ?? []; +} + +$method = $_SERVER['REQUEST_METHOD']; +$action = $_GET['action'] ?? ''; +$user = Auth::user(); +$pdo = Db::pdo(); + +try { + if ($method === 'GET' && $action === 'list') { + $rows = $pdo->query('SELECT * FROM escrituras_tramites ORDER BY id DESC')->fetchAll(); + ok($rows); + } + + if ($method === 'POST' && $action === 'create') { + $b = body(); + $stmt = $pdo->prepare( + 'INSERT INTO escrituras_tramites + (tramite, cliente, abogado, notaria, inscribe, altera_turno, instrucciones, + fecha_solicitud, estatus, observaciones, creado_por) + VALUES + (:tramite, :cliente, :abogado, :notaria, :inscribe, :altera, :instrucciones, + NOW(), :estatus, :obs, :user)' + ); + $stmt->execute([ + ':tramite' => $b['tramite'] ?? '', + ':cliente' => $b['cliente'] ?? '', + ':abogado' => $b['abogado'] ?? '', + ':notaria' => $b['notaria'] ?? '', + ':inscribe' => $b['inscribe'] ?? 'Sí', + ':altera' => $b['altera_turno'] ?? 'No', + ':instrucciones' => $b['instrucciones'] ?? '', + ':estatus' => $b['estatus'] ?? 'Solicitado', + ':obs' => $b['observaciones'] ?? '', + ':user' => $user['email'], + ]); + $id = (int) $pdo->lastInsertId(); + + $pdo->prepare( + "INSERT INTO escrituras_historial + (tramite_id, modificado_por, campo, valor_anterior, valor_nuevo) + VALUES (?, ?, 'Trámite', '', 'Creado')" + )->execute([$id, $user['email']]); + + $row = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id'); + $row->execute([':id' => $id]); + ok($row->fetch()); + } + + if ($method === 'PUT' && $action === 'update') { + $b = body(); + $id = (int) ($b['id'] ?? 0); + if (!$id) err('ID requerido'); + + $beforeStmt = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id'); + $beforeStmt->execute([':id' => $id]); + $before = $beforeStmt->fetch(); + if (!$before) err('Trámite no encontrado', 404); + + $fields = [ + 'tramite' => $b['tramite'] ?? '', + 'cliente' => $b['cliente'] ?? '', + 'abogado' => $b['abogado'] ?? '', + 'notaria' => $b['notaria'] ?? '', + 'inscribe' => $b['inscribe'] ?? 'Sí', + 'altera_turno' => $b['altera_turno'] ?? 'No', + 'instrucciones' => $b['instrucciones'] ?? '', + 'estatus' => $b['estatus'] ?? 'Solicitado', + 'observaciones' => $b['observaciones'] ?? '', + ]; + + $stmt = $pdo->prepare( + 'UPDATE escrituras_tramites SET + tramite=:tramite, cliente=:cliente, abogado=:abogado, notaria=:notaria, + inscribe=:inscribe, altera_turno=:altera_turno, instrucciones=:instrucciones, + estatus=:estatus, observaciones=:observaciones, + actualizado_por=:actualizado_por + WHERE id=:id' + ); + $stmt->execute(array_merge( + array_combine( + array_map(fn($k) => ':' . $k, array_keys($fields)), + array_values($fields) + ), + [':actualizado_por' => $user['email'], ':id' => $id] + )); + + $labels = [ + 'tramite' => 'Nombre del Trámite', + 'cliente' => 'Cliente', + 'abogado' => 'Abogado Solicitante', + 'notaria' => 'Notaría', + 'inscribe' => 'Se Inscribe', + 'altera_turno' => 'Alt. de Turno', + 'instrucciones' => 'Instrucciones Especiales', + 'estatus' => 'Estatus', + 'observaciones' => 'Observaciones', + ]; + + $hist = $pdo->prepare( + 'INSERT INTO escrituras_historial + (tramite_id, modificado_por, campo, valor_anterior, valor_nuevo) + VALUES (:tid, :por, :campo, :ant, :nvo)' + ); + foreach ($fields as $campo => $nuevo) { + $anterior = $before[$campo] ?? ''; + if ((string) $anterior !== (string) $nuevo) { + $hist->execute([ + ':tid' => $id, + ':por' => $user['email'], + ':campo' => $labels[$campo] ?? $campo, + ':ant' => $anterior, + ':nvo' => $nuevo, + ]); + } + } + + $row = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id'); + $row->execute([':id' => $id]); + ok($row->fetch()); + } + + if ($method === 'DELETE' && $action === 'delete') { + $id = (int) ($_GET['id'] ?? 0); + if (!$id) err('ID requerido'); + $pdo->prepare('DELETE FROM escrituras_tramites WHERE id = :id')->execute([':id' => $id]); + ok(['deleted' => $id]); + } + + if ($method === 'GET' && $action === 'historial') { + $tid = (int) ($_GET['id'] ?? 0); + if (!$tid) err('ID requerido'); + $stmt = $pdo->prepare( + 'SELECT * FROM escrituras_historial + WHERE tramite_id = :tid + ORDER BY modificado_en DESC + LIMIT 200' + ); + $stmt->execute([':tid' => $tid]); + ok($stmt->fetchAll()); + } + + err('Acción no reconocida', 404); + +} catch (PDOException $e) { + error_log('escrituras db error: ' . $e->getMessage()); + err('Error de base de datos', 500); +} catch (Throwable $e) { + error_log('escrituras error: ' . $e->getMessage()); + err('Error interno', 500); +} diff --git a/public/escrituras/index.php b/public/escrituras/index.php new file mode 100644 index 0000000..7e88ec8 --- /dev/null +++ b/public/escrituras/index.php @@ -0,0 +1,8 @@ + '', 'name' => '']; +$apps = $authed ? Apps::catalog() : []; + +function initials(string $name): string { + $parts = preg_split('/\s+/u', trim($name)); + $a = mb_substr($parts[0] ?? '', 0, 1); + $b = mb_substr($parts[1] ?? '', 0, 1); + return strtoupper($a . $b) ?: 'AA'; +} + +function icon(string $name): string { + $svgs = [ + 'document' => '', + 'briefcase' => '', + 'calendar' => '', + 'users' => '', + ]; + return $svgs[$name] ?? $svgs['document']; +} +?> + + + + +<?= $authed ? 'Donna · AALN Portal' : 'Donna · AALN Law' ?> + + + + + + + +
+ + +
+
+
Plataforma interna AALN
+

Tu equipo de trabajo, siempre un paso adelante.

+

Donna es el portal interno de AALN: control de escrituras, expedientes, agenda y herramientas internas en un solo lugar. Inicia sesión con tu correo corporativo.

+
+ +
+ +
+ + +
+
+ + +
+
+
D
+
+ AALN · Donna + Portal interno +
+
+
+
+ + Cerrar sesión +
+
+ +
+
+

Hola, .

+

Bienvenido a Donna. Elige una herramienta para empezar.

+
+ + + +
+ + + + + diff --git a/sql/001_core.sql b/sql/001_core.sql new file mode 100644 index 0000000..0006e2e --- /dev/null +++ b/sql/001_core.sql @@ -0,0 +1,12 @@ +-- ============================================================ +-- Donna Portal — Schema 001: Core +-- ============================================================ + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL DEFAULT '', + first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sql/002_escrituras.sql b/sql/002_escrituras.sql new file mode 100644 index 0000000..26bbcb7 --- /dev/null +++ b/sql/002_escrituras.sql @@ -0,0 +1,51 @@ +-- ============================================================ +-- Donna Portal — Schema 002: Control de Escrituras +-- (migrado del repo ZH-Consultores/control-escrituras) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS escrituras_tramites ( + id INT AUTO_INCREMENT PRIMARY KEY, + tramite VARCHAR(255) NOT NULL DEFAULT '', + cliente VARCHAR(255) NOT NULL DEFAULT '', + abogado VARCHAR(100) NOT NULL DEFAULT '', + notaria VARCHAR(100) NOT NULL DEFAULT '', + inscribe ENUM('Sí','No') NOT NULL DEFAULT 'Sí', + altera_turno ENUM('Sí','No') NOT NULL DEFAULT 'No', + instrucciones TEXT NOT NULL, + fecha_solicitud DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + estatus ENUM( + 'Solicitado', + 'Recibido', + 'En Proceso', + 'Protocolizado', + 'En Registro', + 'Inscrito' + ) NOT NULL DEFAULT 'Solicitado', + ultima_actualizacion DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + observaciones TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + creado_por VARCHAR(255) NOT NULL DEFAULT '', + actualizado_por VARCHAR(255) NOT NULL DEFAULT '', + + INDEX idx_estatus (estatus), + INDEX idx_abogado (abogado), + INDEX idx_inscribe (inscribe), + INDEX idx_altera (altera_turno), + INDEX idx_fecha (fecha_solicitud DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS escrituras_historial ( + id INT AUTO_INCREMENT PRIMARY KEY, + tramite_id INT NOT NULL, + modificado_por VARCHAR(255) NOT NULL DEFAULT '', + modificado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + campo VARCHAR(100) NOT NULL DEFAULT '', + valor_anterior TEXT NOT NULL, + valor_nuevo TEXT NOT NULL, + + INDEX idx_tramite (tramite_id), + INDEX idx_fecha (modificado_en DESC), + CONSTRAINT fk_escrituras_historial_tramite + FOREIGN KEY (tramite_id) REFERENCES escrituras_tramites(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/Apps.php b/src/Apps.php new file mode 100644 index 0000000..198a2f5 --- /dev/null +++ b/src/Apps.php @@ -0,0 +1,41 @@ + 'escrituras', + 'title' => 'Control de Escrituras', + 'description' => 'Seguimiento de trámites notariales: estado, abogado, notaría, historial y auditoría.', + 'icon' => 'document', + 'href' => '/escrituras/', + 'status' => 'live', + ], + [ + 'slug' => 'casos', + 'title' => 'Gestión de Casos', + 'description' => 'Expedientes, plazos procesales y notas de cada asunto.', + 'icon' => 'briefcase', + 'href' => '#', + 'status' => 'coming-soon', + ], + [ + 'slug' => 'agenda', + 'title' => 'Agenda', + 'description' => 'Calendario compartido de audiencias, reuniones y vencimientos.', + 'icon' => 'calendar', + 'href' => '#', + 'status' => 'coming-soon', + ], + [ + 'slug' => 'clientes', + 'title' => 'Directorio de Clientes', + 'description' => 'Datos de contacto, sociedades vinculadas y matriz de KYC.', + 'icon' => 'users', + 'href' => '#', + 'status' => 'coming-soon', + ], + ]; + } +} diff --git a/src/Auth.php b/src/Auth.php new file mode 100644 index 0000000..7204aa7 --- /dev/null +++ b/src/Auth.php @@ -0,0 +1,124 @@ + Config::int('SESSION_LIFETIME', 28800), + 'path' => '/', + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + session_start(); + } + + public static function isAuthenticated(): bool { + self::startSession(); + return !empty($_SESSION['authenticated']) + && !empty($_SESSION['auth_time']) + && (time() - (int)$_SESSION['auth_time']) < Config::int('SESSION_LIFETIME', 28800); + } + + public static function user(): array { + self::startSession(); + return [ + 'email' => $_SESSION['user_email'] ?? '', + 'name' => $_SESSION['user_name'] ?? '', + ]; + } + + public static function buildLoginUrl(?string $returnTo = null): string { + self::startSession(); + $state = bin2hex(random_bytes(16)); + $_SESSION['oauth_state'] = $state; + $_SESSION['oauth_return_to'] = $returnTo ?: ($_SERVER['REQUEST_URI'] ?? '/'); + + $params = http_build_query([ + 'response_type' => 'code', + 'client_id' => Config::get('OAUTH_CLIENT_ID'), + 'redirect_uri' => Config::get('OAUTH_REDIRECT_URI'), + 'scope' => Config::get('OAUTH_SCOPE', 'profile'), + 'state' => $state, + ]); + return Config::get('OAUTH_AUTH_URL') . '?' . $params; + } + + public static function requireAuth(): void { + if (self::isAuthenticated()) return; + $loginUrl = self::buildLoginUrl(); + $accepts = $_SERVER['HTTP_ACCEPT'] ?? ''; + $isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) + || str_contains($accepts, 'application/json'); + if ($isAjax) { + header('Content-Type: application/json; charset=utf-8'); + http_response_code(401); + echo json_encode(['ok' => false, 'auth_required' => true, 'login_url' => $loginUrl]); + exit; + } + header('Location: ' . $loginUrl); + exit; + } + + public static function emailDomainAllowed(string $email): bool { + $allowed = Config::list('ALLOWED_EMAIL_DOMAINS'); + if (!$allowed) return true; + $parts = explode('@', strtolower(trim($email))); + if (count($parts) !== 2) return false; + return in_array($parts[1], array_map('strtolower', $allowed), true); + } + + public static function completeLogin(string $email, string $name): void { + self::startSession(); + session_regenerate_id(true); + $_SESSION['authenticated'] = true; + $_SESSION['user_email'] = $email; + $_SESSION['user_name'] = $name ?: $email; + $_SESSION['auth_time'] = time(); + + try { + $pdo = Db::pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO users (email, name, first_seen_at, last_login_at) + VALUES (:email, :name, NOW(), NOW()) + ON DUPLICATE KEY UPDATE name = VALUES(name), last_login_at = NOW()' + ); + $stmt->execute([':email' => $email, ':name' => $name]); + } catch (Throwable $e) { + error_log('users upsert failed: ' . $e->getMessage()); + } + } + + public static function logout(): void { + self::startSession(); + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $p = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'] ?? '', $p['secure'], $p['httponly']); + } + session_destroy(); + } + + public static function popReturnTo(): string { + self::startSession(); + $to = $_SESSION['oauth_return_to'] ?? '/'; + unset($_SESSION['oauth_return_to']); + return $to; + } + + public static function checkState(string $state): bool { + self::startSession(); + $expected = $_SESSION['oauth_state'] ?? ''; + unset($_SESSION['oauth_state']); + return $state !== '' && hash_equals((string)$expected, $state); + } +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..6fa39c5 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,34 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + return self::$pdo; + } +} diff --git a/src/Oauth.php b/src/Oauth.php new file mode 100644 index 0000000..711e53c --- /dev/null +++ b/src/Oauth.php @@ -0,0 +1,65 @@ + 'authorization_code', + 'code' => $code, + 'redirect_uri' => Config::get('OAUTH_REDIRECT_URI'), + 'client_id' => Config::get('OAUTH_CLIENT_ID'), + 'client_secret' => Config::get('OAUTH_CLIENT_SECRET'), + ]); + } + + public static function fetchProfile(string $accessToken): array { + return self::httpGetBearer(Config::get('OAUTH_PROFILE_URL'), $accessToken); + } + + private static function httpPost(string $url, array $form): array { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query($form), + CURLOPT_HTTPHEADER => ['Accept: application/json'], + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + $body = curl_exec($ch); + $err = curl_error($ch); + curl_close($ch); + if ($err || $body === false) { + error_log('oauth POST failed: ' . $err); + return []; + } + return json_decode((string)$body, true) ?? []; + } + + private static function httpGetBearer(string $url, string $token): array { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $token, + 'Accept: application/json', + ], + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); + $body = curl_exec($ch); + $err = curl_error($ch); + curl_close($ch); + if ($err || $body === false) { + error_log('oauth GET failed: ' . $err); + return []; + } + return json_decode((string)$body, true) ?? []; + } +} diff --git a/views/escrituras.html b/views/escrituras.html new file mode 100644 index 0000000..c15d85a --- /dev/null +++ b/views/escrituras.html @@ -0,0 +1,1092 @@ + + + + + +Control de Escrituras — AALN Law + + + + + + +
+
+ +
+ +
+
+
+ Conectando… +
+
+
?
+ Cargando… +
+ +
+
+ +
+
+ + +
+
+ Estatus + +
+
+ Abogado + +
+
+ Inscribe + +
+
+ Alt. Turno + +
+ +
+ + +
+
+ +
+
Total trámites
—
+
Inscritos
—
+
En registro
—
+
Pendientes
—
+
Alt. de turno
—
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
#Nombre del TrámiteClienteAbogado SolicitanteNotaríaSe InscribeAlt. de TurnoInstrucciones EspecialesFecha de SolicitudEstatusÚlt. ActualizaciónActualizado porObservacionesCreado por
+ +
+
+
+ +
+ Arias, Abrego, López & Noriega  ·  P.H. Midtown, Piso 16, Of. 1605, Calle 74 Este, San Francisco, Ciudad de Panamá  ·  SPECTER Legal Assistant +
+ + + + + + + +