Files
Soporte ZH Consultores 1097b2df00 docs(readme): patrón de sesiones PHP con read_and_close
Documenta el fix de 1bfb3fb en la sección "Patrones de código" para
que futuros contribuyentes no escriban a $_SESSION sin llamar
startSession(true), lo cual perdería el write en silencio.
2026-07-17 11:06:24 -05:00

19 KiB

Donna — Portal interno AAL

Portal interno de la firma Arias, Abrego & López (aal-law.com) que aloja las herramientas operativas del equipo: control de escrituras y trámites legales, registro de horas, calendario, panel de administración y auditoría.

Producción: https://donna.aal-law.com


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 (terminación SSL en Cloudflare → infra Caddy)
  • MariaDB 11.8 (127.0.0.1:3306), zona horaria America/Panama (UTC-5)
  • Autenticación OAuth2 contra Mailcow (mail.aal-law.com)
  • Frontend: HTML + JS vanilla con window.DONNA inyectado por Bootstrap::inject()
  • Exportación a Excel/PDF cliente-side con SheetJS y window.print()
  • Backups diarios automáticos vía systemd timer

Roles y permisos

Rol Descripción Visibilidad
admin Programador / modo Dios oculto No aparece en dropdowns ni en listados para socios
socio Acceso total al sistema Ve todo, gestiona usuarios y auditoría
abogado Puede crear trámites y escrituras Solo ve trámites donde es creador, abogado solicitante o involucrado
usuario Apoyo administrativo (pasantes, asistentes) Solo ve trámites donde está involucrado; crea escrituras

Helpers en src/Auth.php:

  • Auth::isSocio() — true para admin y socio
  • Auth::isAdmin() — solo admin
  • Auth::canCreate() — socio, abogado, admin (trámites)
  • Auth::canDelete() — igual que canCreate

Usuarios nuevos al loguearse por primera vez nacen con rol usuario.


Estructura del repo

donna/
├── public/                       ← DocumentRoot
│   ├── index.php                 ← Landing + grid de apps + "Eventos de hoy"
│   ├── assets/
│   │   ├── donna-export.css      ← UI compartida del botón Exportar
│   │   └── donna-export.js       ← Helpers DonnaExport.toExcel / toPDF / attach
│   ├── auth/
│   │   ├── login.php             ← Inicia OAuth flow
│   │   ├── callback.php          ← Recibe code de Mailcow + crea/actualiza user
│   │   └── logout.php
│   ├── admin/
│   │   ├── index.php             ← Auth-guard + view (solo socios/admin)
│   │   └── api.php               ← CRUD usuarios + query del audit log
│   ├── calendario/
│   │   ├── index.php
│   │   └── api.php               ← CRUD calendar_events (manuales)
│   ├── escrituras/
│   │   ├── index.php
│   │   └── api.php               ← CRUD escrituras + historial
│   ├── horas/
│   │   ├── index.php
│   │   └── api.php               ← Reporte de horas con filtros + agregaciones
│   └── tramites/
│       ├── index.php
│       └── api.php               ← CRUD trámites + observaciones + horas
├── src/                          ← Librerías PHP compartidas (fuera de DocumentRoot)
│   ├── Apps.php                  ← Catálogo de apps del portal (filtra por isSocio)
│   ├── AuditLog.php              ← Helper para registrar acciones en audit_log
│   ├── Auth.php                  ← Sesión + OAuth guard + roles + whitelist
│   ├── Bootstrap.php             ← Inyecta window.DONNA en cada view HTML
│   ├── Config.php                ← Lee /etc/donna/donna.env, fija TZ a Panama
│   ├── Db.php                    ← PDO singleton, fija session TZ a -05:00
│   ├── Lookups.php               ← Queries para dropdowns (abogados, usuarios)
│   └── Oauth.php                 ← Cliente HTTP a Mailcow
├── views/                        ← Templates HTML servidos por PHP tras auth
│   ├── admin.html                ← Panel de Control (2 pestañas: Usuarios + Log)
│   ├── calendario.html           ← Vista año/mes/semana/día con eventos manuales
│   ├── escrituras.html
│   ├── horas.html                ← Reporte de horas con desglose por trámite
│   └── tramites.html             ← 4 tabs: Admin / Corp / Mig / Litigios
├── sql/                          ← Migraciones (aplicar manualmente, ordenadas)
│   ├── 001_core.sql              ← users
│   ├── 002_escrituras.sql        ← escrituras_tramites + escrituras_historial
│   ├── 003_escrituras_entrada.sql
│   ├── 004_tramites.sql          ← tramites_legales + tramites_seq
│   ├── 005_users_roles.sql       ← Roles socio/abogado/usuario
│   ├── 006_tramites_involucrados.sql
│   ├── 007_drop_apellido.sql     ← Nombre Completo en toda la app
│   ├── 008_admin_role.sql        ← Rol admin (programador)
│   ├── 009_panama_timezone.sql   ← Convertir timestamps históricos UTC → -05:00
│   ├── 010_audit_log.sql         ← Tabla audit_log
│   ├── 011_calendar_events.sql   ← Eventos manuales del calendario
│   ├── 012_drop_users_orden.sql  ← Simplificación: orden alfabético siempre
│   ├── 013_normalize_abogado_names.sql  ← Backfill nombres legacy
│   └── 014_add_user_id_to_hours_obs.sql ← FK user_id en hours/obs
├── 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

