# Arquitectura · NimiApps 0.2.0 (ENT-002)

Este documento describe cómo se organizan los usuarios, los espacios de
trabajo, los roles y el aislamiento de datos, y cómo deben usarlo las
microapps que se construyan a partir de ENT-003.

## 1. Modelo elegido: espacios de trabajo

Todo dato de negocio pertenece a un **espacio de trabajo** (`workspaces`). Hay
dos tipos:

| Tipo           | Cómo nace                                    | Miembros            | Puede invitar | Se puede transferir |
|----------------|----------------------------------------------|---------------------|---------------|---------------------|
| `personal`     | Automáticamente, uno por usuario, al registrarse | Solo su dueño      | No            | No                  |
| `organization` | Lo crea un usuario desde el panel            | Varios, con rol     | Sí            | Sí                  |

**Por qué un único concepto y no «usuario» y «organización» por separado:**

- Las microapps guardan sus datos con una sola columna, `workspace_id`, y un
  solo mecanismo de aislamiento. Sin espacio personal cada módulo tendría que
  manejar dos casos (`user_id` o `organization_id`) y cada consulta sería un
  punto donde equivocarse.
- Un autónomo usa su espacio personal; una empresa, una organización; la
  misma persona puede pasar de uno a otro con el selector sin cambiar de
  cuenta.
- La futura facturación, las aplicaciones activas y los permisos se asignan
  por espacio, sea del tipo que sea.
- El espacio personal tiene reglas propias en las políticas (no admite
  miembros ni invitaciones), así que no hay riesgo de «compartir» por error
  datos personales.

Alternativas descartadas: organización obligatoria para todos (fricción en el
registro y organizaciones «fantasma» de una persona), y bases de datos o
esquemas por cliente (inviable en un hosting compartido con una sola MariaDB y
sin root).

## 2. Tablas

Todas las migraciones son aditivas y compatibles con MariaDB 10.6 (sin
funciones de 10.11) y con SQLite (pruebas).

| Tabla                   | Contenido |
|-------------------------|-----------|
| `users` (+columnas)     | `last_name`, `current_workspace_id` (último espacio usado, FK `nullOnDelete`), `last_login_at`. El email se guarda en minúsculas. |
| `workspaces`            | `ulid` (identificador público en URLs), `type`, `name`, `slug` único (identificador interno), `trade_name`, datos fiscales opcionales (`legal_name`, `tax_id`, `tax_address`, `tax_postal_code`, `tax_city`, `tax_province`, `tax_country`), `owner_id` (FK `restrict`), `personal_user_id` (único: un solo espacio personal por usuario), `created_by`. |
| `workspace_memberships` | `workspace_id` + `user_id` únicos, `role`, `invited_by`, `joined_at`. Columna generada `owner_marker` (1 si el rol es `owner`, si no NULL) con índice único junto a `workspace_id`: **la base de datos impide dos propietarios** en un espacio. |
| `workspace_invitations` | `ulid`, `email`, `role`, `token_hash` (SHA-256, único), `status` (`pending`, `accepted`, `revoked`, `expired`), `expires_at`, `last_sent_at`, `send_count`, `invited_by`, `accepted_at/by`, `revoked_at/by`. Columna generada `pending_email` (el email solo mientras está pendiente) con índice único junto a `workspace_id`: **no puede haber dos invitaciones pendientes al mismo email en el mismo espacio**. |
| `legal_acceptances`     | Usuario, documento (`terms`/`privacy`), versión, fecha, IP y navegador de cada aceptación. |
| `platform_admins`       | Usuarios con acceso a `/admin`, con quién y cómo se concedió. |
| `audit_logs`            | Evento, actor, espacio, sujeto, metadatos (JSON sin secretos), IP, navegador y fecha. |

Las columnas generadas usan `storedAs(...)` con `CASE WHEN`, admitido por
MariaDB 10.6 y SQLite. La migración `2026_10_21_000008` crea el espacio
personal de los usuarios que ya existían en ENT-001 (recorre los usuarios por
id; probado con 1.203 usuarios).

## 3. Roles y permisos

Los roles son `owner` (propietario), `admin` (administrador) y `member`
(miembro). Las comprobaciones no preguntan «¿es admin?», sino «¿tiene el
permiso X?» (`App\Enums\WorkspacePermission`), para poder añadir permisos más
finos sin tocar el resto del código.

| Permiso                     | owner | admin | member |
|-----------------------------|:-----:|:-----:|:------:|
| `workspace.view`            | ✓ | ✓ | ✓ |
| `workspace.update`          | ✓ | ✓ |   |
| `workspace.update-identity` (slug y datos fiscales) | ✓ |   |   |
| `members.view`              | ✓ | ✓ |   |
| `members.manage` (miembros) | ✓ | ✓ |   |
| `admins.manage` (administradores) | ✓ |   |   |
| `members.invite`            | ✓ | ✓ |   |
| `admins.invite`             | ✓ |   |   |
| `invitations.view`          | ✓ | ✓ |   |
| `invitations.revoke`        | ✓ | ✓ |   |
| `ownership.transfer`        | ✓ |   |   |

Reglas que se aplican en el servidor (políticas en `app/Policies` y acciones en
`app/Actions/Workspaces`):

- Nadie puede asignar ni invitar con el rol `owner`: solo se llega a él por
  transferencia.
- Un administrador gestiona miembros, pero no puede expulsar, degradar ni
  ascender a otro administrador ni al propietario, ni ascenderse a sí mismo.
- Un miembro no puede cambiar roles ni invitar.
- El propietario no puede abandonar ni ser expulsado: primero debe transferir
  la propiedad.
- La transferencia la hace solo el propietario, exige su contraseña, bloquea
  las filas implicadas, convierte al antiguo propietario en administrador y
  al nuevo en propietario en una transacción, y avisa por correo a ambos. La
  base de datos garantiza un único propietario por espacio.
- Las organizaciones no se pueden eliminar en ENT-002.

## 4. Espacio activo y aislamiento multiempresa

Todas las organizaciones comparten una base de datos MariaDB. El aislamiento
se basa en tres piezas:

1. **`CurrentWorkspace`** (`app/Support/Workspaces`): servicio con ámbito de
   petición (o de trabajo en cola) que guarda el espacio, la membresía y el
   usuario activos. Métodos: `get()`, `getOrFail()`, `id()`, `membership()`,
   `role()`, `can(WorkspacePermission)`.
2. **Middleware `workspace`** (`ResolveCurrentWorkspace`): en **cada
   petición** vuelve a leer la membresía del usuario en el espacio guardado en
   la sesión. Si ya no existe (le han expulsado), no se usa ese espacio: se
   le lleva a su espacio personal con un aviso. Por eso una expulsión surte
   efecto en la siguiente petición, sin esperar a que caduque la sesión.
3. **Rasgo `BelongsToWorkspace`** para los modelos de las microapps:
   - añade un filtro global `workspace_id = espacio activo` a todas las
     consultas;
   - asigna el espacio activo al crear y rechaza (`WorkspaceMismatch`) crear,
     modificar, mover o borrar registros de otro espacio;
   - **sin espacio activo falla** (`MissingWorkspaceContext`) en lugar de
     devolver datos de todos: es preferible un error a una fuga;
   - `Modelo::acrossAllWorkspaces()` quita el filtro, de forma explícita, solo
     para procesos internos.

Además:

- Las URLs usan el `ulid` del espacio o identificadores de filas que se
  buscan **dentro** del espacio activo (`$workspace->memberships()->findOrFail()`),
  así que cambiar un número en la URL da 404 (protección IDOR).
- `SwitchWorkspace` solo cambia a espacios donde el usuario es miembro (404
  en otro caso).
- **Trabajos en segundo plano:** el rasgo `WorkspaceAware` guarda el espacio y
  el usuario al despachar el trabajo; el middleware de trabajo
  `WithWorkspaceContext` vuelve a comprobar la membresía al ejecutarlo y, si
  ya no existe, descarta el trabajo y lo audita (`job.discarded`).
- **Comandos y tareas programadas:** `WorkspaceContext::run($espacio, $usuario, fn () => ...)`
  establece el contexto (comprobando la membresía si se indica usuario) y lo
  restaura al terminar.

## 5. Autenticación

- Rutas en español: `/registro`, `/acceso`, `/salir`, `/recuperar-contrasena`,
  `/restablecer-contrasena/{token}`, `/email/verificar`.