Apps

Registradas en src/Apps.php::catalog($isSocio). El landing las renderiza como cards.

Slug Estado Visible a Ruta Tablas DB
escrituras Live Todos /escrituras/ escrituras_tramites, escrituras_historial
tramites Live Todos (filtrado por canView()) /tramites/ tramites_legales, tramites_involucrados, tramites_obs, tramites_hours, tramites_seq
calendario Live Todos /calendario/ calendar_events (+ deriva de escrituras/trámites)
horas Live Todos (no-socios solo sus propias) /horas/ tramites_hours (vista)
admin Live Solo socios/admin /admin/ users, audit_log

Control de Escrituras (escrituras)

Seguimiento de trámites notariales: estado, abogado, notaría, alteración de turno, historial y auditoría. Estatus: Solicitado → Recibido → En Proceso → Protocolizado → En Registro → Inscrito.

Control de Trámites Legales (tramites)

4 departamentos (Administrativo, Corporativo/Civil, Migración, Litigios) con campos específicos por depto. Cada trámite tiene:

  • Involucrados (M:N con users): determinan acceso y dropdowns
  • Observaciones (tramites_obs): timeline de comentarios
  • Horas Incurridas (tramites_hours): registro en bloques de 15 min (0:15, 0:30… 12:00). Editable/eliminable por autor, involucrados o socios. Almacena abogado (string) + user_id (FK) para que el filtro siga funcionando si renombras a un usuario.

Calendario (calendario)

Vista año/mes/semana/día. Eventos derivados automáticamente de Escrituras (fecha de solicitud) y Trámites (solicitud, vencimiento migración, término procesal). Más eventos manuales (calendar_events): reserva de sala, cumpleaños, recordatorio, evento general — con horario, ubicación y creador.

Reporte de Horas (horas)

Filtros por rango de fechas y por usuario. Tarjetas por abogado expandibles → muestran el desglose de trámites con total de horas por trámite. KPIs (Total horas, Entradas, Personas). No-socios ven solo sus propias horas.

Panel de Control (admin)

Pestaña Usuarios: CRUD, asignación de roles, activación/desactivación. La columna placeholder original se va reemplazando conforme los abogados se loguean. Listas siempre alfabéticas.

Pestaña Registro de Transacciones: query de audit_log con filtros por fecha, usuario, módulo, acción y búsqueda libre. Cada login, logout, create, update, delete, set_status, add_obs, add_hours y similares queda registrado.


Exportación a Excel + PDF

Módulo compartido en public/assets/donna-export.{js,css} con la misma UI (botón "Exportar ▾" + dropdown verde Excel / rojo PDF) en los 6 puntos del sistema:

Módulo Qué exporta
Escrituras Trámites filtrados
Trámites (cada tab) Filas filtradas del tab activo
Calendario visibleEvents() (respeta chips de origen + búsqueda)
Reporte de Horas Entradas filtradas
Panel · Usuarios Lista visible (respeta filtros)
Panel · Registro de Transacciones Entradas cargadas

API JS:

DonnaExport.buttonHtml(id?)   // returns HTML del botón + menú
DonnaExport.attach(wrapId, { onExcel, onPDF })
DonnaExport.toExcel({ filename, sheetName, headers, rows, columnWidths, onEmpty })
DonnaExport.toPDF  ({ filename, title, headers, rows, pdfCols?, orientation?,
                      statusColIndex?, statusColors?, footer?, onEmpty })
  • Excel: .xlsx con header en navy, ancho de columnas personalizable, UTF-8 nativo.
  • PDF: abre ventana nueva con HTML estilizado + window.print() (paisaje A4 por defecto).

Eventos de hoy (Home)

Debajo de las tarjetas de módulos, el portal muestra los eventos del día:

  • Eventos manuales (cumpleaños, sala de conferencias, recordatorios): visibles a todos
  • Escrituras con fecha_solicitud = hoy: visibles a todos
  • Trámites con fecha_solicitud / vencimiento / término = hoy: solo a creador, abogado solicitante o involucrado (mismo canView() que el módulo)

Cada card es clickable y abre la ficha correspondiente.


Setup en un servidor nuevo

# 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. Zona horaria del SO
timedatectl set-timezone America/Panama

# 3. 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';"

# 4. Código
git clone https://git.zhconsultores.com/ZH-Consultores/donna.git /opt/donna
chown -R www-data:www-data /opt/donna

# 5. Migraciones (en orden)
for f in /opt/donna/sql/0*.sql; do mariadb donna < "$f"; done

# 6. 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

# 7. 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` (rol=usuario si es nuevo)
  ├── registra login en audit_log
  └── redirige a /

Sesión: cookie donna_session, path=/, Secure, HttpOnly, SameSite=Lax, lifetime 8h. SSO automático entre todas las apps del portal.


Patrones de código

Bootstrap::inject($viewPath)

Cada <app>/index.php hace:

require_once __DIR__ . '/../../src/Bootstrap.php';
Auth::requireAuth();
header('Content-Type: text/html; charset=utf-8');
Bootstrap::inject(__DIR__ . '/../../views/<app>.html');

Esto inyecta antes del primer </head>:

<script>window.DONNA = {
  me: 'email@aal-law.com',
  name: 'Nombre Completo',
  role: 'socio',
  isAdmin: false,
  isSocio: true,
  canCreate: true,
  canDelete: true,
  abogados: ['Mario Arias', ...],
  users: [{id, email, name, role}, ...]
};</script>

Importante: usa strpos/substr para reemplazar solo el primer </head> (no str_replace), porque algunos JS embebidos contienen </head> dentro de template literals que rompen el HTML si se inyecta ahí.

AuditLog::log($modulo, $accion, $entityType, $entityId, $entityLabel, $detalles?)

Falla silenciosamente (try/catch) para no romper el flujo principal. Llamado desde cada API después de operaciones de escritura. Los detalles van como JSON.

Visibilidad de trámites legales (canView())

Socio/admin → siempre
Resto       → creador, o abogado solicitante (match por users.name = t.abogado),
              o involucrado (M:N en tramites_involucrados)

FK user_id en hours/obs

Tanto tramites_hours como tramites_obs tienen abogado VARCHAR(100) (display) y user_id INT NULL (FK canónica ON DELETE SET NULL). Esto evita que renombrar un usuario en el Panel de Control desconecte sus entradas históricas de los filtros. Si lo borras, user_id queda NULL y el string abogado permanece como historia.

Sesiones PHP: Auth::startSession($forWrite = false)

session_start() por defecto toma un flock() exclusivo sobre el archivo de sesión que dura todo el request. Con polling en /escrituras/ cada 15 s + varias pestañas abiertas del mismo usuario, los requests se serializan esperando el lock y agotan el pool de php-fpm.