- Registro con nombre, apellidos, email, contraseña (mínimo 10 caracteres con
  letras y números) y aceptación expresa (casilla sin marcar) de condiciones y
  privacidad. Se guarda versión y fecha de cada texto en `legal_acceptances`.
  Los textos actuales son **provisionales** y se muestran marcados como tal
  (`config/microapps.php` → `legal`).
- El registro **no revela si un email ya existe**: muestra la misma
  respuesta, hace el mismo trabajo de hash y envía al titular un aviso
  («alguien ha intentado registrarse con tu email», como máximo uno por hora).
- Acceso: límite de 5 intentos por email+IP y 20 por minuto por IP,
  regeneración de la sesión al entrar, invalidación y nuevo token CSRF al
  salir, «recordarme» de 30 días, `auth.session` para que cambiar la
  contraseña o cerrar las otras sesiones las invalide de verdad.
- Recuperación: token de un solo uso que caduca en 60 minutos, misma
  respuesta exista o no el email, límites por IP y por email.
- Verificación de email: enlace firmado que caduca en 60 minutos, reenvío
  limitado (2 por minuto, 6 por hora). El panel exige email verificado.
- Perfil: editar nombre y email (cambiar el email exige la contraseña, vuelve
  a pedir verificación y avisa a la dirección anterior), cambiar contraseña,
  ver organizaciones y cerrar las demás sesiones.
- Los correos se envían en el momento (sin cola). Si el SMTP falla, la acción
  no se rompe: se registra un aviso y se informa al usuario.

## 6. Invitaciones

1. Un propietario o administrador invita a un email con un rol (`admin` solo
   lo puede usar el propietario).
2. Se genera un token de 256 bits (`random_bytes`). En la base de datos solo
   se guarda su **hash SHA-256**; el token completo solo viaja en el correo.
3. El enlace `/invitaciones/{token}` caduca (72 h por defecto,
   `NIMIAPPS_INVITATION_EXPIRE_HOURS`), es de un solo uso y solo lo puede
   aceptar una cuenta **verificada con el mismo email**. Si la persona no tiene
   cuenta, se le lleva al registro con el email precargado y, tras verificar,
   vuelve a la invitación. El token se guarda en la sesión (como hash) y se
   quita de la URL.
4. Reenvío con nuevo token (el anterior deja de valer), un mínimo de 60 s
   entre envíos y un máximo de 5 envíos. Revocación en cualquier momento.
5. Protección frente a carreras: el espacio y la invitación se bloquean
   (`SELECT ... FOR UPDATE`, siempre en el mismo orden) y la aceptación es un
   `UPDATE ... WHERE status = 'pending'`; los índices únicos impiden
   duplicados. Probado en MariaDB con 10 procesos simultáneos.
6. Una invitación solo sirve para su espacio: se busca por hash y la
   membresía se crea en el espacio de la propia invitación.
7. El administrador ve las invitaciones pendientes; el propietario ve además
   quién envió cada una.

## 7. Administración interna (`/admin`)

- Separada del panel: rutas `/admin`, middleware `platform.admin` (Gate
  `platform-admin`: cuenta con email verificado y fila en `platform_admins`).
  Un usuario normal recibe 403.
- Pantallas: resumen, usuarios (búsqueda), organizaciones (búsqueda) y
  miembros de cada organización (la consulta queda auditada).
- El primer administrador se crea por SSH con `php artisan microapps:admin-crear`
  (interactivo, sin contraseñas por defecto). No existe ninguna ruta web para
  hacerse administrador. También `microapps:admin-listar` y
  `microapps:admin-revocar`.

## 8. Auditoría

`App\Support\Audit\Audit::record()` guarda: accesos, accesos fallidos,
bloqueos, cierres de sesión, registros, verificaciones, cambios y
restablecimientos de contraseña, cambios de email, cierre de otras sesiones,
creación y edición de espacios, invitaciones (creación, reenvío, revocación,
aceptación), cambios de rol, expulsiones, abandonos, transferencias de
propiedad, concesión y retirada de administradores, consultas de `/admin` y
trabajos descartados.

Nunca se guardan contraseñas ni tokens: la función rechaza claves como
`password`, `token` o `secret` en los metadatos y los emails de terceros se
guardan enmascarados (`a***@dominio.com`).

## 9. Estructura de carpetas (lo nuevo)

```
app/Actions/Auth/RegisterUser.php
app/Actions/Workspaces/      CreatePersonalWorkspace, CreateOrganization, SwitchWorkspace,
                             InviteMember, ResendInvitation, RevokeInvitation, AcceptInvitation,
                             ChangeMemberRole, RemoveMember, LeaveWorkspace, TransferOwnership
app/Console/Commands/        CreatePlatformAdmin, ListPlatformAdmins, RevokePlatformAdmin, SendTestMail
app/Enums/                   WorkspaceType, WorkspaceRole, WorkspacePermission
app/Exceptions/              MissingWorkspaceContext, WorkspaceMismatch, WorkspaceAccessDenied, InvitationException
app/Http/Controllers/Auth/   Registro, acceso, recuperación, verificación
app/Http/Controllers/Panel/  Inicio, aplicaciones, espacio, miembros, invitaciones, configuración, perfil
app/Http/Controllers/Admin/  Administración interna
app/Http/Middleware/         ResolveCurrentWorkspace, EnsureWorkspacePermission,
                             EnsureOrganizationWorkspace, EnsurePlatformAdmin
app/Jobs/Concerns/WorkspaceAware.php, app/Jobs/Middleware/WithWorkspaceContext.php
app/Livewire/Admin/          Tablas de usuarios y organizaciones
app/Models/                  Workspace, WorkspaceMembership, WorkspaceInvitation, LegalAcceptance,
                             PlatformAdmin, AuditLog, Concerns/BelongsToWorkspace
app/Notifications/           Correos transaccionales
app/Policies/                WorkspacePolicy, WorkspaceMembershipPolicy, WorkspaceInvitationPolicy
app/Support/Audit/, app/Support/Workspaces/, app/Support/SafeMailer.php
lang/es/                     Traducciones
resources/views/             auth/, panel/, admin/, invitations/, legal/, errors/, components/
```

## 10. Guía para desarrollar microapps (ENT-003 en adelante)

**Migración** de una tabla del módulo:

```php
Schema::create('facturas', function (Blueprint $table) {
    $table->id();
    $table->foreignWorkspace();          // workspace_id + FK + índice
    $table->string('numero');
    $table->timestamps();
    $table->unique(['workspace_id', 'numero']);   // unicidad siempre por espacio
});
```

**Modelo:**

```php
class Factura extends Model
{
    use BelongsToWorkspace;
}

Factura::query()->get();                 // solo las del espacio activo
Factura::create(['numero' => 'F-1']);    // workspace_id se asigna solo
```

**Rutas** del módulo dentro del panel:

```php
Route::middleware(['web', 'auth', 'auth.session', 'verified', 'workspace'])
    ->prefix('panel/facturas')->group(function () {
        Route::get('/', ...);                                        // cualquier miembro
        Route::post('/', ...)->middleware('workspace.can:workspace.update');
    });
```

**En controladores y vistas:** `app(CurrentWorkspace::class)->getOrFail()`,
`->can(WorkspacePermission::...)`, o `Gate::authorize(...)` con una política
propia del módulo.

**Trabajos en cola:**

```php
class GenerarInforme implements ShouldQueue
{
    use Queueable, WorkspaceAware;
    public function __construct() { $this->captureWorkspace(); }
    public function middleware(): array { return $this->workspaceMiddleware(); }
    public function handle(): void { /* consultas ya filtradas por espacio */ }
}
```

**Comandos y tareas programadas:**

```php
WorkspaceContext::run($workspace, null, function () { /* ... */ });
```

Reglas para los módulos:

1. Toda tabla con datos de clientes lleva `workspace_id` y su modelo usa
   `BelongsToWorkspace`. Sin excepciones.
2. Los índices únicos incluyen `workspace_id`.
3. Buscar registros por id siempre a través del modelo con el rasgo (nunca
   `DB::table()` sin filtrar). `acrossAllWorkspaces()` solo en procesos
   internos revisados.
4. Los archivos subidos se guardan bajo una carpeta por espacio
   (`workspaces/{ulid}/...`) y se sirven a través de un controlador que
   comprueba el espacio.
5. Las cachés con datos de cliente incluyen el id del espacio en la clave.
6. Las pruebas de cada módulo deben incluir el caso «otro espacio no ve ni
   modifica estos datos» (hay ejemplos en `tests/Feature/Tenancy`).