Por eso Auth::startSession() usa session_start(['read_and_close' => true]) por defecto: carga $_SESSION en memoria y libera el lock de inmediato. Todos los Auth::requireAuth() / isAuthenticated() / user() funcionan con esta snapshot.

Regla al agregar código nuevo: si vas a escribir a $_SESSION[...] = ... o unset($_SESSION[...]), debes hacerlo dentro de un método que llame self::startSession(true) para tomar el lock exclusivo real. Si no, el write ocurre en memoria pero no se persiste al archivo al final del request. Los 5 flujos que ya lo hacen: buildLoginUrl, completeLogin, logout, popReturnTo, checkState.


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.

Colores por departamento de trámites:

  • Admin: #6c5b95 · Corp: #9E7C2E · Mig: #2980b9 · Lit: #722f37

Colores por tipo de evento manual:

  • Reserva sala: #2e7d8f · Cumpleaños: #c2185b · Recordatorio: #e67e22 · Evento: #27ae60

Backups

donna-backup.timer ejecuta diariamente a las 03:00 (jitter ±5min) y genera /var/backups/donna/donna-YYYY-MM-DD_HHMMSS.tar.gz. Retención 14 días. Atómico: el .tmp se renombra al final solo si todo salió bien (no quedan archivos parciales).

Contenido del tarball:

donna-YYYY-MM-DD_HHMMSS/
├── MANIFEST.txt                ← inventario + tamaños + SHA-256 de cada archivo
├── db/
│   ├── schema-and-data.sql.gz  ← mariadb-dump completo (schema + data + routines + triggers + events)
│   └── grants.sql              ← SHOW GRANTS del usuario donna_app
└── etc/
    ├── donna/donna.env         ← secrets (OAuth, DB pass, APP_SECRET)
    ├── caddy/Caddyfile         ← config del Caddy local
    └── systemd/system/
        ├── donna-backup.service
        └── donna-backup.timer

Restaurar en un server nuevo:

TAR=/path/to/donna-YYYY-MM-DD_HHMMSS.tar.gz
tar -xzf "$TAR" -C /tmp && cd /tmp/donna-*
# 1. Configs
cp etc/donna/donna.env    /etc/donna/donna.env  && chmod 640 /etc/donna/donna.env
cp etc/caddy/Caddyfile    /etc/caddy/Caddyfile  && systemctl reload caddy
cp etc/systemd/system/*   /etc/systemd/system/  && systemctl daemon-reload
# 2. DB (crea DB y usuario primero)
mariadb -e "CREATE DATABASE donna CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mariadb < db/grants.sql   # recrea el usuario y sus permisos
gunzip -c db/schema-and-data.sql.gz | mariadb donna
# 3. Verificar
sha256sum -c <(grep -E '^[a-f0-9]{64}' MANIFEST.txt)  # checksums OK?

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
Aplicar migración mariadb donna < /opt/donna/sql/0NN_<nombre>.sql
Consultar audit log Panel de Control → Registro de Transacciones

Agregar una app nueva

  1. Crear /opt/donna/public/<slug>/index.php con Bootstrap::inject(__DIR__ . '/../../views/<slug>.html') precedido de Auth::requireAuth().
  2. Crear /opt/donna/public/<slug>/api.php con Auth::requireAuth() (o requireSocio()).
  3. Crear /opt/donna/views/<slug>.html con el HTML + JS del módulo.
  4. Prefijar todas las tablas DB con <slug>_ y crear migración numerada en sql/0NN_<slug>.sql.
  5. Registrar en src/Apps.php::catalog() (con flag isSocio si aplica).
  6. Si tiene exportación, incluir:
    <link rel="stylesheet" href="/assets/donna-export.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
    <script src="/assets/donna-export.js"></script>
    
  7. Para escritura de auditoría, llamar AuditLog::log(...) después de cada operación.
  8. Commit + push.

Licencia

Software interno — sin licencia pública. Propiedad de Arias, Abrego & López.