diff --git a/CleanVSCache.ps1 b/CleanVSCache.ps1 new file mode 100644 index 0000000..cb12c19 --- /dev/null +++ b/CleanVSCache.ps1 @@ -0,0 +1,78 @@ +# Script para limpiar caché de Visual Studio 2026 y resolver problemas de implementación +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Limpieza de caché de Visual Studio 2026" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# Verificar si Visual Studio está ejecutándose +$vsProcesses = Get-Process devenv -ErrorAction SilentlyContinue +if ($vsProcesses) { + Write-Host "ERROR: Visual Studio está ejecutándose." -ForegroundColor Red + Write-Host "Por favor, cierra Visual Studio antes de ejecutar este script." -ForegroundColor Yellow + Write-Host "" + Read-Host "Presiona Enter para salir" + exit 1 +} + +Write-Host "✓ Visual Studio no está en ejecución" -ForegroundColor Green +Write-Host "" + +# Limpiar carpeta .vs del proyecto +$vsFolder = Join-Path $PSScriptRoot ".vs" +if (Test-Path $vsFolder) { + Write-Host "Eliminando carpeta .vs del proyecto..." -ForegroundColor Yellow + Remove-Item $vsFolder -Recurse -Force -ErrorAction SilentlyContinue + Write-Host "✓ Carpeta .vs eliminada" -ForegroundColor Green +} else { + Write-Host "✓ Carpeta .vs no existe" -ForegroundColor Green +} + +# Limpiar carpeta bin y obj de web +$webBin = Join-Path $PSScriptRoot "web\bin" +$webObj = Join-Path $PSScriptRoot "web\obj" + +if (Test-Path $webBin) { + Write-Host "Eliminando web\bin..." -ForegroundColor Yellow + Remove-Item $webBin -Recurse -Force -ErrorAction SilentlyContinue + Write-Host "✓ web\bin eliminada" -ForegroundColor Green +} + +if (Test-Path $webObj) { + Write-Host "Eliminando web\obj..." -ForegroundColor Yellow + Remove-Item $webObj -Recurse -Force -ErrorAction SilentlyContinue + Write-Host "✓ web\obj eliminada" -ForegroundColor Green +} + +# Limpiar caché de componentes de Visual Studio +$vsComponentCache = "$env:LOCALAPPDATA\Microsoft\VisualStudio\18.0_*" +$cacheFolders = @( + "ComponentModelCache", + "ApplicationPrivateSettings.xml" +) + +Write-Host "" +Write-Host "Limpiando caché de componentes de VS..." -ForegroundColor Yellow + +Get-Item $vsComponentCache -ErrorAction SilentlyContinue | ForEach-Object { + $vsPath = $_.FullName + foreach ($cache in $cacheFolders) { + $cachePath = Join-Path $vsPath $cache + if (Test-Path $cachePath) { + Remove-Item $cachePath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host "✓ Eliminado: $cache" -ForegroundColor Green + } + } +} + +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Limpieza completada" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Pasos siguientes:" -ForegroundColor Yellow +Write-Host "1. Abre Visual Studio 2026" -ForegroundColor White +Write-Host "2. Abre la solución NexusCad.sln" -ForegroundColor White +Write-Host "3. Reconstruye la solución (Ctrl+Shift+B)" -ForegroundColor White +Write-Host "4. Intenta implementar el proyecto web nuevamente" -ForegroundColor White +Write-Host "" +Read-Host "Presiona Enter para salir" diff --git a/INSTRUCCIONES-URGENTE.md b/INSTRUCCIONES-URGENTE.md new file mode 100644 index 0000000..e60b73a --- /dev/null +++ b/INSTRUCCIONES-URGENTE.md @@ -0,0 +1,102 @@ +# ⚠️ INSTRUCCIONES EXACTAS - Visual Studio 2026 + +## 🛑 LO QUE ESTÁS HACIENDO MAL: + +Estás usando una de estas opciones **INCORRECTAS**: +- ❌ Clic derecho en proyecto → "Implementar" / "Deploy" +- ❌ Menú: Compilar → Implementar NexusCad.Web +- ❌ Menú: Compilar → Implementar solución +- ❌ Cualquier opción que diga "IMPLEMENTAR" / "DEPLOY" + +## ✅ LO QUE DEBES HACER: + +### OPCIÓN 1: Usar Teclado (MÁS FÁCIL) + +1. Cierra cualquier ventana de navegador anterior +2. En Visual Studio, asegúrate que estás en el proyecto NexusCad.Web +3. Presiona una de estas teclas: + - **Ctrl + F5** (recomendado - ejecutar sin depuración) + - **F5** (ejecutar con depuración) + +### OPCIÓN 2: Usar Menú + +1. Menú superior → **Depurar** +2. Selecciona: + - **"Iniciar sin depurar"** (Ctrl+F5) + - O **"Iniciar depuración"** (F5) + +### OPCIÓN 3: Usar Barra de Herramientas + +1. Busca en la barra de herramientas superior el botón de **play verde ▶️** +2. Asegúrate que al lado diga "NexusCad.Web" +3. Haz clic en el botón **▶️ verde** + +### OPCIÓN 4: Usar Terminal (100% Garantizado) + +Si nada de lo anterior funciona: + +1. En Visual Studio: **Ver → Terminal** (o Ctrl + `) +2. Ejecuta: +```powershell +cd web +pnpm dev +``` +3. Abre tu navegador en: http://localhost:3000 + +## 🔴 NUNCA HAGAS ESTO CON PROYECTOS WEB: + +``` +❌ "Compilar e implementar" +❌ "Build and Deploy" +❌ Botón "Implementar" +❌ "Deploy Project" +❌ Clic derecho → Implementar +``` + +## ✅ SIEMPRE HAZ ESTO CON PROYECTOS WEB: + +``` +✅ F5 o Ctrl+F5 +✅ "Depurar" → "Iniciar" +✅ Botón play verde ▶️ +✅ Terminal: pnpm dev +``` + +## 📸 Verificación Visual + +Cuando lo hagas correctamente, verás en la salida: + +``` +Iniciando la depuración del proyecto... +> pnpm dev + + VITE v8.0.14 ready in XXX ms + + ➜ Local: http://localhost:3000/ + ➜ Network: use --host to expose +``` + +**NO debe decir**: "Operación Implementar iniciada" + +## 🔧 Si Ya Recargaste el Proyecto y Sigue Fallando: + +Visual Studio tiene caché. Haz lo siguiente: + +1. **CIERRA Visual Studio completamente** +2. Ejecuta el script de limpieza: +```powershell +cd E:\NexusCad +.\CleanVSCache.ps1 +``` +3. **REABRE Visual Studio** +4. Abre SOLO el archivo `NexusCad.Web.esproj` +5. Presiona **Ctrl + F5** + +## 💡 Resumen de 3 Segundos: + +**NO uses "Implementar". USA Ctrl+F5 o pnpm dev en terminal.** + +--- + +Si después de seguir **exactamente** estas instrucciones sigue sin funcionar, +dime qué opción exacta estás usando (nombre del menú o botón) y qué ves en pantalla. diff --git a/NexusCad.slnx b/NexusCad.slnx index 5fbe4bd..53650d1 100644 --- a/NexusCad.slnx +++ b/NexusCad.slnx @@ -14,8 +14,7 @@ - - + diff --git a/RunWeb.ps1 b/RunWeb.ps1 new file mode 100644 index 0000000..4e4de7e --- /dev/null +++ b/RunWeb.ps1 @@ -0,0 +1,19 @@ +# Script para ejecutar el proyecto web directamente +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Ejecutando NexusCad.Web" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +Set-Location -Path "$PSScriptRoot\web" + +Write-Host "Directorio actual: $(Get-Location)" -ForegroundColor Yellow +Write-Host "" +Write-Host "Iniciando servidor de desarrollo Vite..." -ForegroundColor Green +Write-Host "Presiona Ctrl+C para detener el servidor" -ForegroundColor Yellow +Write-Host "" +Write-Host "Una vez iniciado, abre tu navegador en:" -ForegroundColor Cyan +Write-Host " http://localhost:3000" -ForegroundColor White +Write-Host "" + +# Ejecutar pnpm dev +pnpm dev diff --git a/docs/SOLUCION-VS2026-ESPROJ.md b/docs/SOLUCION-VS2026-ESPROJ.md new file mode 100644 index 0000000..2aa582f --- /dev/null +++ b/docs/SOLUCION-VS2026-ESPROJ.md @@ -0,0 +1,107 @@ +# ¡SOLUCIÓN ENCONTRADA! - Error de Implementación VS2026 + +## 🎯 EL PROBLEMA REAL + +**Visual Studio 2026 NO soporta "Implementar" (Deploy) para proyectos esproj de la misma manera que proyectos .NET tradicionales.** + +El error "El valor no puede ser nulo. Nombre del parámetro: source" ocurre porque: +- VS2026 intenta ejecutar un proceso de "implementación" que no aplica a proyectos Vite/Node +- Los proyectos JavaScript/TypeScript se deben **ejecutar**, no **implementar** + +## ✅ SOLUCIÓN CORRECTA + +### Método 1: Ejecutar sin Depuración (F5 o Ctrl+F5) + +En lugar de usar "Implementar": + +1. **Establece `NexusCad.Web` como proyecto de inicio:** + - Clic derecho en el proyecto → "Establecer como proyecto de inicio" + +2. **Ejecuta el proyecto:** + - Presiona **F5** (con depuración) o **Ctrl+F5** (sin depuración) + - O usa el menú: **Depurar → Iniciar sin depurar** + +3. Visual Studio ejecutará automáticamente: + ``` + pnpm dev + ``` + +### Método 2: Usar la Terminal de Visual Studio + +1. Abre la terminal en VS: **Ver → Terminal** +2. Navega al proyecto web: + ```powershell + cd web + ``` +3. Ejecuta el servidor de desarrollo: + ```powershell + pnpm dev + ``` + +### Método 3: Terminal Externa + +Abre PowerShell o Terminal y ejecuta: +```powershell +cd E:\NexusCad\web +pnpm dev +``` + +## 🚫 NO USES "IMPLEMENTAR" (Deploy) + +La opción **"Implementar proyecto"** o **"Deploy"** NO funciona con proyectos esproj porque: +- Es para aplicaciones .NET que necesitan ser publicadas +- Los proyectos Node/Vite se ejecutan directamente con el servidor de desarrollo +- No hay un proceso de "implementación" en desarrollo local + +## 📋 Configuración Correcta del Proyecto + +El archivo `NexusCad.Web.esproj` debe tener: +- ✅ `StartupCommand` = `pnpm dev` +- ✅ `BuildCommand` = `pnpm build` +- ❌ NO necesita `LaunchCommand` ni `LaunchUrl` (causan el error) +- ❌ NO necesita `.vscode/launch.json` para esproj + +## 🎬 Flujo de Trabajo Correcto + +### Para Desarrollo: +1. Abre el proyecto en VS2026 +2. Presiona **Ctrl+F5** (ejecutar sin depuración) +3. VS ejecuta `pnpm dev` automáticamente +4. El navegador se abre en http://localhost:3000 + +### Para Compilar (Build): +1. Clic derecho en proyecto → **Compilar** +2. VS ejecuta `pnpm build` +3. Los archivos se generan en `web/dist/` + +### Para Producción: +- Usa Docker: `docker build -f web/Dockerfile .` +- O compila y sirve: `pnpm build && pnpm preview` + +## 🔍 Por Qué Funcionaba en Otro PC + +En el otro PC probablemente: +- Usabas **F5/Ctrl+F5** en lugar de "Implementar" +- O tenías una versión diferente de VS con mejor manejo de esproj +- O el proyecto web se ejecutaba desde terminal directamente + +## ⚠️ IMPORTANTE + +**Nunca uses "Compilar e Implementar" o "Deploy" con proyectos JavaScript/TypeScript en VS2026.** + +Usa siempre: +- **Compilar** (Build) → `pnpm build` +- **Ejecutar** (F5/Ctrl+F5) → `pnpm dev` +- **Terminal** → Control manual completo + +## 📝 Resumen + +| ❌ NO HAGAS ESTO | ✅ HAZ ESTO | +|------------------|-------------| +| Clic derecho → Implementar | Presiona **Ctrl+F5** | +| Build → Deploy | Build → Debug (F5) | +| Buscar "Deploy/Implementar" | Buscar "Run/Ejecutar" | + +--- + +**Después de seguir esta guía, el proyecto debería ejecutarse sin errores.** diff --git a/docs/TROUBLESHOOTING-VS2026.md b/docs/TROUBLESHOOTING-VS2026.md new file mode 100644 index 0000000..fbbf831 --- /dev/null +++ b/docs/TROUBLESHOOTING-VS2026.md @@ -0,0 +1,104 @@ +# Solución al Error de Implementación en Visual Studio 2026 + +## Problema +Error: "El valor no puede ser nulo. Nombre del parámetro: source" al intentar implementar el proyecto NexusCad.Web.esproj + +## Causa +Este error es específico de Visual Studio 2026 (18.6.2) y ocurre cuando: +1. La caché de Visual Studio tiene configuraciones corruptas +2. El proyecto esproj no tiene las propiedades de lanzamiento correctamente configuradas +3. Los archivos de configuración de lanzamiento están incompletos + +## Soluciones Aplicadas + +### 1. Actualización de NexusCad.Web.esproj +Se agregaron las propiedades `LaunchCommand` y `LaunchUrl` al archivo del proyecto: +```xml +pnpm dev +http://localhost:3000 +``` + +### 2. Configuración de launchSettings.json +Se creó/actualizó `web/Properties/launchSettings.json` con: +- Schema JSON para validación +- Perfil de lanzamiento con nombre del proyecto +- URL de aplicación configurada +- Variables de entorno + +### 3. Configuración de launch.json +Se creó `.vscode/launch.json` con configuraciones para: +- Chrome +- Edge +- Node (pnpm dev) + +## Solución Recomendada + +### Opción A: Limpiar Caché de Visual Studio (RECOMENDADO) + +1. **Cierra Visual Studio 2026 completamente** + +2. **Ejecuta el script de limpieza:** + ```powershell + .\CleanVSCache.ps1 + ``` + Este script eliminará: + - Carpeta `.vs` del proyecto + - Carpetas `bin` y `obj` del proyecto web + - Caché de componentes de Visual Studio + +3. **Reabre Visual Studio y la solución** + +4. **Reconstruye la solución** (Ctrl+Shift+B) + +5. **Intenta implementar nuevamente** + +### Opción B: Limpieza Manual (si el script falla) + +Si prefieres hacerlo manualmente: + +1. Cierra Visual Studio +2. Elimina estas carpetas: + - `E:\NexusCad\.vs` + - `E:\NexusCad\web\bin` + - `E:\NexusCad\web\obj` +3. Elimina caché de VS en: `%LOCALAPPDATA%\Microsoft\VisualStudio\18.0_*\ComponentModelCache` +4. Reabre VS y reconstruye + +### Opción C: Usar Terminal Directamente (WORKAROUND) + +Si el problema persiste, puedes ejecutar el proyecto directamente desde terminal: + +```powershell +cd E:\NexusCad\web +pnpm dev +``` + +Esto iniciará Vite en http://localhost:3000 sin necesidad de la implementación de Visual Studio. + +## Verificación + +Después de aplicar la solución, deberías ver: +- ✅ Compilación exitosa +- ✅ Implementación sin errores +- ✅ Navegador abriéndose en http://localhost:3000 + +## Notas Adicionales + +- Las advertencias de SignalR sobre `/*#__PURE__*/` son solo advertencias y no afectan la funcionalidad +- La advertencia sobre el tamaño del chunk (758KB) es informativa y puede optimizarse más adelante con code-splitting +- En otros equipos el proyecto funciona correctamente, lo que confirma que es un problema local de caché de VS + +## Si el Problema Persiste + +Si después de limpiar la caché el problema continúa: + +1. Verifica que Node.js esté correctamente instalado en `C:\Program Files\nodejs\node.exe` +2. Verifica que pnpm esté instalado globalmente: `pnpm --version` +3. Reinstala las dependencias: `cd web && pnpm install` +4. Considera reparar la instalación de Visual Studio 2026 + +## Archivos Modificados +- `web/NexusCad.Web.esproj` - Agregadas propiedades de lanzamiento +- `web/Properties/launchSettings.json` - Configuración de perfiles +- `.vscode/launch.json` - Configuraciones de depuración +- `CleanVSCache.ps1` - Script de limpieza (nuevo) diff --git a/nexuscad.db.bak-pre-uninstall-20260529-131623 b/nexuscad.db.bak-pre-uninstall-20260529-131623 new file mode 100644 index 0000000..2b7c4f6 Binary files /dev/null and b/nexuscad.db.bak-pre-uninstall-20260529-131623 differ diff --git a/src/NexusCad.Admin/App.xaml.cs b/src/NexusCad.Admin/App.xaml.cs index 8c0ee60..d1793b9 100644 --- a/src/NexusCad.Admin/App.xaml.cs +++ b/src/NexusCad.Admin/App.xaml.cs @@ -77,7 +77,7 @@ protected override void OnStartup(StartupEventArgs e) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); // ViewModels services.AddTransient(); @@ -88,8 +88,8 @@ protected override void OnStartup(StartupEventArgs e) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); // Model Rules diff --git a/src/NexusCad.Admin/MainWindow.xaml b/src/NexusCad.Admin/MainWindow.xaml index 9d00ab6..67e724a 100644 --- a/src/NexusCad.Admin/MainWindow.xaml +++ b/src/NexusCad.Admin/MainWindow.xaml @@ -89,18 +89,18 @@ - - + + - + - + @@ -173,8 +173,8 @@ Foreground="{StaticResource NexusIconMuted}" VerticalAlignment="Center" Margin="0,0,4,0" /> - - + + @@ -322,7 +322,7 @@ - + @@ -336,12 +336,12 @@ - + - - - @@ -375,7 +375,7 @@ - + - + - + /// Ruta personalizada de modelos SolidWorks master para este proyecto. + /// + public string? ModelsPath { get; set; } public int Version { get; set; } public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } @@ -57,6 +61,7 @@ public class CreateProjectRequest public string? Description { get; set; } public string FormSchemaJson { get; set; } = "{}"; public string? IconUrl { get; set; } + public string? ModelsPath { get; set; } } public class UpdateProjectRequest @@ -65,4 +70,5 @@ public class UpdateProjectRequest public string? Description { get; set; } public string FormSchemaJson { get; set; } = "{}"; public string? IconUrl { get; set; } + public string? ModelsPath { get; set; } } diff --git a/src/NexusCad.Admin/Models/RegisterRequest.cs b/src/NexusCad.Admin/Models/RegisterRequest.cs index 2dccebf..2e3e9bf 100644 --- a/src/NexusCad.Admin/Models/RegisterRequest.cs +++ b/src/NexusCad.Admin/Models/RegisterRequest.cs @@ -21,7 +21,4 @@ public class RegisterRequest [Required(ErrorMessage = "Full name is required")] [StringLength(200, ErrorMessage = "Full name cannot exceed 200 characters")] public string FullName { get; set; } = string.Empty; - - [Required(ErrorMessage = "Role is required")] - public string Role { get; set; } = string.Empty; } diff --git a/src/NexusCad.Admin/Models/UpdateUserRequest.cs b/src/NexusCad.Admin/Models/UpdateUserRequest.cs index 1c0f093..922dbd5 100644 --- a/src/NexusCad.Admin/Models/UpdateUserRequest.cs +++ b/src/NexusCad.Admin/Models/UpdateUserRequest.cs @@ -16,8 +16,6 @@ public class UpdateUserRequest public bool IsActive { get; set; } = true; - public List Roles { get; set; } = new(); - [MinLength(6, ErrorMessage = "Password must be at least 6 characters")] public string? NewPassword { get; set; } } diff --git a/src/NexusCad.Admin/Models/UserGroupDtos.cs b/src/NexusCad.Admin/Models/UserGroupDtos.cs new file mode 100644 index 0000000..00a2fe8 --- /dev/null +++ b/src/NexusCad.Admin/Models/UserGroupDtos.cs @@ -0,0 +1,80 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +namespace NexusCad.Admin.Models; + +/// +/// Grupo personalizado de usuarios. Distinto de Workspace (contenedor de proyectos) +/// y de los roles globales (Admin/Autor/Comercial/Cliente). +/// +public class UserGroupDto +{ + public Guid Id { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string? IconUrl { get; set; } + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public int MemberCount { get; set; } +} + +public class UserGroupListItemDto +{ + public Guid Id { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string? IconUrl { get; set; } + public int MemberCount { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +public class CreateUserGroupRequest +{ + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public string? IconUrl { get; set; } +} + +public class UpdateUserGroupRequest +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public string? IconUrl { get; set; } +} + +public class UserGroupMemberDto +{ + public string UserId { get; set; } = string.Empty; + public string? UserName { get; set; } + public string? Email { get; set; } + public string? FullName { get; set; } + public DateTime AddedAt { get; set; } +} + +public class AddUserGroupMemberRequest +{ + public string UserId { get; set; } = string.Empty; +} + +public class SetUserGroupMembersRequest +{ + public List UserIds { get; set; } = new(); +} + +public class UserGroupProjectDto +{ + public Guid ProjectId { get; set; } + public string ProjectCode { get; set; } = string.Empty; + public string ProjectName { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } +} + +public class SetUserGroupProjectsRequest +{ + public List ProjectIds { get; set; } = new(); +} diff --git a/src/NexusCad.Admin/Models/UserGroupMembershipDto.cs b/src/NexusCad.Admin/Models/UserGroupMembershipDto.cs new file mode 100644 index 0000000..56f330e --- /dev/null +++ b/src/NexusCad.Admin/Models/UserGroupMembershipDto.cs @@ -0,0 +1,31 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +namespace NexusCad.Admin.Models; + +/// +/// Membresía de un usuario en un grupo, devuelta por GET /api/auth/users/{id}/memberships +/// +public class UserGroupMembershipDto +{ + public Guid GroupId { get; set; } + public string GroupCode { get; set; } = string.Empty; + public string GroupName { get; set; } = string.Empty; + public string? GroupIconUrl { get; set; } + public int Role { get; set; } // 0=Owner,1=Author,2=Commercial,3=Client + public DateTime CreatedAt { get; set; } +} + +/// +/// Request para reemplazar todas las membresías de un usuario. +/// +public class SetUserMembershipsRequest +{ + public List Memberships { get; set; } = new(); +} + +public class MembershipItem +{ + public Guid GroupId { get; set; } + public int Role { get; set; } +} diff --git a/src/NexusCad.Admin/Models/GroupDto.cs b/src/NexusCad.Admin/Models/WorkspaceDto.cs similarity index 65% rename from src/NexusCad.Admin/Models/GroupDto.cs rename to src/NexusCad.Admin/Models/WorkspaceDto.cs index 1a05856..a0c1e82 100644 --- a/src/NexusCad.Admin/Models/GroupDto.cs +++ b/src/NexusCad.Admin/Models/WorkspaceDto.cs @@ -3,7 +3,7 @@ // namespace NexusCad.Admin.Models; -public class GroupDto +public class WorkspaceDto { public Guid Id { get; set; } public string Code { get; set; } = string.Empty; @@ -18,7 +18,7 @@ public class GroupDto public DateTime UpdatedAt { get; set; } } -public class GroupListItemDto +public class WorkspaceListItemDto { public Guid Id { get; set; } public string Code { get; set; } = string.Empty; @@ -29,7 +29,7 @@ public class GroupListItemDto public DateTime UpdatedAt { get; set; } } -public class CreateGroupRequest +public class CreateWorkspaceRequest { public string Code { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; @@ -38,10 +38,33 @@ public class CreateGroupRequest public string? IconUrl { get; set; } } -public class UpdateGroupRequest +public class UpdateWorkspaceRequest { public string Name { get; set; } = string.Empty; public string? Description { get; set; } public string? WorkspacePath { get; set; } public string? IconUrl { get; set; } } + +/// Acceso de un workspace a un proyecto concreto. +public class WorkspaceProjectAccessDto +{ + public Guid ProjectId { get; set; } + public string ProjectCode { get; set; } = string.Empty; + public string ProjectName { get; set; } = string.Empty; + + /// 0 = Ver, 1 = Configurar + public int AccessLevel { get; set; } = 1; +} + +public class SetWorkspaceProjectAccessRequest +{ + public List Items { get; set; } = new(); +} + +public class WorkspaceProjectAccessEntry +{ + public Guid ProjectId { get; set; } + public int AccessLevel { get; set; } = 1; +} + diff --git a/src/NexusCad.Admin/Services/CurrentGroupService.cs b/src/NexusCad.Admin/Services/CurrentGroupService.cs deleted file mode 100644 index b60e76a..0000000 --- a/src/NexusCad.Admin/Services/CurrentGroupService.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (C) 2026 JaviFRx -// Licensed under AGPL v3 -// -using System.IO; -using System.Text.Json; -using NexusCad.Admin.Models; - -namespace NexusCad.Admin.Services; - -public interface ICurrentGroupService -{ - event EventHandler? CurrentGroupChanged; - - GroupDto? Current { get; } - - bool HasActiveGroup { get; } - - Task SetCurrentAsync(GroupDto? group); - - Task RestoreLastAsync(INexusCadApi api); -} - -/// -/// Mantiene en memoria el Group activo del Admin y persiste su Id en -/// %LocalAppData%/NexusCad/active-group.json para restaurarlo en el siguiente arranque. -/// -public sealed class CurrentGroupService : ICurrentGroupService -{ - private static readonly string StatePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "NexusCad", - "active-group.json"); - - public event EventHandler? CurrentGroupChanged; - - public GroupDto? Current { get; private set; } - - public bool HasActiveGroup => Current != null; - - public async Task SetCurrentAsync(GroupDto? group) - { - Current = group; - await PersistAsync(group?.Id); - CurrentGroupChanged?.Invoke(this, EventArgs.Empty); - } - - public async Task RestoreLastAsync(INexusCadApi api) - { - var storedId = await LoadStoredIdAsync(); - if (storedId == null) - return null; - - try - { - var response = await api.GetGroupByIdAsync(storedId.Value); - if (response.Success && response.Data != null) - { - Current = response.Data; - CurrentGroupChanged?.Invoke(this, EventArgs.Empty); - return Current; - } - } - catch - { - // Si el grupo ya no existe o la API no responde, ignoramos — - // el usuario tendrá que reabrir desde Recent. - } - return null; - } - - private static async Task PersistAsync(Guid? id) - { - try - { - var dir = Path.GetDirectoryName(StatePath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - if (id == null) - { - if (File.Exists(StatePath)) - File.Delete(StatePath); - return; - } - - var json = JsonSerializer.Serialize(new { ActiveGroupId = id }); - await File.WriteAllTextAsync(StatePath, json); - } - catch - { - } - } - - private static async Task LoadStoredIdAsync() - { - try - { - if (!File.Exists(StatePath)) - return null; - var json = await File.ReadAllTextAsync(StatePath); - using var doc = JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("ActiveGroupId", out var el) - && el.TryGetGuid(out var id)) - return id; - } - catch - { - } - return null; - } -} diff --git a/src/NexusCad.Admin/Services/CurrentWorkspaceService.cs b/src/NexusCad.Admin/Services/CurrentWorkspaceService.cs new file mode 100644 index 0000000..f43fcc7 --- /dev/null +++ b/src/NexusCad.Admin/Services/CurrentWorkspaceService.cs @@ -0,0 +1,152 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using System.IO; +using System.Text.Json; +using NexusCad.Admin.Models; + +namespace NexusCad.Admin.Services; + +public interface ICurrentWorkspaceService +{ + event EventHandler? CurrentWorkspaceChanged; + + /// + /// Disparado cuando la lista de workspaces conocida cambia (CRUD desde + /// cualquier vista). Los consumidores deben recargar su listado. + /// + event EventHandler? WorkspacesListChanged; + + WorkspaceDto? Current { get; } + + bool HasActiveWorkspace { get; } + + Task SetCurrentAsync(WorkspaceDto? workspace); + + Task RestoreLastAsync(INexusCadApi api); + + /// + /// Notifica a todos los consumidores que la lista de workspaces ha cambiado. + /// Llamar tras create/update/delete de un workspace. + /// + void NotifyWorkspacesListChanged(); +} + +/// +/// Mantiene en memoria el Workspace activo del Admin y persiste su Id en +/// %LocalAppData%/NexusCad/active-workspace.json para restaurarlo en el siguiente arranque. +/// +public sealed class CurrentWorkspaceService : ICurrentWorkspaceService +{ + private static readonly string StatePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "NexusCad", + "active-workspace.json"); + + // Ruta del archivo legacy (renombrado de Group → Workspace en v1.x) + private static readonly string LegacyStatePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "NexusCad", + "active-group.json"); + + public event EventHandler? CurrentWorkspaceChanged; + + public event EventHandler? WorkspacesListChanged; + + public WorkspaceDto? Current { get; private set; } + + public bool HasActiveWorkspace => Current != null; + + public async Task SetCurrentAsync(WorkspaceDto? workspace) + { + Current = workspace; + await PersistAsync(workspace?.Id); + CurrentWorkspaceChanged?.Invoke(this, EventArgs.Empty); + } + + public void NotifyWorkspacesListChanged() + { + WorkspacesListChanged?.Invoke(this, EventArgs.Empty); + } + + public async Task RestoreLastAsync(INexusCadApi api) + { + var storedId = await LoadStoredIdAsync(); + if (storedId == null) + return null; + + try + { + var response = await api.GetWorkspaceByIdAsync(storedId.Value); + if (response.Success && response.Data != null) + { + Current = response.Data; + CurrentWorkspaceChanged?.Invoke(this, EventArgs.Empty); + return Current; + } + } + catch + { + // Si el workspace ya no existe o la API no responde, ignoramos — + // el usuario tendrá que reabrir desde Recent. + } + return null; + } + + private static async Task PersistAsync(Guid? id) + { + try + { + var dir = Path.GetDirectoryName(StatePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + if (id == null) + { + if (File.Exists(StatePath)) + File.Delete(StatePath); + return; + } + + var json = JsonSerializer.Serialize(new { ActiveWorkspaceId = id }); + await File.WriteAllTextAsync(StatePath, json); + } + catch + { + } + } + + private static async Task LoadStoredIdAsync() + { + try + { + // Migración: si existe el archivo legacy (active-group.json), lo leemos + // y lo migramos al nuevo formato (active-workspace.json). + if (!File.Exists(StatePath) && File.Exists(LegacyStatePath)) + { + var legacyJson = await File.ReadAllTextAsync(LegacyStatePath); + using var legacyDoc = JsonDocument.Parse(legacyJson); + if (legacyDoc.RootElement.TryGetProperty("ActiveGroupId", out var legacyEl) + && legacyEl.TryGetGuid(out var legacyId)) + { + // Escribe al nuevo archivo y elimina el legacy + await PersistAsync(legacyId); + try { File.Delete(LegacyStatePath); } catch { } + return legacyId; + } + } + + if (!File.Exists(StatePath)) + return null; + var json = await File.ReadAllTextAsync(StatePath); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("ActiveWorkspaceId", out var el) + && el.TryGetGuid(out var id)) + return id; + } + catch + { + } + return null; + } +} diff --git a/src/NexusCad.Admin/Services/INexusCadApi.cs b/src/NexusCad.Admin/Services/INexusCadApi.cs index 81ef25f..986e861 100644 --- a/src/NexusCad.Admin/Services/INexusCadApi.cs +++ b/src/NexusCad.Admin/Services/INexusCadApi.cs @@ -39,6 +39,14 @@ public interface INexusCadApi [Headers("Authorization: Bearer")] Task> DeleteUserAsync(string id); + [Get("/api/auth/users/{id}/memberships")] + [Headers("Authorization: Bearer")] + Task>> GetUserMembershipsAsync(string id); + + [Put("/api/auth/users/{id}/memberships")] + [Headers("Authorization: Bearer")] + Task>> SetUserMembershipsAsync(string id, [Body] SetUserMembershipsRequest request); + // Projects endpoints [Get("/api/projects")] [Headers("Authorization: Bearer")] @@ -71,30 +79,75 @@ Task> GetProjectsAsync( [Headers("Authorization: Bearer")] Task> DeleteProjectAsync(Guid id); - // Groups endpoints + // Workspaces endpoints [Get("/api/groups")] [Headers("Authorization: Bearer")] - Task>> GetGroupsAsync([Query] bool recent = false); + Task>> GetWorkspacesAsync([Query] bool recent = false); [Get("/api/groups/{id}")] [Headers("Authorization: Bearer")] - Task> GetGroupByIdAsync(Guid id); + Task> GetWorkspaceByIdAsync(Guid id); [Post("/api/groups")] [Headers("Authorization: Bearer")] - Task> CreateGroupAsync([Body] CreateGroupRequest request); + Task> CreateWorkspaceAsync([Body] CreateWorkspaceRequest request); [Put("/api/groups/{id}")] [Headers("Authorization: Bearer")] - Task> UpdateGroupAsync(Guid id, [Body] UpdateGroupRequest request); + Task> UpdateWorkspaceAsync(Guid id, [Body] UpdateWorkspaceRequest request); // El endpoint devuelve 204 NoContent — Refit no puede deserializarlo a un // NexusCadApiResponse, así que el método retorna Task vacío. Refit lanza - // ApiException si el status no es 2xx (p. ej. 409 si el grupo tiene proyectos + // ApiException si el status no es 2xx (p. ej. 409 si el workspace tiene proyectos // y no se pasó force=true). [Delete("/api/groups/{id}")] [Headers("Authorization: Bearer")] - Task DeleteGroupAsync(Guid id, [Query] bool force = false); + Task DeleteWorkspaceAsync(Guid id, [Query] bool force = false); + + [Get("/api/groups/{id}/project-access")] + [Headers("Authorization: Bearer")] + Task>> GetWorkspaceProjectAccessAsync(Guid id); + + [Put("/api/groups/{id}/project-access")] + [Headers("Authorization: Bearer")] + Task>> SetWorkspaceProjectAccessAsync(Guid id, [Body] SetWorkspaceProjectAccessRequest request); + + // UserGroups endpoints (grupos personalizados de usuarios — distintos de Workspace y de roles) + [Get("/api/usergroups")] + [Headers("Authorization: Bearer")] + Task>> GetUserGroupsAsync(); + + [Get("/api/usergroups/{id}")] + [Headers("Authorization: Bearer")] + Task> GetUserGroupByIdAsync(Guid id); + + [Post("/api/usergroups")] + [Headers("Authorization: Bearer")] + Task> CreateUserGroupAsync([Body] CreateUserGroupRequest request); + + [Put("/api/usergroups/{id}")] + [Headers("Authorization: Bearer")] + Task> UpdateUserGroupAsync(Guid id, [Body] UpdateUserGroupRequest request); + + [Delete("/api/usergroups/{id}")] + [Headers("Authorization: Bearer")] + Task DeleteUserGroupAsync(Guid id); + + [Get("/api/usergroups/{id}/members")] + [Headers("Authorization: Bearer")] + Task>> GetUserGroupMembersAsync(Guid id); + + [Put("/api/usergroups/{id}/members")] + [Headers("Authorization: Bearer")] + Task>> SetUserGroupMembersAsync(Guid id, [Body] SetUserGroupMembersRequest request); + + [Get("/api/usergroups/{id}/projects")] + [Headers("Authorization: Bearer")] + Task>> GetUserGroupProjectsAsync(Guid id); + + [Put("/api/usergroups/{id}/projects")] + [Headers("Authorization: Bearer")] + Task>> SetUserGroupProjectsAsync(Guid id, [Body] SetUserGroupProjectsRequest request); // LookupTables endpoints [Get("/api/lookuptables")] diff --git a/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs new file mode 100644 index 0000000..5f0b7b6 --- /dev/null +++ b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs @@ -0,0 +1,118 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace NexusCad.Admin.ViewModels; + +/// +/// Item de usuario en el dialog de Group: el usuario completo + checkbox de pertenencia. +/// +public class GroupUserItem : INotifyPropertyChanged +{ + private bool _isMember; + + public string UserId { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + + public bool IsMember + { + get => _isMember; + set { _isMember = value; OnPropertyChanged(); } + } + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} + +/// +/// Item de proyecto en el dialog de Group: el proyecto + checkbox de acceso. +/// +public class GroupProjectItem : INotifyPropertyChanged +{ + private bool _hasAccess; + + public Guid ProjectId { get; set; } + public string ProjectCode { get; set; } = string.Empty; + public string ProjectName { get; set; } = string.Empty; + + public bool HasAccess + { + get => _hasAccess; + set { _hasAccess = value; OnPropertyChanged(); } + } + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} + +public class GroupEditDialogViewModel : INotifyPropertyChanged +{ + private string _name = string.Empty; + private string _description = string.Empty; + private string _code = string.Empty; + private bool _isEditMode; + + public bool IsEditMode + { + get => _isEditMode; + set + { + _isEditMode = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(Title)); + OnPropertyChanged(nameof(IsCodeReadOnly)); + } + } + + public bool IsCodeReadOnly => IsEditMode; + + public string Title => IsEditMode ? $"Editar grupo · {Code}" : "Nuevo grupo"; + + public string Code + { + get => _code; + set + { + _code = (value ?? string.Empty).ToLowerInvariant().Replace(" ", "-"); + OnPropertyChanged(); + OnPropertyChanged(nameof(Title)); + } + } + + public string Name + { + get => _name; + set { _name = value; OnPropertyChanged(); } + } + + public string Description + { + get => _description; + set { _description = value; OnPropertyChanged(); } + } + + /// Todos los usuarios del sistema con su estado de pertenencia al grupo. + public ObservableCollection UserItems { get; } = new(); + + /// Todos los proyectos del sistema con su estado de acceso para este grupo. + public ObservableCollection ProjectItems { get; } = new(); + + public bool IsValid() + { + if (string.IsNullOrWhiteSpace(Name)) + return false; + if (!IsEditMode && string.IsNullOrWhiteSpace(Code)) + return false; + return true; + } + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/src/NexusCad.Admin/ViewModels/MainWindowViewModel.cs b/src/NexusCad.Admin/ViewModels/MainWindowViewModel.cs index e2ff90a..2e62aa3 100644 --- a/src/NexusCad.Admin/ViewModels/MainWindowViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/MainWindowViewModel.cs @@ -15,7 +15,7 @@ public partial class MainWindowViewModel : ObservableObject { private readonly INavigationService _navigationService; private readonly IServiceProvider _serviceProvider; - private readonly ICurrentGroupService _currentGroupService; + private readonly ICurrentWorkspaceService _currentWorkspaceService; private readonly INexusCadApi _api; [ObservableProperty] @@ -28,7 +28,7 @@ public partial class MainWindowViewModel : ObservableObject private string version = "v0.1.0-phase5"; [ObservableProperty] - private string currentGroup = "(none)"; + private string currentWorkspace = "(none)"; [ObservableProperty] private string currentProject = "(none)"; @@ -57,49 +57,49 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(CreateProjectCommand))] [NotifyCanExecuteChangedFor(nameof(OpenProjectCommand))] - [NotifyCanExecuteChangedFor(nameof(CloseGroupCommand))] + [NotifyCanExecuteChangedFor(nameof(CloseWorkspaceCommand))] private bool isTreeEnabled; /// - /// Grupo seleccionado en el panel central. Al asignarse, lo activa como - /// CurrentGroup y carga sus proyectos en el panel Projects. + /// Workspace seleccionado en el panel central. Al asignarse, lo activa como + /// CurrentWorkspace y carga sus proyectos en el panel Projects. /// [ObservableProperty] - private GroupListItemDto? selectedGroup; + private WorkspaceListItemDto? selectedWorkspace; [ObservableProperty] private ProjectListItemDto? selectedProject; public ObservableCollection Stages { get; } - public ObservableCollection Groups { get; } = new(); + public ObservableCollection Workspaces { get; } = new(); public ObservableCollection Projects { get; } = new(); public MainWindowViewModel( INavigationService navigationService, IServiceProvider serviceProvider, - ICurrentGroupService currentGroupService, + ICurrentWorkspaceService currentWorkspaceService, INexusCadApi api) { _navigationService = navigationService; _serviceProvider = serviceProvider; - _currentGroupService = currentGroupService; + _currentWorkspaceService = currentWorkspaceService; _api = api; _navigationService.CurrentViewChanged += OnNavigationViewChanged; - _currentGroupService.CurrentGroupChanged += OnCurrentGroupChanged; + _currentWorkspaceService.CurrentWorkspaceChanged += OnCurrentWorkspaceChanged; + _currentWorkspaceService.WorkspacesListChanged += OnWorkspacesListChanged; Stages = new ObservableCollection { new("Home", "house"), new("Help", "help"), - new( - "Stage 1: Group Setup", + new("Stage 1: Workspace Setup", "group", new[] { new StageNode("Security Settings", "shield"), - new StageNode("Group Tables", "table"), + new StageNode("Workspace Tables", "table"), new StageNode("Autopilot Settings", "cog"), new StageNode("Published Apps", "application_xp_terminal"), }, @@ -133,10 +133,11 @@ public MainWindowViewModel( /// public async Task InitializeAsync() { - await _currentGroupService.RestoreLastAsync(_api); - ApplyCurrentGroup(); - await RefreshGroupsAsync(); + await _currentWorkspaceService.RestoreLastAsync(_api); + ApplyCurrentWorkspace(); + await RefreshWorkspacesAsync(); await RefreshProjectsAsync(); + NavigateToNode("Home"); } private void OnNavigationViewChanged(object? sender, object? newView) @@ -144,15 +145,22 @@ private void OnNavigationViewChanged(object? sender, object? newView) CurrentView = newView; } - private void OnCurrentGroupChanged(object? sender, EventArgs e) + private void OnCurrentWorkspaceChanged(object? sender, EventArgs e) + { + ApplyCurrentWorkspace(); + } + + private async void OnWorkspacesListChanged(object? sender, EventArgs e) { - ApplyCurrentGroup(); + // Otro VM (SecuritySettings, etc.) ha creado/editado/borrado un workspace. + // Refrescamos la lista del sidebar para mantenerla coherente. + await RefreshWorkspacesAsync(); } - private void ApplyCurrentGroup() + private void ApplyCurrentWorkspace() { - var g = _currentGroupService.Current; - CurrentGroup = g?.Name ?? "(none)"; + var g = _currentWorkspaceService.Current; + CurrentWorkspace = g?.Name ?? "(none)"; IsTreeEnabled = g != null; if (g == null) { @@ -160,72 +168,72 @@ private void ApplyCurrentGroup() SelectedNode = null; Projects.Clear(); } - // Mantener la selección visual en la lista de grupos coherente con - // el grupo activo real. - var match = g == null ? null : Groups.FirstOrDefault(x => x.Id == g.Id); - if (!ReferenceEquals(SelectedGroup, match)) + // Mantener la selección visual en la lista de workspaces coherente con + // el workspace activo real. + var match = g == null ? null : Workspaces.FirstOrDefault(x => x.Id == g.Id); + if (!ReferenceEquals(SelectedWorkspace, match)) { - _suppressGroupSelectionEvent = true; - SelectedGroup = match; - _suppressGroupSelectionEvent = false; + _suppressWorkspaceSelectionEvent = true; + SelectedWorkspace = match; + _suppressWorkspaceSelectionEvent = false; } System.Windows.Input.CommandManager.InvalidateRequerySuggested(); } - private bool _suppressGroupSelectionEvent; + private bool _suppressWorkspaceSelectionEvent; - partial void OnSelectedGroupChanged(GroupListItemDto? value) + partial void OnSelectedWorkspaceChanged(WorkspaceListItemDto? value) { - if (_suppressGroupSelectionEvent) + if (_suppressWorkspaceSelectionEvent) return; - _ = ActivateGroupAsync(value); + _ = ActivateWorkspaceAsync(value); } - private async Task ActivateGroupAsync(GroupListItemDto? listItem) + private async Task ActivateWorkspaceAsync(WorkspaceListItemDto? listItem) { if (listItem == null) { - await _currentGroupService.SetCurrentAsync(null); - ApplyCurrentGroup(); + await _currentWorkspaceService.SetCurrentAsync(null); + ApplyCurrentWorkspace(); CurrentProject = "(none)"; return; } try { - var response = await _api.GetGroupByIdAsync(listItem.Id); + var response = await _api.GetWorkspaceByIdAsync(listItem.Id); if (response.Success && response.Data != null) { - await _currentGroupService.SetCurrentAsync(response.Data); - ApplyCurrentGroup(); + await _currentWorkspaceService.SetCurrentAsync(response.Data); + ApplyCurrentWorkspace(); await RefreshProjectsAsync(); } } catch (Refit.ApiException apiEx) { MessageBox.Show($"API error ({(int)apiEx.StatusCode}): {apiEx.Content ?? apiEx.Message}", - "Open Group", MessageBoxButton.OK, MessageBoxImage.Error); + "Open Workspace", MessageBoxButton.OK, MessageBoxImage.Error); } } - private async Task RefreshGroupsAsync() + private async Task RefreshWorkspacesAsync() { try { - var response = await _api.GetGroupsAsync(recent: false); + var response = await _api.GetWorkspacesAsync(recent: false); if (!response.Success || response.Data == null) return; - Groups.Clear(); + Workspaces.Clear(); foreach (var g in response.Data.OrderBy(g => g.Name)) - Groups.Add(g); + Workspaces.Add(g); - // Resincroniza la selección visual con el grupo activo tras recargar. - var active = _currentGroupService.Current; + // Resincroniza la selección visual con el workspace activo tras recargar. + var active = _currentWorkspaceService.Current; if (active != null) { - _suppressGroupSelectionEvent = true; - SelectedGroup = Groups.FirstOrDefault(x => x.Id == active.Id); - _suppressGroupSelectionEvent = false; + _suppressWorkspaceSelectionEvent = true; + SelectedWorkspace = Workspaces.FirstOrDefault(x => x.Id == active.Id); + _suppressWorkspaceSelectionEvent = false; } } catch @@ -237,7 +245,7 @@ private async Task RefreshGroupsAsync() private async Task RefreshProjectsAsync() { Projects.Clear(); - var active = _currentGroupService.Current; + var active = _currentWorkspaceService.Current; if (active == null) return; @@ -270,13 +278,17 @@ private void NavigateToNode(string nodeName) { switch (nodeName) { + case "Home": + _navigationService.NavigateTo(new Views.HomeView()); + break; + case "Security Settings": var securityVm = _serviceProvider.GetRequiredService(); var securityView = new Views.SecuritySettingsView { DataContext = securityVm }; _navigationService.NavigateTo(securityView); break; - case "Group Tables": + case "Workspace Tables": var lookupVm = _serviceProvider.GetRequiredService(); var lookupView = new Views.LookupTablesView { DataContext = lookupVm }; _navigationService.NavigateTo(lookupView); @@ -343,142 +355,145 @@ private void NavigateToNode(string nodeName) } [RelayCommand] - private async Task NewGroup() + private async Task NewWorkspace() { - var dialogVm = _serviceProvider.GetRequiredService(); + var dialogVm = _serviceProvider.GetRequiredService(); var owner = Application.Current.MainWindow; - var created = Views.NewGroupDialog.ShowDialog(owner, dialogVm); + var created = Views.NewWorkspaceDialog.ShowDialog(owner, dialogVm); if (created != null) { - await _currentGroupService.SetCurrentAsync(created); - await RefreshGroupsAsync(); - ApplyCurrentGroup(); + await _currentWorkspaceService.SetCurrentAsync(created); + await RefreshWorkspacesAsync(); + ApplyCurrentWorkspace(); await RefreshProjectsAsync(); + _currentWorkspaceService.NotifyWorkspacesListChanged(); } } [RelayCommand] - private async Task OpenGroup() + private async Task OpenWorkspace() { - var dialogVm = _serviceProvider.GetRequiredService(); + var dialogVm = _serviceProvider.GetRequiredService(); var owner = Application.Current.MainWindow; - var opened = Views.OpenGroupDialog.ShowDialog(owner, dialogVm); + var opened = Views.OpenWorkspaceDialog.ShowDialog(owner, dialogVm); if (opened != null) { - await _currentGroupService.SetCurrentAsync(opened); - await RefreshGroupsAsync(); - ApplyCurrentGroup(); + await _currentWorkspaceService.SetCurrentAsync(opened); + await RefreshWorkspacesAsync(); + ApplyCurrentWorkspace(); await RefreshProjectsAsync(); } } - [RelayCommand(CanExecute = nameof(CanCloseGroup))] - private async Task CloseGroup() + [RelayCommand(CanExecute = nameof(CanCloseWorkspace))] + private async Task CloseWorkspace() { - await _currentGroupService.SetCurrentAsync(null); - ApplyCurrentGroup(); + await _currentWorkspaceService.SetCurrentAsync(null); + ApplyCurrentWorkspace(); CurrentProject = "(none)"; } - private bool CanCloseGroup() => IsTreeEnabled; + private bool CanCloseWorkspace() => IsTreeEnabled; [RelayCommand] - private async Task DeleteGroup(GroupListItemDto? item) + private async Task DeleteWorkspace(WorkspaceListItemDto? item) { if (item == null) return; var confirm = MessageBox.Show( - $"Delete group '{item.Name}'? This cannot be undone.\n\nThe API will reject the delete if the group still contains projects.", - "Delete Group", + $"Delete workspace '{item.Name}'? This cannot be undone.\n\nThe API will reject the delete if the workspace still contains projects.", + "Delete Workspace", MessageBoxButton.YesNo, MessageBoxImage.Warning); if (confirm != MessageBoxResult.Yes) return; - await TryDeleteGroupAsync(item.Id, item.Name, force: false); + await TryDeleteWorkspaceAsync(item.Id, item.Name, force: false); } [RelayCommand] - private async Task RenameGroup(GroupListItemDto? item) + private async Task RenameWorkspace(WorkspaceListItemDto? item) { if (item == null) return; - var input = Views.RenameDialog.Show("Rename Group", $"New name for group '{item.Name}':", item.Name); + var input = Views.RenameDialog.Show("Rename Workspace", $"New name for workspace '{item.Name}':", item.Name); if (string.IsNullOrWhiteSpace(input) || input == item.Name) return; try { - var request = new Models.UpdateGroupRequest { Name = input.Trim(), Description = item.Description }; - await _api.UpdateGroupAsync(item.Id, request); + var request = new Models.UpdateWorkspaceRequest { Name = input.Trim(), Description = item.Description }; + await _api.UpdateWorkspaceAsync(item.Id, request); - if (_currentGroupService.Current?.Id == item.Id) + if (_currentWorkspaceService.Current?.Id == item.Id) { - _currentGroupService.Current.Name = input.Trim(); - ApplyCurrentGroup(); + _currentWorkspaceService.Current.Name = input.Trim(); + ApplyCurrentWorkspace(); } - await RefreshGroupsAsync(); + await RefreshWorkspacesAsync(); + _currentWorkspaceService.NotifyWorkspacesListChanged(); } catch (Refit.ApiException apiEx) { MessageBox.Show($"API error ({(int)apiEx.StatusCode}): {apiEx.Content ?? apiEx.Message}", - "Rename Group", MessageBoxButton.OK, MessageBoxImage.Error); + "Rename Workspace", MessageBoxButton.OK, MessageBoxImage.Error); } catch (Exception ex) { - MessageBox.Show(ex.Message, "Rename Group", MessageBoxButton.OK, MessageBoxImage.Error); + MessageBox.Show(ex.Message, "Rename Workspace", MessageBoxButton.OK, MessageBoxImage.Error); } } - private async Task TryDeleteGroupAsync(Guid groupId, string label, bool force) + private async Task TryDeleteWorkspaceAsync(Guid workspaceId, string label, bool force) { try { - await _api.DeleteGroupAsync(groupId, force); + await _api.DeleteWorkspaceAsync(workspaceId, force); - if (_currentGroupService.Current?.Id == groupId) + if (_currentWorkspaceService.Current?.Id == workspaceId) { - await _currentGroupService.SetCurrentAsync(null); + await _currentWorkspaceService.SetCurrentAsync(null); CurrentProject = "(none)"; } - await RefreshGroupsAsync(); - ApplyCurrentGroup(); + await RefreshWorkspacesAsync(); + ApplyCurrentWorkspace(); await RefreshProjectsAsync(); + _currentWorkspaceService.NotifyWorkspacesListChanged(); } catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode == 409 && !force) { var confirm = MessageBox.Show( - $"{apiEx.Content}\n\nForce-delete the group and all its projects, specifications and generated documents? This cannot be undone.", - "Delete Group — Force", + $"{apiEx.Content}\n\nForce-delete the workspace and all its projects, specifications and generated documents? This cannot be undone.", + "Delete Workspace — Force", MessageBoxButton.YesNo, MessageBoxImage.Warning); if (confirm == MessageBoxResult.Yes) { - await TryDeleteGroupAsync(groupId, label, force: true); + await TryDeleteWorkspaceAsync(workspaceId, label, force: true); } } catch (Refit.ApiException apiEx) { MessageBox.Show($"API error ({(int)apiEx.StatusCode}): {apiEx.Content ?? apiEx.Message}", - "Delete Group", MessageBoxButton.OK, MessageBoxImage.Error); + "Delete Workspace", MessageBoxButton.OK, MessageBoxImage.Error); } catch (Exception ex) { - MessageBox.Show(ex.Message, "Delete Group", MessageBoxButton.OK, MessageBoxImage.Error); + MessageBox.Show(ex.Message, "Delete Workspace", MessageBoxButton.OK, MessageBoxImage.Error); } } [RelayCommand(CanExecute = nameof(CanCreateProject))] private async Task CreateProject() { - var current = _currentGroupService.Current; + var current = _currentWorkspaceService.Current; if (current == null) { - MessageBox.Show("Open or create a group first.", "Create Project", + MessageBox.Show("Open or create a workspace first.", "Create Project", MessageBoxButton.OK, MessageBoxImage.Information); return; } @@ -542,10 +557,10 @@ private async Task CreateProject() [RelayCommand(CanExecute = nameof(CanCreateProject))] private async Task OpenProject() { - var current = _currentGroupService.Current; + var current = _currentWorkspaceService.Current; if (current == null) { - MessageBox.Show("Open or create a group first.", "Open Project", + MessageBox.Show("Open or create a workspace first.", "Open Project", MessageBoxButton.OK, MessageBoxImage.Information); return; } diff --git a/src/NexusCad.Admin/ViewModels/NewGroupDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/NewWorkspaceDialogViewModel.cs similarity index 72% rename from src/NexusCad.Admin/ViewModels/NewGroupDialogViewModel.cs rename to src/NexusCad.Admin/ViewModels/NewWorkspaceDialogViewModel.cs index 149bbc0..5b418a6 100644 --- a/src/NexusCad.Admin/ViewModels/NewGroupDialogViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/NewWorkspaceDialogViewModel.cs @@ -12,7 +12,7 @@ namespace NexusCad.Admin.ViewModels; -public partial class NewGroupDialogViewModel : ObservableValidator +public partial class NewWorkspaceDialogViewModel : ObservableValidator { private readonly INexusCadApi _api; @@ -22,19 +22,19 @@ public partial class NewGroupDialogViewModel : ObservableValidator ErrorMessage = "Lowercase letters, digits and hyphens only (e.g. configurador-itecam)")] [NotifyDataErrorInfo] [NotifyCanExecuteChangedFor(nameof(CreateCommand))] - private string _groupCode = string.Empty; + private string _workspaceCode = string.Empty; [ObservableProperty] [Required(ErrorMessage = "Name is required")] [StringLength(200, MinimumLength = 3)] [NotifyDataErrorInfo] [NotifyCanExecuteChangedFor(nameof(CreateCommand))] - private string _groupName = string.Empty; + private string _workspaceName = string.Empty; [ObservableProperty] [StringLength(1000)] [NotifyDataErrorInfo] - private string? _groupDescription; + private string? _workspaceDescription; [ObservableProperty] [StringLength(500)] @@ -50,18 +50,18 @@ public partial class NewGroupDialogViewModel : ObservableValidator [ObservableProperty] private bool _isBusy; - public GroupDto? CreatedGroup { get; private set; } + public WorkspaceDto? CreatedWorkspace { get; private set; } public bool DialogResult { get; private set; } - public NewGroupDialogViewModel(INexusCadApi api) + public NewWorkspaceDialogViewModel(INexusCadApi api) { _api = api; PropertyChanged += (_, e) => { - if (e.PropertyName == nameof(GroupCode) - || e.PropertyName == nameof(GroupName) - || e.PropertyName == nameof(GroupDescription)) + if (e.PropertyName == nameof(WorkspaceCode) + || e.PropertyName == nameof(WorkspaceName) + || e.PropertyName == nameof(WorkspaceDescription)) { ValidateAllProperties(); } @@ -71,11 +71,9 @@ public NewGroupDialogViewModel(INexusCadApi api) [RelayCommand] private void BrowseWorkspace() { - // OpenFileDialog en modo "selección de carpeta" — usamos un truco común: - // pedir un archivo dummy y quedarnos con su carpeta. var dlg = new OpenFolderDialog { - Title = "Select the workspace folder for this group", + Title = "Select the workspace folder for this workspace", }; if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.FolderName)) { @@ -94,25 +92,25 @@ private async Task CreateAsync() ErrorMessage = null; try { - var request = new CreateGroupRequest + var request = new CreateWorkspaceRequest { - Code = GroupCode.Trim().ToLowerInvariant(), - Name = GroupName.Trim(), - Description = string.IsNullOrWhiteSpace(GroupDescription) ? null : GroupDescription.Trim(), + Code = WorkspaceCode.Trim().ToLowerInvariant(), + Name = WorkspaceName.Trim(), + Description = string.IsNullOrWhiteSpace(WorkspaceDescription) ? null : WorkspaceDescription.Trim(), WorkspacePath = string.IsNullOrWhiteSpace(WorkspacePath) ? null : WorkspacePath.Trim(), IconUrl = string.IsNullOrWhiteSpace(IconUrl) ? null : IconUrl.Trim(), }; - var response = await _api.CreateGroupAsync(request); + var response = await _api.CreateWorkspaceAsync(request); if (response.Success && response.Data != null) { - CreatedGroup = response.Data; + CreatedWorkspace = response.Data; DialogResult = true; OnPropertyChanged(nameof(DialogResult)); } else { - ErrorMessage = response.Message ?? "Failed to create group"; + ErrorMessage = response.Message ?? "Failed to create workspace"; } } catch (ApiException apiEx) @@ -131,8 +129,8 @@ private async Task CreateAsync() private bool CanCreate() => !IsBusy - && !string.IsNullOrWhiteSpace(GroupCode) - && !string.IsNullOrWhiteSpace(GroupName) + && !string.IsNullOrWhiteSpace(WorkspaceCode) + && !string.IsNullOrWhiteSpace(WorkspaceName) && !HasErrors; [RelayCommand] diff --git a/src/NexusCad.Admin/ViewModels/OpenProjectDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/OpenProjectDialogViewModel.cs index 0342c5b..5abfae8 100644 --- a/src/NexusCad.Admin/ViewModels/OpenProjectDialogViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/OpenProjectDialogViewModel.cs @@ -14,7 +14,7 @@ namespace NexusCad.Admin.ViewModels; public partial class OpenProjectDialogViewModel : ObservableObject { private readonly INexusCadApi _api; - private readonly ICurrentGroupService _currentGroupService; + private readonly ICurrentWorkspaceService _currentWorkspaceService; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(OpenCommand))] @@ -32,10 +32,10 @@ public partial class OpenProjectDialogViewModel : ObservableObject public bool DialogResult { get; private set; } - public OpenProjectDialogViewModel(INexusCadApi api, ICurrentGroupService currentGroupService) + public OpenProjectDialogViewModel(INexusCadApi api, ICurrentWorkspaceService currentWorkspaceService) { _api = api; - _currentGroupService = currentGroupService; + _currentWorkspaceService = currentWorkspaceService; } public async Task LoadAsync() @@ -44,14 +44,14 @@ public async Task LoadAsync() ErrorMessage = null; try { - var currentGroup = _currentGroupService.Current; - if (currentGroup == null) + var currentWorkspace = _currentWorkspaceService.Current; + if (currentWorkspace == null) { - ErrorMessage = "No group is currently active. Please open a group first."; + ErrorMessage = "No workspace is currently active. Please open a workspace first."; return; } - var response = await _api.GetProjectsAsync(groupId: currentGroup.Id, pageSize: 200); + var response = await _api.GetProjectsAsync(groupId: currentWorkspace.Id, pageSize: 200); if (!response.Success || response.Data?.Items == null) { ErrorMessage = response.Message ?? "Failed to load projects"; diff --git a/src/NexusCad.Admin/ViewModels/OpenGroupDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/OpenWorkspaceDialogViewModel.cs similarity index 64% rename from src/NexusCad.Admin/ViewModels/OpenGroupDialogViewModel.cs rename to src/NexusCad.Admin/ViewModels/OpenWorkspaceDialogViewModel.cs index 7d11a00..94517ef 100644 --- a/src/NexusCad.Admin/ViewModels/OpenGroupDialogViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/OpenWorkspaceDialogViewModel.cs @@ -11,13 +11,13 @@ namespace NexusCad.Admin.ViewModels; -public partial class OpenGroupDialogViewModel : ObservableObject +public partial class OpenWorkspaceDialogViewModel : ObservableObject { private readonly INexusCadApi _api; [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(OpenCommand))] - private GroupListItemDto? selectedGroup; + private WorkspaceListItemDto? selectedWorkspace; [ObservableProperty] private bool isLoading; @@ -25,13 +25,13 @@ public partial class OpenGroupDialogViewModel : ObservableObject [ObservableProperty] private string? errorMessage; - public ObservableCollection Groups { get; } = new(); + public ObservableCollection Workspaces { get; } = new(); - public GroupDto? OpenedGroup { get; private set; } + public WorkspaceDto? OpenedWorkspace { get; private set; } public bool DialogResult { get; private set; } - public OpenGroupDialogViewModel(INexusCadApi api) + public OpenWorkspaceDialogViewModel(INexusCadApi api) { _api = api; } @@ -42,15 +42,15 @@ public async Task LoadAsync() ErrorMessage = null; try { - var response = await _api.GetGroupsAsync(recent: false); + var response = await _api.GetWorkspacesAsync(recent: false); if (!response.Success || response.Data == null) { - ErrorMessage = response.Message ?? "Failed to load groups"; + ErrorMessage = response.Message ?? "Failed to load workspaces"; return; } - Groups.Clear(); - foreach (var g in response.Data) - Groups.Add(g); + Workspaces.Clear(); + foreach (var w in response.Data) + Workspaces.Add(w); } catch (Exception ex) { @@ -65,22 +65,22 @@ public async Task LoadAsync() [RelayCommand(CanExecute = nameof(CanOpen))] private async Task OpenAsync() { - if (SelectedGroup == null) + if (SelectedWorkspace == null) return; IsLoading = true; ErrorMessage = null; try { - var response = await _api.GetGroupByIdAsync(SelectedGroup.Id); + var response = await _api.GetWorkspaceByIdAsync(SelectedWorkspace.Id); if (response.Success && response.Data != null) { - OpenedGroup = response.Data; + OpenedWorkspace = response.Data; DialogResult = true; OnPropertyChanged(nameof(DialogResult)); } else { - ErrorMessage = response.Message ?? "Failed to open group"; + ErrorMessage = response.Message ?? "Failed to open workspace"; } } catch (Exception ex) @@ -93,47 +93,47 @@ private async Task OpenAsync() } } - private bool CanOpen() => SelectedGroup != null && !IsLoading; + private bool CanOpen() => SelectedWorkspace != null && !IsLoading; [RelayCommand] - private async Task DeleteAsync(GroupListItemDto? group) + private async Task DeleteAsync(WorkspaceListItemDto? workspace) { - if (group == null) + if (workspace == null) return; var confirm = MessageBox.Show( - $"Delete group '{group.Name}'? This cannot be undone.\n\nThe API will reject the delete if the group still contains projects.", - "Delete Group", + $"Delete workspace '{workspace.Name}'? This cannot be undone.\n\nThe API will reject the delete if the workspace still contains projects.", + "Delete Workspace", MessageBoxButton.YesNo, MessageBoxImage.Warning); if (confirm != MessageBoxResult.Yes) return; - await TryDeleteAsync(group, force: false); + await TryDeleteAsync(workspace, force: false); } - private async Task TryDeleteAsync(GroupListItemDto group, bool force) + private async Task TryDeleteAsync(WorkspaceListItemDto workspace, bool force) { IsLoading = true; ErrorMessage = null; try { - await _api.DeleteGroupAsync(group.Id, force); - Groups.Remove(group); - if (SelectedGroup?.Id == group.Id) - SelectedGroup = null; + await _api.DeleteWorkspaceAsync(workspace.Id, force); + Workspaces.Remove(workspace); + if (SelectedWorkspace?.Id == workspace.Id) + SelectedWorkspace = null; } catch (ApiException apiEx) when ((int)apiEx.StatusCode == 409 && !force) { IsLoading = false; var confirm = MessageBox.Show( - $"{apiEx.Content}\n\nForce-delete the group and all its projects?", - "Delete Group — Force", + $"{apiEx.Content}\n\nForce-delete the workspace and all its projects?", + "Delete Workspace — Force", MessageBoxButton.YesNo, MessageBoxImage.Warning); if (confirm == MessageBoxResult.Yes) { - await TryDeleteAsync(group, force: true); + await TryDeleteAsync(workspace, force: true); } return; } diff --git a/src/NexusCad.Admin/ViewModels/ProjectEditorViewModel.cs b/src/NexusCad.Admin/ViewModels/ProjectEditorViewModel.cs index 3cb644a..de6eb9b 100644 --- a/src/NexusCad.Admin/ViewModels/ProjectEditorViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/ProjectEditorViewModel.cs @@ -43,6 +43,19 @@ public string ProjectDescription } } + private string? _modelsPath; + public string? ModelsPath + { + get => _modelsPath; + set + { + if (SetProperty(ref _modelsPath, value)) + { + HasUnsavedChanges = true; + } + } + } + public ObservableCollection Variables { get; set; } = new(); private ProjectVariableDto? _selectedVariable; @@ -127,6 +140,7 @@ public bool IsPublished public ICommand DeleteVariableCommand { get; } public ICommand OpenRulesEditorCommand { get; } public ICommand OpenFormDesignerCommand { get; } + public ICommand BrowseModelsPathCommand { get; } public ProjectEditorViewModel(Services.INexusCadApi api) { @@ -139,6 +153,7 @@ public ProjectEditorViewModel(Services.INexusCadApi api) DeleteVariableCommand = new RelayCommand(DeleteVariable, CanDeleteVariable); OpenRulesEditorCommand = new RelayCommand(OpenRulesEditor); OpenFormDesignerCommand = new RelayCommand(OpenFormDesigner); + BrowseModelsPathCommand = new RelayCommand(BrowseModelsPath); } private async Task LoadProject() @@ -205,7 +220,8 @@ private async Task SaveProject() { Name = ProjectName, Description = ProjectDescription, - FormSchemaJson = FormSchemaJson + FormSchemaJson = FormSchemaJson, + ModelsPath = string.IsNullOrWhiteSpace(ModelsPath) ? null : ModelsPath }; var response = await _api.UpdateProjectAsync(_projectId.Value, request); @@ -300,6 +316,20 @@ private void OpenRulesEditor() ErrorMessage = "Rules editor will be implemented in a future update"; } + private void BrowseModelsPath() + { + var dialog = new Microsoft.Win32.OpenFolderDialog + { + Title = "Seleccionar carpeta de modelos SolidWorks del proyecto", + InitialDirectory = ModelsPath ?? string.Empty + }; + + if (dialog.ShowDialog() == true) + { + ModelsPath = dialog.FolderName; + } + } + private void OpenFormDesigner() { var formDesignerVm = new FormDesignerViewModel(); @@ -364,6 +394,7 @@ public void LoadExistingProject(Models.ProjectDto project) ProjectDescription = project.Description; FormSchemaJson = project.FormSchemaJson ?? "{}"; IsPublished = project.Status == Models.ProjectStatus.Published; + ModelsPath = project.ModelsPath; // TODO: Cargar las variables del proyecto desde la API Variables.Clear(); diff --git a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs index 12521fc..c95bb24 100644 --- a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs @@ -13,12 +13,15 @@ namespace NexusCad.Admin.ViewModels; /// -/// ViewModel for Security Settings view (User management) +/// ViewModel for Security Settings view (User and Group management) /// public partial class SecuritySettingsViewModel : ObservableObject { private readonly INexusCadApi _api; private readonly IAuthService _authService; + private readonly ICurrentWorkspaceService _currentWorkspaceService; + + // ── Users ────────────────────────────────────────────────────────────── [ObservableProperty] private ObservableCollection users = new(); @@ -40,10 +43,64 @@ public partial class SecuritySettingsViewModel : ObservableObject // Available roles public List AvailableRoles { get; } = new() { "Admin", "Autor", "Comercial", "Cliente" }; - public SecuritySettingsViewModel(INexusCadApi api, IAuthService authService) + // ── Workspaces ───────────────────────────────────────────────────────────── + + [ObservableProperty] + private ObservableCollection workspaces = new(); + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(EditWorkspaceCommand))] + [NotifyCanExecuteChangedFor(nameof(DeleteWorkspaceCommand))] + private WorkspaceListItemDto? selectedWorkspace; + + [ObservableProperty] + private bool isWorkspacesLoading; + + [ObservableProperty] + private string? workspacesErrorMessage; + + [ObservableProperty] + private string? workspacesSuccessMessage; + + // ── UserGroups (grupos personalizados de usuarios) ────────────────────── + + [ObservableProperty] + private ObservableCollection userGroups = new(); + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(EditUserGroupCommand))] + [NotifyCanExecuteChangedFor(nameof(DeleteUserGroupCommand))] + private UserGroupListItemDto? selectedUserGroup; + + [ObservableProperty] + private bool isUserGroupsLoading; + + [ObservableProperty] + private string? userGroupsErrorMessage; + + [ObservableProperty] + private string? userGroupsSuccessMessage; + + public SecuritySettingsViewModel( + INexusCadApi api, + IAuthService authService, + ICurrentWorkspaceService currentWorkspaceService) { _api = api; _authService = authService; + _currentWorkspaceService = currentWorkspaceService; + _currentWorkspaceService.WorkspacesListChanged += OnWorkspacesListChanged; + } + + private bool _suppressExternalReload; + + private async void OnWorkspacesListChanged(object? sender, EventArgs e) + { + // Evita re-cargar si el cambio lo originó esta misma vista (LoadWorkspacesAsync + // ya se llamó justo antes de NotifyWorkspacesListChanged). + if (_suppressExternalReload) + return; + await LoadWorkspacesAsync(); } [RelayCommand] @@ -92,14 +149,49 @@ private async Task EditUser() if (SelectedUser == null) return; - var dialogVm = new UserEditDialogViewModel(AvailableRoles) + var dialogVm = new UserEditDialogViewModel { IsEditMode = true, FullName = SelectedUser.FullName, IsActive = SelectedUser.IsActive, - SelectedRoles = new ObservableCollection(SelectedUser.Roles) }; + // Load all user-groups and current memberships to populate the groups tab + var allUserGroups = new List(); + var currentMemberships = new List(); + string? groupsLoadError = null; + try + { + var groupsResponse = await _api.GetUserGroupsAsync(); + if (groupsResponse.Success && groupsResponse.Data != null) + allUserGroups = groupsResponse.Data; + else + groupsLoadError = $"No se pudieron cargar los grupos: {groupsResponse.Message}"; + + var membResponse = await _api.GetUserMembershipsAsync(SelectedUser.Id); + if (membResponse.Success && membResponse.Data != null) + currentMemberships = membResponse.Data; + } + catch (Exception ex) + { + groupsLoadError = $"Error cargando grupos: {ex.Message}"; + } + + foreach (var g in allUserGroups) + { + var existing = currentMemberships.FirstOrDefault(m => m.GroupId == g.Id); + dialogVm.GroupItems.Add(new GroupMembershipItem + { + GroupId = g.Id, + GroupCode = g.Code, + GroupName = g.Name, + HasAccess = existing != null, + }); + } + + if (groupsLoadError != null) + ErrorMessage = groupsLoadError; + var owner = Application.Current.MainWindow; var confirmed = UserEditDialog.ShowEditDialog(owner!, dialogVm); if (!confirmed) @@ -111,11 +203,11 @@ private async Task EditUser() ErrorMessage = null; SuccessMessage = null; + // Update basic user data var request = new UpdateUserRequest { FullName = dialogVm.FullName, IsActive = dialogVm.IsActive, - Roles = dialogVm.SelectedRoles.ToList(), NewPassword = string.IsNullOrWhiteSpace(dialogVm.Password) ? null : dialogVm.Password }; @@ -125,7 +217,26 @@ private async Task EditUser() var idx = Users.IndexOf(SelectedUser); Users[idx] = response.Data; SelectedUser = response.Data; - SuccessMessage = "User updated successfully"; + + // Save group memberships + var membRequest = new SetUserMembershipsRequest + { + Memberships = dialogVm.GroupItems + .Where(g => g.HasAccess) + .Select(g => new MembershipItem { GroupId = g.GroupId }) + .ToList() + }; + try + { + await _api.SetUserMembershipsAsync(SelectedUser.Id, membRequest); + } + catch (Exception membEx) + { + ErrorMessage = $"Usuario actualizado pero error al guardar acceso a grupos: {membEx.Message}"; + return; + } + + SuccessMessage = "Usuario actualizado correctamente"; } else { @@ -154,7 +265,15 @@ private async Task DeleteUser() if (SelectedUser == null) return; - // TODO: Show confirmation dialog + var result = MessageBox.Show( + $"¿Estás seguro de que quieres eliminar el usuario '{SelectedUser.FullName}' ({SelectedUser.Email})?\n\nEsta acción no se puede deshacer.", + "Eliminar usuario", + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + + if (result != MessageBoxResult.Yes) + return; + try { IsLoading = true; @@ -214,14 +333,270 @@ private static string ExtractApiError(Refit.ApiException apiEx, string fallback) return $"HTTP {(int)apiEx.StatusCode} {apiEx.StatusCode}"; } + // ── Workspace commands ───────────────────────────────────────────────────── + + [RelayCommand] + private async Task LoadWorkspaces() + { + WorkspacesErrorMessage = null; + WorkspacesSuccessMessage = null; + await LoadWorkspacesAsync(); + } + + public async Task LoadWorkspacesAsync() + { + try + { + IsWorkspacesLoading = true; + var response = await _api.GetWorkspacesAsync(); + if (response.Success && response.Data != null) + { + Workspaces = new ObservableCollection(response.Data); + if (Workspaces.Count == 0) + WorkspacesErrorMessage = "No workspaces found."; + } + else + { + WorkspacesErrorMessage = $"API error: {response.Message ?? "Unknown error"}"; + } + } + catch (Refit.ApiException apiEx) + { + WorkspacesErrorMessage = $"HTTP {(int)apiEx.StatusCode} {apiEx.StatusCode}: {apiEx.Content}"; + } + catch (Exception ex) + { + WorkspacesErrorMessage = $"{ex.GetType().Name}: {ex.Message}"; + } + finally + { + IsWorkspacesLoading = false; + } + } + + [RelayCommand] + private async Task CreateWorkspace() + { + var dialogVm = new NewWorkspaceDialogViewModel(_api); + var owner = Application.Current.MainWindow; + var created = NewWorkspaceDialog.ShowDialog(owner!, dialogVm); + if (created == null) + return; + + WorkspacesSuccessMessage = $"Workspace '{created.Name}' created successfully"; + WorkspacesErrorMessage = null; + await LoadWorkspacesAsync(); + SelectedWorkspace = Workspaces.FirstOrDefault(g => g.Id == created.Id); + _suppressExternalReload = true; + try { _currentWorkspaceService.NotifyWorkspacesListChanged(); } + finally { _suppressExternalReload = false; } + } + + [RelayCommand(CanExecute = nameof(CanEditWorkspace))] + private async Task EditWorkspace() + { + if (SelectedWorkspace == null) + return; + + var dialogVm = new WorkspaceEditDialogViewModel + { + Code = SelectedWorkspace.Code, + Name = SelectedWorkspace.Name, + Description = SelectedWorkspace.Description, + }; + + // Load full workspace to get WorkspacePath and IconUrl + try + { + var full = await _api.GetWorkspaceByIdAsync(SelectedWorkspace.Id); + if (full.Success && full.Data != null) + { + dialogVm.WorkspacePath = full.Data.WorkspacePath; + dialogVm.IconUrl = full.Data.IconUrl; + } + } + catch { /* non-critical */ } + + // Load all projects + current access for this workspace + try + { + var allProjectsResp = await _api.GetProjectsAsync(pageSize: 1000); + var currentAccessResp = await _api.GetWorkspaceProjectAccessAsync(SelectedWorkspace.Id); + + var currentAccess = (currentAccessResp.Success ? currentAccessResp.Data : null) + ?? new List(); + + if (allProjectsResp.Success && allProjectsResp.Data?.Items != null) + { + foreach (var proj in allProjectsResp.Data.Items) + { + var existing = currentAccess.FirstOrDefault(a => a.ProjectId == proj.Id); + dialogVm.ProjectItems.Add(new WorkspaceProjectAccessItem + { + ProjectId = proj.Id, + ProjectCode = proj.Code, + ProjectName = proj.Name, + HasAccess = existing != null, + AccessLevel = existing?.AccessLevel ?? 1, + }); + } + } + } + catch { /* non-critical — proceed without project tab */ } + + var owner = Application.Current.MainWindow; + var confirmed = WorkspaceEditDialog.ShowDialog(owner!, dialogVm); + if (!confirmed) + return; + + try + { + IsWorkspacesLoading = true; + WorkspacesErrorMessage = null; + WorkspacesSuccessMessage = null; + + var request = new UpdateWorkspaceRequest + { + Name = dialogVm.Name, + Description = string.IsNullOrWhiteSpace(dialogVm.Description) ? null : dialogVm.Description, + WorkspacePath = string.IsNullOrWhiteSpace(dialogVm.WorkspacePath) ? null : dialogVm.WorkspacePath, + IconUrl = string.IsNullOrWhiteSpace(dialogVm.IconUrl) ? null : dialogVm.IconUrl, + }; + + var response = await _api.UpdateWorkspaceAsync(SelectedWorkspace.Id, request); + if (response.Success && response.Data != null) + { + // Save project access + var accessRequest = new SetWorkspaceProjectAccessRequest + { + Items = dialogVm.ProjectItems + .Where(p => p.HasAccess) + .Select(p => new WorkspaceProjectAccessEntry + { + ProjectId = p.ProjectId, + AccessLevel = p.AccessLevel, + }) + .ToList() + }; + try { await _api.SetWorkspaceProjectAccessAsync(SelectedWorkspace.Id, accessRequest); } + catch { /* non-critical */ } + + WorkspacesSuccessMessage = "Workspace updated successfully"; + await LoadWorkspacesAsync(); + SelectedWorkspace = Workspaces.FirstOrDefault(g => g.Id == response.Data.Id); + _suppressExternalReload = true; + try { _currentWorkspaceService.NotifyWorkspacesListChanged(); } + finally { _suppressExternalReload = false; } + } + else + { + WorkspacesErrorMessage = FormatApiError(response.Message, response.Errors, "Failed to update workspace"); + } + } + catch (Refit.ApiException apiEx) + { + WorkspacesErrorMessage = ExtractApiError(apiEx, "Failed to update workspace"); + } + catch (Exception ex) + { + WorkspacesErrorMessage = $"Error updating workspace: {ex.Message}"; + } + finally + { + IsWorkspacesLoading = false; + } + } + + private bool CanEditWorkspace() => SelectedWorkspace != null; + + [RelayCommand(CanExecute = nameof(CanDeleteWorkspace))] + private async Task DeleteWorkspace() + { + if (SelectedWorkspace == null) + return; + + var result = MessageBox.Show( + $"Delete workspace '{SelectedWorkspace.Name}'?\n\nThis will fail if the workspace still has projects.", + "Confirm Delete", + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + + if (result != MessageBoxResult.Yes) + return; + + try + { + IsWorkspacesLoading = true; + WorkspacesErrorMessage = null; + WorkspacesSuccessMessage = null; + + await _api.DeleteWorkspaceAsync(SelectedWorkspace.Id); + Workspaces.Remove(SelectedWorkspace); + SelectedWorkspace = null; + WorkspacesSuccessMessage = "Workspace deleted successfully"; + _suppressExternalReload = true; + try { _currentWorkspaceService.NotifyWorkspacesListChanged(); } + finally { _suppressExternalReload = false; } + } + catch (Refit.ApiException apiEx) when (apiEx.StatusCode == System.Net.HttpStatusCode.Conflict) + { + WorkspacesErrorMessage = "Cannot delete: the workspace still has projects. Delete or move them first."; + } + catch (Refit.ApiException apiEx) + { + WorkspacesErrorMessage = ExtractApiError(apiEx, "Failed to delete workspace"); + } + catch (Exception ex) + { + WorkspacesErrorMessage = $"Error deleting workspace: {ex.Message}"; + } + finally + { + IsWorkspacesLoading = false; + } + } + + private bool CanDeleteWorkspace() => SelectedWorkspace != null; + [RelayCommand] private async Task CreateUser() { - var dialogVm = new UserEditDialogViewModel(AvailableRoles) + var dialogVm = new UserEditDialogViewModel { IsEditMode = false }; + // Load user-groups so the groups tab is populated on creation + string? groupsLoadError = null; + try + { + var groupsResponse = await _api.GetUserGroupsAsync(); + if (groupsResponse.Success && groupsResponse.Data != null) + { + foreach (var g in groupsResponse.Data) + { + dialogVm.GroupItems.Add(new GroupMembershipItem + { + GroupId = g.Id, + GroupCode = g.Code, + GroupName = g.Name, + HasAccess = false, + }); + } + } + else + { + groupsLoadError = $"No se pudieron cargar los grupos: {groupsResponse.Message}"; + } + } + catch (Exception ex) + { + groupsLoadError = $"Error cargando grupos: {ex.Message}"; + } + + if (groupsLoadError != null) + ErrorMessage = groupsLoadError; + var owner = Application.Current.MainWindow; var confirmed = UserEditDialog.ShowCreateDialog(owner!, dialogVm); if (!confirmed) @@ -238,12 +613,28 @@ private async Task CreateUser() Email = dialogVm.Email, Password = dialogVm.Password, FullName = dialogVm.FullName, - Role = dialogVm.SelectedRoles.FirstOrDefault() ?? string.Empty }; var response = await _api.RegisterUserAsync(request); if (response.Success && response.Data != null) { + // Save group memberships for the newly created user + var selectedGroups = dialogVm.GroupItems.Where(g => g.HasAccess).ToList(); + if (selectedGroups.Count > 0) + { + try + { + var membRequest = new SetUserMembershipsRequest + { + Memberships = selectedGroups + .Select(g => new MembershipItem { GroupId = g.GroupId }) + .ToList() + }; + await _api.SetUserMembershipsAsync(response.Data.Id, membRequest); + } + catch { /* non-critical */ } + } + SuccessMessage = "User created successfully"; await LoadUsersAsync(); SelectedUser = Users.FirstOrDefault(u => u.Email == request.Email); @@ -259,6 +650,25 @@ private async Task CreateUser() SuccessMessage = "User created successfully"; await LoadUsersAsync(); SelectedUser = Users.FirstOrDefault(u => u.Email == dialogVm.Email); + // Save group memberships + if (SelectedUser != null) + { + var selectedGroups = dialogVm.GroupItems.Where(g => g.HasAccess).ToList(); + if (selectedGroups.Count > 0) + { + try + { + var membRequest = new SetUserMembershipsRequest + { + Memberships = selectedGroups + .Select(g => new MembershipItem { GroupId = g.GroupId }) + .ToList() + }; + await _api.SetUserMembershipsAsync(SelectedUser.Id, membRequest); + } + catch { /* non-critical */ } + } + } } catch (Refit.ApiException apiEx) { @@ -273,4 +683,337 @@ private async Task CreateUser() IsLoading = false; } } + + // ── UserGroup commands ─────────────────────────────────────────────────── + + [RelayCommand] + private async Task LoadUserGroups() + { + UserGroupsErrorMessage = null; + UserGroupsSuccessMessage = null; + await LoadUserGroupsAsync(); + } + + public async Task LoadUserGroupsAsync() + { + try + { + IsUserGroupsLoading = true; + var response = await _api.GetUserGroupsAsync(); + if (response.Success && response.Data != null) + { + UserGroups = new ObservableCollection(response.Data); + if (UserGroups.Count == 0) + UserGroupsErrorMessage = "No groups defined yet. Create one with 'New Group'."; + } + else + { + UserGroupsErrorMessage = $"API error: {response.Message ?? "Unknown error"}"; + } + } + catch (Refit.ApiException apiEx) + { + UserGroupsErrorMessage = $"HTTP {(int)apiEx.StatusCode} {apiEx.StatusCode}: {apiEx.Content}"; + } + catch (Exception ex) + { + UserGroupsErrorMessage = $"{ex.GetType().Name}: {ex.Message}"; + } + finally + { + IsUserGroupsLoading = false; + } + } + + [RelayCommand] + private async Task CreateUserGroup() + { + var dialogVm = new GroupEditDialogViewModel { IsEditMode = false }; + + // Load all users so the dialog can offer membership checkboxes + await PopulateUserItemsAsync(dialogVm, currentMemberIds: new HashSet()); + await PopulateProjectItemsAsync(dialogVm, currentProjectIds: new HashSet()); + + var owner = Application.Current.MainWindow; + var confirmed = GroupEditDialog.ShowDialog(owner!, dialogVm); + if (!confirmed) + return; + + try + { + IsUserGroupsLoading = true; + UserGroupsErrorMessage = null; + UserGroupsSuccessMessage = null; + + var request = new CreateUserGroupRequest + { + Code = dialogVm.Code.Trim().ToLowerInvariant().Replace(" ", "-"), + Name = dialogVm.Name.Trim(), + Description = string.IsNullOrWhiteSpace(dialogVm.Description) ? null : dialogVm.Description, + }; + + var response = await _api.CreateUserGroupAsync(request); + if (response.Success && response.Data != null) + { + // Save members if any were checked + var members = dialogVm.UserItems.Where(u => u.IsMember).Select(u => u.UserId).ToList(); + if (members.Count > 0) + { + try + { + await _api.SetUserGroupMembersAsync(response.Data.Id, + new SetUserGroupMembersRequest { UserIds = members }); + } + catch (Exception membEx) + { + UserGroupsErrorMessage = $"Grupo creado pero error al asignar miembros: {membEx.Message}"; + } + } + + // Save project access + var projectIds = dialogVm.ProjectItems.Where(p => p.HasAccess).Select(p => p.ProjectId).ToList(); + try + { + await _api.SetUserGroupProjectsAsync(response.Data.Id, + new SetUserGroupProjectsRequest { ProjectIds = projectIds }); + } + catch (Exception projEx) + { + UserGroupsErrorMessage = $"Grupo creado pero error al asignar proyectos: {projEx.Message}"; + } + + UserGroupsSuccessMessage = $"Group '{response.Data.Name}' created"; + await LoadUserGroupsAsync(); + SelectedUserGroup = UserGroups.FirstOrDefault(g => g.Id == response.Data.Id); + } + else + { + UserGroupsErrorMessage = FormatApiError(response.Message, response.Errors, "Failed to create group"); + } + } + catch (Refit.ApiException apiEx) when (apiEx.StatusCode == System.Net.HttpStatusCode.Created) + { + UserGroupsSuccessMessage = "Group created"; + await LoadUserGroupsAsync(); + } + catch (Refit.ApiException apiEx) + { + UserGroupsErrorMessage = ExtractApiError(apiEx, "Failed to create group"); + } + catch (Exception ex) + { + UserGroupsErrorMessage = $"Error creating group: {ex.Message}"; + } + finally + { + IsUserGroupsLoading = false; + } + } + + [RelayCommand(CanExecute = nameof(CanEditUserGroup))] + private async Task EditUserGroup() + { + if (SelectedUserGroup == null) + return; + + var dialogVm = new GroupEditDialogViewModel + { + IsEditMode = true, + Code = SelectedUserGroup.Code, + Name = SelectedUserGroup.Name, + Description = SelectedUserGroup.Description, + }; + + // Load current members and populate user list + var currentMemberIds = new HashSet(); + try + { + var membersResp = await _api.GetUserGroupMembersAsync(SelectedUserGroup.Id); + if (membersResp.Success && membersResp.Data != null) + { + foreach (var m in membersResp.Data) + currentMemberIds.Add(m.UserId); + } + } + catch { /* non-critical */ } + + await PopulateUserItemsAsync(dialogVm, currentMemberIds); + + // Load current project access for this group + var currentProjectIds = new HashSet(); + try + { + var projectsResp = await _api.GetUserGroupProjectsAsync(SelectedUserGroup.Id); + if (projectsResp.Success && projectsResp.Data != null) + { + foreach (var p in projectsResp.Data) + currentProjectIds.Add(p.ProjectId); + } + } + catch { /* non-critical */ } + + await PopulateProjectItemsAsync(dialogVm, currentProjectIds); + + var owner = Application.Current.MainWindow; + var confirmed = GroupEditDialog.ShowDialog(owner!, dialogVm); + if (!confirmed) + return; + + try + { + IsUserGroupsLoading = true; + UserGroupsErrorMessage = null; + UserGroupsSuccessMessage = null; + + var request = new UpdateUserGroupRequest + { + Name = dialogVm.Name.Trim(), + Description = string.IsNullOrWhiteSpace(dialogVm.Description) ? null : dialogVm.Description, + }; + + var response = await _api.UpdateUserGroupAsync(SelectedUserGroup.Id, request); + if (response.Success && response.Data != null) + { + // Save members + var members = dialogVm.UserItems.Where(u => u.IsMember).Select(u => u.UserId).ToList(); + try + { + await _api.SetUserGroupMembersAsync(SelectedUserGroup.Id, + new SetUserGroupMembersRequest { UserIds = members }); + } + catch (Exception membEx) + { + UserGroupsErrorMessage = $"Grupo actualizado pero error al guardar miembros: {membEx.Message}"; + return; + } + + // Save project access + var projectIds = dialogVm.ProjectItems.Where(p => p.HasAccess).Select(p => p.ProjectId).ToList(); + try + { + await _api.SetUserGroupProjectsAsync(SelectedUserGroup.Id, + new SetUserGroupProjectsRequest { ProjectIds = projectIds }); + } + catch (Exception projEx) + { + UserGroupsErrorMessage = $"Grupo actualizado pero error al guardar proyectos: {projEx.Message}"; + return; + } + + UserGroupsSuccessMessage = "Group updated"; + await LoadUserGroupsAsync(); + SelectedUserGroup = UserGroups.FirstOrDefault(g => g.Id == response.Data.Id); + } + else + { + UserGroupsErrorMessage = FormatApiError(response.Message, response.Errors, "Failed to update group"); + } + } + catch (Refit.ApiException apiEx) + { + UserGroupsErrorMessage = ExtractApiError(apiEx, "Failed to update group"); + } + catch (Exception ex) + { + UserGroupsErrorMessage = $"Error updating group: {ex.Message}"; + } + finally + { + IsUserGroupsLoading = false; + } + } + + private bool CanEditUserGroup() => SelectedUserGroup != null; + + [RelayCommand(CanExecute = nameof(CanDeleteUserGroup))] + private async Task DeleteUserGroup() + { + if (SelectedUserGroup == null) + return; + + var result = MessageBox.Show( + $"Delete group '{SelectedUserGroup.Name}'?\n\nMembers will lose membership but their user accounts remain.", + "Confirm Delete", + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + + if (result != MessageBoxResult.Yes) + return; + + try + { + IsUserGroupsLoading = true; + UserGroupsErrorMessage = null; + UserGroupsSuccessMessage = null; + + await _api.DeleteUserGroupAsync(SelectedUserGroup.Id); + UserGroups.Remove(SelectedUserGroup); + SelectedUserGroup = null; + UserGroupsSuccessMessage = "Group deleted"; + } + catch (Refit.ApiException apiEx) + { + UserGroupsErrorMessage = ExtractApiError(apiEx, "Failed to delete group"); + } + catch (Exception ex) + { + UserGroupsErrorMessage = $"Error deleting group: {ex.Message}"; + } + finally + { + IsUserGroupsLoading = false; + } + } + + private bool CanDeleteUserGroup() => SelectedUserGroup != null; + + private async Task PopulateUserItemsAsync(GroupEditDialogViewModel dialogVm, HashSet currentMemberIds) + { + try + { + var usersResp = await _api.GetAllUsersAsync(); + if (usersResp.Success && usersResp.Data != null) + { + foreach (var u in usersResp.Data.OrderBy(u => u.FullName ?? u.Email)) + { + dialogVm.UserItems.Add(new GroupUserItem + { + UserId = u.Id, + Email = u.Email, + FullName = string.IsNullOrWhiteSpace(u.FullName) ? u.Email : u.FullName, + IsMember = currentMemberIds.Contains(u.Id), + }); + } + } + } + catch + { + // dialog can still proceed without users list + } + } + + private async Task PopulateProjectItemsAsync(GroupEditDialogViewModel dialogVm, HashSet currentProjectIds) + { + try + { + var projectsResp = await _api.GetProjectsAsync(pageSize: 1000); + if (projectsResp.Success && projectsResp.Data?.Items != null) + { + foreach (var p in projectsResp.Data.Items.OrderBy(p => p.Name)) + { + dialogVm.ProjectItems.Add(new GroupProjectItem + { + ProjectId = p.Id, + ProjectCode = p.Code, + ProjectName = p.Name, + HasAccess = currentProjectIds.Contains(p.Id), + }); + } + } + } + catch + { + // dialog can still proceed without projects list + } + } } diff --git a/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs index 3785a6d..0d2a21d 100644 --- a/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs @@ -3,9 +3,23 @@ // using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; +using NexusCad.Admin.Models; namespace NexusCad.Admin.ViewModels; +/// +/// Item observable que representa la membresía de un usuario en un grupo concreto. +/// +public partial class GroupMembershipItem : ObservableObject +{ + public Guid GroupId { get; set; } + public string GroupCode { get; set; } = string.Empty; + public string GroupName { get; set; } = string.Empty; + + [ObservableProperty] + private bool hasAccess; +} + public partial class UserEditDialogViewModel : ObservableObject { [ObservableProperty] @@ -20,20 +34,17 @@ public partial class UserEditDialogViewModel : ObservableObject [ObservableProperty] private bool isActive = true; - [ObservableProperty] - private ObservableCollection selectedRoles = new(); - [ObservableProperty] private bool isEditMode; - public string Title => IsEditMode ? "Edit User" : "New User"; + public string Title => IsEditMode ? "Editar usuario" : "Nuevo usuario"; - public List AvailableRoles { get; } + /// + /// Grupos a los que puede pertenecer el usuario. Se rellena antes de abrir el diálogo. + /// + public ObservableCollection GroupItems { get; } = new(); - public UserEditDialogViewModel(List availableRoles) - { - AvailableRoles = availableRoles; - } + public UserEditDialogViewModel() { } public bool IsValid() { @@ -52,9 +63,6 @@ public bool IsValid() return false; } - if (!SelectedRoles.Any()) - return false; - return true; } @@ -66,3 +74,4 @@ public static bool IsPasswordStrong(string password) => && password.Any(char.IsLower) && password.Any(char.IsDigit); } + diff --git a/src/NexusCad.Admin/ViewModels/WorkspaceEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/WorkspaceEditDialogViewModel.cs new file mode 100644 index 0000000..456bdd4 --- /dev/null +++ b/src/NexusCad.Admin/ViewModels/WorkspaceEditDialogViewModel.cs @@ -0,0 +1,47 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace NexusCad.Admin.ViewModels; + +/// +/// Item observable para una fila de proyecto en la pestaña "Proyectos" del diálogo de workspace. +/// +public partial class WorkspaceProjectAccessItem : ObservableObject +{ + public Guid ProjectId { get; set; } + public string ProjectCode { get; set; } = string.Empty; + public string ProjectName { get; set; } = string.Empty; + + [ObservableProperty] + private bool hasAccess; + + /// 0 = Ver, 1 = Configurar + [ObservableProperty] + private int accessLevel = 1; +} + +public partial class WorkspaceEditDialogViewModel : ObservableObject +{ + [ObservableProperty] + private string name = string.Empty; + + [ObservableProperty] + private string? description; + + [ObservableProperty] + private string? workspacePath; + + [ObservableProperty] + private string? iconUrl; + + public string Code { get; init; } = string.Empty; + + /// Proyectos disponibles con su estado de acceso. Cargado por SecuritySettingsViewModel antes de abrir el diálogo. + public ObservableCollection ProjectItems { get; } = new(); + + public bool IsValid() => !string.IsNullOrWhiteSpace(Name); +} + diff --git a/src/NexusCad.Admin/Views/GroupEditDialog.xaml b/src/NexusCad.Admin/Views/GroupEditDialog.xaml new file mode 100644 index 0000000..2a7aa5f --- /dev/null +++ b/src/NexusCad.Admin/Views/GroupEditDialog.xaml @@ -0,0 +1,293 @@ + + + + + #0078D7 + #212529 + #6C757D + #DEE2E6 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs b/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs new file mode 100644 index 0000000..65772e0 --- /dev/null +++ b/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs @@ -0,0 +1,67 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using System.Windows; +using NexusCad.Admin.ViewModels; + +namespace NexusCad.Admin.Views; + +public partial class GroupEditDialog : Window +{ + public GroupEditDialogViewModel ViewModel { get; } + + public GroupEditDialog(GroupEditDialogViewModel viewModel) + { + InitializeComponent(); + ViewModel = viewModel; + DataContext = viewModel; + Loaded += (_, _) => + { + if (ViewModel.IsEditMode) + NameBox.Focus(); + else + CodeBox.Focus(); + RefreshNoUsersText(); + RefreshNoProjectsText(); + }; + viewModel.UserItems.CollectionChanged += (_, _) => RefreshNoUsersText(); + viewModel.ProjectItems.CollectionChanged += (_, _) => RefreshNoProjectsText(); + } + + private void RefreshNoUsersText() + { + NoUsersText.Visibility = ViewModel.UserItems.Count == 0 + ? Visibility.Visible + : Visibility.Collapsed; + } + + private void RefreshNoProjectsText() + { + NoProjectsText.Visibility = ViewModel.ProjectItems.Count == 0 + ? Visibility.Visible + : Visibility.Collapsed; + } + + private void OkButton_Click(object sender, RoutedEventArgs e) + { + ErrorText.Visibility = Visibility.Collapsed; + + if (!ViewModel.IsValid()) + { + ErrorText.Text = ViewModel.IsEditMode + ? "El nombre es obligatorio." + : "Código y nombre son obligatorios."; + ErrorText.Visibility = Visibility.Visible; + return; + } + + DialogResult = true; + Close(); + } + + public static bool ShowDialog(Window owner, GroupEditDialogViewModel viewModel) + { + var dlg = new GroupEditDialog(viewModel) { Owner = owner }; + return dlg.ShowDialog() == true; + } +} diff --git a/src/NexusCad.Admin/Views/HomeView.xaml b/src/NexusCad.Admin/Views/HomeView.xaml new file mode 100644 index 0000000..93c7438 --- /dev/null +++ b/src/NexusCad.Admin/Views/HomeView.xaml @@ -0,0 +1,638 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Captured Models + Cotas paramétricas (mm/deg) + Features (supresión) + Custom Properties + Drawings + File Formats + + + + + + + + + + + + + + + + + + + + + + + + + + Model Dimensions + Variables (entrada / derivada) + Model Rules (Roslyn C#) + Publicar proyecto + + + + + + + + + + + + + + + + + + + + + + + + + + Login + Catálogo de proyectos + Formulario dinámico + Enviar especificación + + + + + + + + + + + + + + + + + + + + + + + + + + Evalúa Model Rules + Aplica cotas en SolidWorks + Exporta STEP / DXF / PDF + Archivos listos para descarga + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Instalación: + ejecuta .\dev-scripts\register-swaddin.ps1 (PowerShell elevado). En SolidWorks: Herramientas → Complementos → ☑ NexusCad Capture. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SolidWorks no es thread-safe. + El número de procesos worker en paralelo está limitado al número de licencias disponibles. + + + + + + + + Renovación de token: + el worker renueva el JWT automáticamente. Mensajes IDX10223 en logs se recuperan solos sin intervención. + + + + + + + + + + diff --git a/src/NexusCad.Admin/Views/HomeView.xaml.cs b/src/NexusCad.Admin/Views/HomeView.xaml.cs new file mode 100644 index 0000000..16b831e --- /dev/null +++ b/src/NexusCad.Admin/Views/HomeView.xaml.cs @@ -0,0 +1,14 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using System.Windows.Controls; + +namespace NexusCad.Admin.Views; + +public partial class HomeView : UserControl +{ + public HomeView() + { + InitializeComponent(); + } +} diff --git a/src/NexusCad.Admin/Views/NewGroupDialog.xaml b/src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml similarity index 89% rename from src/NexusCad.Admin/Views/NewGroupDialog.xaml rename to src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml index d9403a8..15da0b8 100644 --- a/src/NexusCad.Admin/Views/NewGroupDialog.xaml +++ b/src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml @@ -2,7 +2,7 @@ Copyright (C) 2026 JaviFRx Licensed under AGPL v3 --> - - - @@ -95,23 +95,23 @@ Padding="24,20"> - + - + - + Text="Optional. What this workspace is for (max 1000 chars)." /> @@ -128,7 +128,7 @@ Margin="8,0,0,0" /> + Text="Optional folder where the workspace stores artifacts, temp and templates." /> @@ -181,7 +181,7 @@ Width="16" Height="16" VerticalAlignment="Center" Margin="0,0,8,0" /> - + diff --git a/src/NexusCad.Admin/Views/NewGroupDialog.xaml.cs b/src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml.cs similarity index 55% rename from src/NexusCad.Admin/Views/NewGroupDialog.xaml.cs rename to src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml.cs index c43820e..8518cf0 100644 --- a/src/NexusCad.Admin/Views/NewGroupDialog.xaml.cs +++ b/src/NexusCad.Admin/Views/NewWorkspaceDialog.xaml.cs @@ -8,11 +8,11 @@ namespace NexusCad.Admin.Views; -public partial class NewGroupDialog : Window +public partial class NewWorkspaceDialog : Window { - public NewGroupDialogViewModel ViewModel { get; } + public NewWorkspaceDialogViewModel ViewModel { get; } - public NewGroupDialog(NewGroupDialogViewModel viewModel) + public NewWorkspaceDialog(NewWorkspaceDialogViewModel viewModel) { InitializeComponent(); ViewModel = viewModel; @@ -23,16 +23,16 @@ public NewGroupDialog(NewGroupDialogViewModel viewModel) private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName != nameof(NewGroupDialogViewModel.DialogResult)) + if (e.PropertyName != nameof(NewWorkspaceDialogViewModel.DialogResult)) return; DialogResult = ViewModel.DialogResult; Close(); } - public static GroupDto? ShowDialog(Window owner, NewGroupDialogViewModel viewModel) + public static WorkspaceDto? ShowDialog(Window owner, NewWorkspaceDialogViewModel viewModel) { - var dialog = new NewGroupDialog(viewModel) { Owner = owner }; + var dialog = new NewWorkspaceDialog(viewModel) { Owner = owner }; var result = dialog.ShowDialog(); - return result == true ? dialog.ViewModel.CreatedGroup : null; + return result == true ? dialog.ViewModel.CreatedWorkspace : null; } } diff --git a/src/NexusCad.Admin/Views/OpenGroupDialog.xaml b/src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml similarity index 93% rename from src/NexusCad.Admin/Views/OpenGroupDialog.xaml rename to src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml index 29ea27e..efc9737 100644 --- a/src/NexusCad.Admin/Views/OpenGroupDialog.xaml +++ b/src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml @@ -2,7 +2,7 @@ Copyright (C) 2026 JaviFRx Licensed under AGPL v3 --> - - - @@ -79,8 +79,8 @@ Foreground="#7A5800" /> - @@ -91,7 +91,7 @@ - @@ -104,7 +104,7 @@ - + @@ -178,7 +178,7 @@ Width="16" Height="16" VerticalAlignment="Center" Margin="0,0,8,0" /> - + diff --git a/src/NexusCad.Admin/Views/OpenGroupDialog.xaml.cs b/src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml.cs similarity index 57% rename from src/NexusCad.Admin/Views/OpenGroupDialog.xaml.cs rename to src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml.cs index 792fd10..8eb397c 100644 --- a/src/NexusCad.Admin/Views/OpenGroupDialog.xaml.cs +++ b/src/NexusCad.Admin/Views/OpenWorkspaceDialog.xaml.cs @@ -8,11 +8,11 @@ namespace NexusCad.Admin.Views; -public partial class OpenGroupDialog : Window +public partial class OpenWorkspaceDialog : Window { - public OpenGroupDialogViewModel ViewModel { get; } + public OpenWorkspaceDialogViewModel ViewModel { get; } - public OpenGroupDialog(OpenGroupDialogViewModel viewModel) + public OpenWorkspaceDialog(OpenWorkspaceDialogViewModel viewModel) { InitializeComponent(); ViewModel = viewModel; @@ -23,16 +23,16 @@ public OpenGroupDialog(OpenGroupDialogViewModel viewModel) private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName != nameof(OpenGroupDialogViewModel.DialogResult)) + if (e.PropertyName != nameof(OpenWorkspaceDialogViewModel.DialogResult)) return; DialogResult = ViewModel.DialogResult; Close(); } - public static GroupDto? ShowDialog(Window owner, OpenGroupDialogViewModel viewModel) + public static WorkspaceDto? ShowDialog(Window owner, OpenWorkspaceDialogViewModel viewModel) { - var dialog = new OpenGroupDialog(viewModel) { Owner = owner }; + var dialog = new OpenWorkspaceDialog(viewModel) { Owner = owner }; var result = dialog.ShowDialog(); - return result == true ? dialog.ViewModel.OpenedGroup : null; + return result == true ? dialog.ViewModel.OpenedWorkspace : null; } } diff --git a/src/NexusCad.Admin/Views/ProjectEditorView.xaml b/src/NexusCad.Admin/Views/ProjectEditorView.xaml index ca2c94e..492b294 100644 --- a/src/NexusCad.Admin/Views/ProjectEditorView.xaml +++ b/src/NexusCad.Admin/Views/ProjectEditorView.xaml @@ -183,6 +183,42 @@ TextWrapping="Wrap" AcceptsReturn="True" MinHeight="80"/> + + + + + + + + + + + + diff --git a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml index aab528f..6bc648c 100644 --- a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml +++ b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml @@ -20,7 +20,6 @@ - @@ -43,222 +42,575 @@ Foreground="{StaticResource NexusText}" VerticalAlignment="Center"/> - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - - - + - - - - - - - - - - + + + + + - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - + + - - - - - - - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml.cs b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml.cs index e37df43..b45bcee 100644 --- a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml.cs +++ b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml.cs @@ -23,6 +23,8 @@ private async void OnDataContextChanged(object sender, DependencyPropertyChanged if (e.NewValue is SecuritySettingsViewModel viewModel) { await viewModel.LoadUsersCommand.ExecuteAsync(null); + await viewModel.LoadWorkspacesCommand.ExecuteAsync(null); + await viewModel.LoadUserGroupsCommand.ExecuteAsync(null); } } diff --git a/src/NexusCad.Admin/Views/UserEditDialog.xaml b/src/NexusCad.Admin/Views/UserEditDialog.xaml index 6b8eb18..c363422 100644 --- a/src/NexusCad.Admin/Views/UserEditDialog.xaml +++ b/src/NexusCad.Admin/Views/UserEditDialog.xaml @@ -12,8 +12,8 @@ mc:Ignorable="d" d:DataContext="{d:DesignInstance Type=vm:UserEditDialogViewModel}" Title="User" - Height="540" - Width="480" + Height="620" + Width="540" WindowStartupLocation="CenterOwner" ResizeMode="NoResize" WindowStyle="SingleBorderWindow" @@ -25,6 +25,7 @@ #212529 #6C757D #DEE2E6 + #198754 @@ -71,7 +72,7 @@ FontSize="18" FontWeight="SemiBold" Foreground="White" /> - @@ -79,88 +80,161 @@ - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + diff --git a/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml.cs b/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml.cs new file mode 100644 index 0000000..95d5ac5 --- /dev/null +++ b/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml.cs @@ -0,0 +1,64 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using System.Windows; +using Microsoft.Win32; +using NexusCad.Admin.ViewModels; + +namespace NexusCad.Admin.Views; + +public partial class WorkspaceEditDialog : Window +{ + public WorkspaceEditDialogViewModel ViewModel { get; } + + public WorkspaceEditDialog(WorkspaceEditDialogViewModel viewModel) + { + InitializeComponent(); + ViewModel = viewModel; + DataContext = viewModel; + Loaded += (_, _) => + { + NameBox.Focus(); + RefreshNoProjectsText(); + }; + viewModel.ProjectItems.CollectionChanged += (_, _) => RefreshNoProjectsText(); + } + + private void RefreshNoProjectsText() + { + NoProjectsText.Visibility = ViewModel.ProjectItems.Count == 0 + ? Visibility.Visible + : Visibility.Collapsed; + } + + private void BrowseButton_Click(object sender, RoutedEventArgs e) + { + var dlg = new OpenFolderDialog + { + Title = "Select the workspace folder for this workspace" + }; + if (dlg.ShowDialog() == true && !string.IsNullOrWhiteSpace(dlg.FolderName)) + ViewModel.WorkspacePath = dlg.FolderName; + } + + private void OkButton_Click(object sender, RoutedEventArgs e) + { + ErrorText.Visibility = Visibility.Collapsed; + + if (!ViewModel.IsValid()) + { + ErrorText.Text = "Name is required."; + ErrorText.Visibility = Visibility.Visible; + return; + } + + DialogResult = true; + Close(); + } + + public static bool ShowDialog(Window owner, WorkspaceEditDialogViewModel viewModel) + { + var dialog = new WorkspaceEditDialog(viewModel) { Owner = owner }; + return dialog.ShowDialog() == true; + } +} diff --git a/src/NexusCad.Api/Controllers/AuthController.cs b/src/NexusCad.Api/Controllers/AuthController.cs index 4575c62..361d6ae 100644 --- a/src/NexusCad.Api/Controllers/AuthController.cs +++ b/src/NexusCad.Api/Controllers/AuthController.cs @@ -4,10 +4,13 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using NexusCad.Api.DTOs.Auth; using NexusCad.Api.DTOs.Common; using NexusCad.Api.Services; using NexusCad.Core.Constants; +using NexusCad.Core.Interfaces; +using NexusCad.Infrastructure.Data; using NexusCad.Infrastructure.Identity; namespace NexusCad.Api.Controllers; @@ -24,6 +27,8 @@ public class AuthController : ControllerBase private readonly SignInManager _signInManager; private readonly JwtTokenService _jwtTokenService; private readonly ILogger _logger; + private readonly IGroupRepository _groupRepository; + private readonly AppDbContext _context; private readonly Microsoft.Extensions.Options.IOptions _jwtSettings; @@ -32,12 +37,16 @@ public AuthController( SignInManager signInManager, JwtTokenService jwtTokenService, Microsoft.Extensions.Options.IOptions jwtSettings, + IGroupRepository groupRepository, + AppDbContext context, ILogger logger) { _userManager = userManager; _signInManager = signInManager; _jwtTokenService = jwtTokenService; _jwtSettings = jwtSettings; + _groupRepository = groupRepository; + _context = context; _logger = logger; } @@ -455,4 +464,109 @@ public async Task>> DeleteUser(string id) return StatusCode(500, ApiResponse.ErrorResult("An error occurred")); } } + + /// + /// Obtener las membresías de grupo de un usuario (solo Admin) + /// + [HttpGet("users/{id}/memberships")] + [Authorize(Roles = Roles.Admin)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>>> GetUserMemberships(string id) + { + try + { + var user = await _userManager.FindByIdAsync(id); + if (user == null) + return NotFound(ApiResponse>.ErrorResult("User not found")); + + var memberships = await _context.GroupMemberships + .Include(m => m.Group) + .Where(m => m.UserId == id) + .ToListAsync(); + + var dtos = memberships.Select(m => new UserGroupMembershipDto + { + GroupId = m.GroupId, + GroupCode = m.Group?.Code ?? string.Empty, + GroupName = m.Group?.Name ?? string.Empty, + GroupIconUrl = m.Group?.IconUrl, + CreatedAt = m.CreatedAt, + }).ToList(); + + return Ok(ApiResponse>.SuccessResult(dtos)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting memberships for user {UserId}", id); + return StatusCode(500, ApiResponse>.ErrorResult("An error occurred")); + } + } + + /// + /// Reemplaza las membresías de grupo de un usuario (solo Admin). + /// Las membresías no presentes en la request son eliminadas. + /// + [HttpPut("users/{id}/memberships")] + [Authorize(Roles = Roles.Admin)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>>> SetUserMemberships( + string id, [FromBody] SetUserMembershipsRequest request) + { + try + { + var user = await _userManager.FindByIdAsync(id); + if (user == null) + return NotFound(ApiResponse>.ErrorResult("User not found")); + + var existing = await _context.GroupMemberships + .Where(m => m.UserId == id) + .ToListAsync(); + + var desired = request.Memberships; + var desiredGroupIds = desired.Select(d => d.GroupId).ToHashSet(); + + // Remove memberships not in desired list + var toRemove = existing.Where(e => !desiredGroupIds.Contains(e.GroupId)).ToList(); + _context.GroupMemberships.RemoveRange(toRemove); + + // Add new memberships (the ones not already present) + foreach (var item in desired) + { + var existingMembership = existing.FirstOrDefault(e => e.GroupId == item.GroupId); + if (existingMembership == null) + { + await _groupRepository.AddMembershipAsync(item.GroupId, id); + } + } + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Updated group memberships for user {UserId}: {Count} groups", id, desired.Count); + + // Return updated memberships + var updated = await _context.GroupMemberships + .Include(m => m.Group) + .Where(m => m.UserId == id) + .ToListAsync(); + + var dtos = updated.Select(m => new UserGroupMembershipDto + { + GroupId = m.GroupId, + GroupCode = m.Group?.Code ?? string.Empty, + GroupName = m.Group?.Name ?? string.Empty, + GroupIconUrl = m.Group?.IconUrl, + CreatedAt = m.CreatedAt, + }).ToList(); + + return Ok(ApiResponse>.SuccessResult(dtos, "Memberships updated successfully")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting memberships for user {UserId}", id); + return StatusCode(500, ApiResponse>.ErrorResult("An error occurred")); + } + } } + diff --git a/src/NexusCad.Api/Controllers/CapturesController.cs b/src/NexusCad.Api/Controllers/CapturesController.cs index 29b7036..fb6d380 100644 --- a/src/NexusCad.Api/Controllers/CapturesController.cs +++ b/src/NexusCad.Api/Controllers/CapturesController.cs @@ -16,7 +16,7 @@ namespace NexusCad.Api.Controllers; [ApiController] [Route("api/projects/{projectId:guid}/captures")] [Produces("application/json")] -[Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")] +[Authorize(Roles = Roles.Admin)] public class CapturesController : ControllerBase { private readonly AppDbContext _db; diff --git a/src/NexusCad.Api/Controllers/DimensionRulesController.cs b/src/NexusCad.Api/Controllers/DimensionRulesController.cs index e8f1723..25955b6 100644 --- a/src/NexusCad.Api/Controllers/DimensionRulesController.cs +++ b/src/NexusCad.Api/Controllers/DimensionRulesController.cs @@ -20,7 +20,7 @@ namespace NexusCad.Api.Controllers; [ApiController] [Route("api/projects/{projectId:guid}/dimension-rules")] [Produces("application/json")] -[Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")] +[Authorize(Roles = Roles.Admin)] public class DimensionRulesController : ControllerBase { private readonly AppDbContext _db; diff --git a/src/NexusCad.Api/Controllers/GroupsController.cs b/src/NexusCad.Api/Controllers/GroupsController.cs index ced2d23..0f0a58d 100644 --- a/src/NexusCad.Api/Controllers/GroupsController.cs +++ b/src/NexusCad.Api/Controllers/GroupsController.cs @@ -89,7 +89,7 @@ public async Task>> GetGroup(Guid id) } [HttpPost] - [Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")] + [Authorize(Roles = Roles.Admin)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status409Conflict)] public async Task>> CreateGroup([FromBody] CreateGroupRequest request) @@ -114,8 +114,8 @@ public async Task>> CreateGroup([FromBody] Cr await _groupRepository.AddAsync(group); - // El creador queda automáticamente como Owner del grupo. - await _groupRepository.AddMembershipAsync(group.Id, userId, GroupRole.Owner); + // El creador queda automáticamente como miembro del grupo. + await _groupRepository.AddMembershipAsync(group.Id, userId); _logger.LogInformation("Created group {Code} ({Id}) by {UserId}", group.Code, group.Id, userId); @@ -242,7 +242,6 @@ public async Task>>> GetMembers(Gu UserId = m.UserId, UserName = user?.UserName, Email = user?.Email, - Role = m.Role, CreatedAt = m.CreatedAt, }; }).ToList(); @@ -265,13 +264,12 @@ public async Task>> AddMember(Guid id, if (existing != null) return Conflict(ApiResponse.ErrorResult("User is already a member")); - var membership = await _groupRepository.AddMembershipAsync(id, request.UserId, request.Role); + var membership = await _groupRepository.AddMembershipAsync(id, request.UserId); var dto = new GroupMemberDto { UserId = membership.UserId, UserName = user.UserName, Email = user.Email, - Role = membership.Role, CreatedAt = membership.CreatedAt, }; return CreatedAtAction(nameof(GetMembers), new { id }, ApiResponse.SuccessResult(dto)); @@ -309,9 +307,106 @@ private async Task UserCanManageAsync(Guid groupId) var userId = GetUserId(); if (userId == null) return false; - var membership = await _context.GroupMemberships - .FirstOrDefaultAsync(m => m.GroupId == groupId && m.UserId == userId); - return membership != null && membership.Role == GroupRole.Owner; + return await _context.GroupMemberships + .AnyAsync(m => m.GroupId == groupId && m.UserId == userId); + } + + // ── Project Access ──────────────────────────────────────────────────────── + + /// + /// Devuelve los accesos a proyectos configurados para un grupo. + /// Si la lista está vacía, el grupo accede a todos sus propios proyectos (modo legacy). + /// + [HttpGet("{id:guid}/project-access")] + [Authorize(Roles = Roles.Admin)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>>> GetGroupProjectAccess(Guid id) + { + try + { + var exists = await _context.Groups.AnyAsync(g => g.Id == id); + if (!exists) + return NotFound(ApiResponse>.ErrorResult("Group not found")); + + var accesses = await _context.GroupProjectAccesses + .Where(a => a.GroupId == id) + .Include(a => a.Project) + .ToListAsync(); + + var dtos = accesses.Select(a => new GroupProjectAccessDto + { + ProjectId = a.ProjectId, + ProjectCode = a.Project?.Code ?? string.Empty, + ProjectName = a.Project?.Name ?? string.Empty, + AccessLevel = (int)a.AccessLevel, + }).ToList(); + + return Ok(ApiResponse>.SuccessResult(dtos)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting project access for group {GroupId}", id); + return StatusCode(500, ApiResponse>.ErrorResult("An error occurred")); + } + } + + /// + /// Reemplaza todos los accesos a proyectos de un grupo de forma atómica. + /// Enviar lista vacía = eliminar todos los accesos (vuelve a modo legacy: acceso a todos los proyectos del grupo). + /// + [HttpPut("{id:guid}/project-access")] + [Authorize(Roles = Roles.Admin)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>>> SetGroupProjectAccess( + Guid id, [FromBody] SetGroupProjectAccessRequest request) + { + try + { + var exists = await _context.Groups.AnyAsync(g => g.Id == id); + if (!exists) + return NotFound(ApiResponse>.ErrorResult("Group not found")); + + // Remove all existing entries for this group + var existing = await _context.GroupProjectAccesses.Where(a => a.GroupId == id).ToListAsync(); + _context.GroupProjectAccesses.RemoveRange(existing); + + // Add new entries + foreach (var item in request.Items) + { + var projectExists = await _context.Projects.AnyAsync(p => p.Id == item.ProjectId); + if (!projectExists) continue; + + _context.GroupProjectAccesses.Add(new Core.Entities.GroupProjectAccess + { + GroupId = id, + ProjectId = item.ProjectId, + AccessLevel = (Core.Enums.ProjectAccessLevel)item.AccessLevel, + CreatedAt = DateTime.UtcNow, + }); + } + + await _context.SaveChangesAsync(); + + var updated = await _context.GroupProjectAccesses + .Where(a => a.GroupId == id) + .Include(a => a.Project) + .ToListAsync(); + + var dtos = updated.Select(a => new GroupProjectAccessDto + { + ProjectId = a.ProjectId, + ProjectCode = a.Project?.Code ?? string.Empty, + ProjectName = a.Project?.Name ?? string.Empty, + AccessLevel = (int)a.AccessLevel, + }).ToList(); + + return Ok(ApiResponse>.SuccessResult(dtos, "Project access updated")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting project access for group {GroupId}", id); + return StatusCode(500, ApiResponse>.ErrorResult("An error occurred")); + } } private static GroupDto ToDto(Group g) => new() diff --git a/src/NexusCad.Api/Controllers/LookupTablesController.cs b/src/NexusCad.Api/Controllers/LookupTablesController.cs index 4b2bf63..7a4c54b 100644 --- a/src/NexusCad.Api/Controllers/LookupTablesController.cs +++ b/src/NexusCad.Api/Controllers/LookupTablesController.cs @@ -17,7 +17,7 @@ namespace NexusCad.Api.Controllers; /// [ApiController] [Route("api/[controller]")] -[Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")] +[Authorize(Roles = Roles.Admin)] [Produces("application/json")] public class LookupTablesController : ControllerBase { diff --git a/src/NexusCad.Api/Controllers/ProjectsController.cs b/src/NexusCad.Api/Controllers/ProjectsController.cs index 2b6611a..e23883c 100644 --- a/src/NexusCad.Api/Controllers/ProjectsController.cs +++ b/src/NexusCad.Api/Controllers/ProjectsController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; using NexusCad.Api.DTOs.Common; using NexusCad.Api.DTOs.Projects; using NexusCad.Api.Hubs; @@ -12,6 +13,7 @@ using NexusCad.Core.Entities; using NexusCad.Core.Enums; using NexusCad.Core.Interfaces; +using NexusCad.Infrastructure.Data; namespace NexusCad.Api.Controllers; @@ -28,24 +30,27 @@ public class ProjectsController : ControllerBase private readonly IVariableRepository _variableRepository; private readonly IHubContext _hubContext; private readonly ILogger _logger; + private readonly AppDbContext _context; public ProjectsController( IProjectRepository projectRepository, IVariableRepository variableRepository, IHubContext hubContext, - ILogger logger) + ILogger logger, + AppDbContext context) { _projectRepository = projectRepository; _variableRepository = variableRepository; _hubContext = hubContext; _logger = logger; + _context = context; } /// /// Obtener lista paginada de proyectos /// [HttpGet] - [Authorize(Roles = $"{Roles.Admin},{Roles.Autor},{Roles.Comercial}")] + [Authorize] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] public async Task>>> GetProjects( [FromQuery] int pageNumber = 1, @@ -59,7 +64,28 @@ public async Task>>> Ge var (projects, totalCount) = await _projectRepository.GetPagedAsync( pageNumber, pageSize, status, search, groupId); - var items = projects.Select(p => new ProjectListItemDto + // Filtrar por acceso de UserGroup si no es Admin + IEnumerable filtered; + if (User.IsInRole(Roles.Admin)) + { + filtered = projects; + } + else + { + var userId = User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier); + var allowedProjectIds = await _context.UserGroupMembers + .Where(m => m.UserId == userId) + .Join(_context.UserGroupProjectAccesses, + m => m.UserGroupId, + a => a.UserGroupId, + (m, a) => a.ProjectId) + .Distinct() + .ToListAsync(); + filtered = projects.Where(p => allowedProjectIds.Contains(p.Id)); + totalCount = filtered.Count(); + } + + var items = filtered.Select(p => new ProjectListItemDto { Id = p.Id, Code = p.Code, @@ -116,6 +142,7 @@ public async Task>> GetProject(Guid id) Status = project.Status, FormSchemaJson = project.FormSchemaJson, IconUrl = project.IconUrl, + ModelsPath = project.ModelsPath, Version = project.Version, CreatedBy = project.CreatedBy, CreatedAt = project.CreatedAt, @@ -213,7 +240,7 @@ public async Task>> GetPubli /// Crear nuevo proyecto /// [HttpPost] - [Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")] + [Authorize(Roles = Roles.Admin)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task>> CreateProject( @@ -253,6 +280,7 @@ public async Task>> CreateProject( Description = request.Description ?? string.Empty, FormSchemaJson = request.FormSchemaJson, IconUrl = request.IconUrl, + ModelsPath = request.ModelsPath, Status = ProjectStatus.Draft, Version = 1, CreatedBy = userId, @@ -271,6 +299,7 @@ public async Task>> CreateProject( Status = project.Status, FormSchemaJson = project.FormSchemaJson, IconUrl = project.IconUrl, + ModelsPath = project.ModelsPath, Version = project.Version, CreatedBy = project.CreatedBy, CreatedAt = project.CreatedAt, @@ -295,7 +324,7 @@ public async Task>> CreateProject( /// Actualizar proyecto existente /// [HttpPut("{id}")] - [Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")] + [Authorize(Roles = Roles.Admin)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> UpdateProject(Guid id, [FromBody] UpdateProjectRequest request) @@ -311,6 +340,7 @@ public async Task>> UpdateProject(Guid id, project.Name = request.Name; project.Description = request.Description ?? string.Empty; project.IconUrl = request.IconUrl; + project.ModelsPath = request.ModelsPath; if (!string.IsNullOrEmpty(request.FormSchemaJson)) { @@ -330,6 +360,7 @@ public async Task>> UpdateProject(Guid id, Status = project.Status, FormSchemaJson = project.FormSchemaJson, IconUrl = project.IconUrl, + ModelsPath = project.ModelsPath, Version = project.Version, CreatedBy = project.CreatedBy, CreatedAt = project.CreatedAt, @@ -354,7 +385,7 @@ public async Task>> UpdateProject(Guid id, /// Publicar proyecto (cambia estado a Published) /// [HttpPost("{id}/publish")] - [Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")] + [Authorize(Roles = Roles.Admin)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> PublishProject(Guid id) @@ -385,6 +416,7 @@ public async Task>> PublishProject(Guid id) Status = project.Status, FormSchemaJson = project.FormSchemaJson, IconUrl = project.IconUrl, + ModelsPath = project.ModelsPath, Version = project.Version, CreatedBy = project.CreatedBy, CreatedAt = project.CreatedAt, @@ -407,7 +439,7 @@ public async Task>> PublishProject(Guid id) /// Despublicar proyecto (vuelve a estado Draft) /// [HttpPost("{id}/unpublish")] - [Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")] + [Authorize(Roles = Roles.Admin)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> UnpublishProject(Guid id) @@ -436,6 +468,7 @@ public async Task>> UnpublishProject(Guid i Status = project.Status, FormSchemaJson = project.FormSchemaJson, IconUrl = project.IconUrl, + ModelsPath = project.ModelsPath, Version = project.Version, CreatedBy = project.CreatedBy, CreatedAt = project.CreatedAt, diff --git a/src/NexusCad.Api/Controllers/SpecificationsController.cs b/src/NexusCad.Api/Controllers/SpecificationsController.cs index 8af9f22..f6bea7b 100644 --- a/src/NexusCad.Api/Controllers/SpecificationsController.cs +++ b/src/NexusCad.Api/Controllers/SpecificationsController.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2026 JaviFRx +// Copyright (C) 2026 JaviFRx // Licensed under AGPL v3 // using System.Security.Claims; @@ -61,7 +61,7 @@ public async Task try { var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var isAdmin = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Autor) || User.IsInRole(Roles.Comercial); + var isAdmin = User.IsInRole(Roles.Admin); var (specifications, totalCount) = await _specificationRepository.GetPagedAsync( pageNumber, pageSize, projectId, status, isAdmin ? null : userId); @@ -112,7 +112,7 @@ public async Task>> GetSpecification( // Verificar permisos (usuarios normales solo pueden ver sus propias specs) var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var isAdmin = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Autor) || User.IsInRole(Roles.Comercial); + var isAdmin = User.IsInRole(Roles.Admin); if (!isAdmin && specification.CreatedBy != userId) { @@ -234,7 +234,7 @@ public async Task>> RecalculateSpecif // Verificar permisos var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var isAdmin = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Autor) || User.IsInRole(Roles.Comercial); + var isAdmin = User.IsInRole(Roles.Admin); if (!isAdmin && specification.CreatedBy != userId) { @@ -333,7 +333,7 @@ public async Task>> SubmitSpecificati // Verificar permisos var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var isAdmin = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Autor) || User.IsInRole(Roles.Comercial); + var isAdmin = User.IsInRole(Roles.Admin); if (!isAdmin && specification.CreatedBy != userId) { @@ -402,7 +402,7 @@ public async Task>>> // Verificar permisos var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var isAdmin = User.IsInRole(Roles.Admin) || User.IsInRole(Roles.Autor) || User.IsInRole(Roles.Comercial); + var isAdmin = User.IsInRole(Roles.Admin); if (!isAdmin && specification.CreatedBy != userId) { @@ -431,3 +431,4 @@ public async Task>>> } } } + diff --git a/src/NexusCad.Api/Controllers/UserGroupsController.cs b/src/NexusCad.Api/Controllers/UserGroupsController.cs new file mode 100644 index 0000000..c3a77d2 --- /dev/null +++ b/src/NexusCad.Api/Controllers/UserGroupsController.cs @@ -0,0 +1,355 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NexusCad.Api.DTOs.Common; +using NexusCad.Api.DTOs.UserGroups; +using NexusCad.Core.Constants; +using NexusCad.Core.Entities; +using NexusCad.Core.Interfaces; +using NexusCad.Infrastructure.Data; +using NexusCad.Infrastructure.Identity; + +namespace NexusCad.Api.Controllers; + +/// +/// CRUD de UserGroups (grupos personalizados de usuarios) y gestión de miembros. +/// Distinto de Groups (workspaces / contenedores de proyectos) y de roles globales de Identity. +/// +[ApiController] +[Route("api/usergroups")] +[Produces("application/json")] +[Authorize(Roles = Roles.Admin)] +public class UserGroupsController : ControllerBase +{ + private readonly IUserGroupRepository _repo; + private readonly AppDbContext _context; + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public UserGroupsController( + IUserGroupRepository repo, + AppDbContext context, + UserManager userManager, + ILogger logger) + { + _repo = repo; + _context = context; + _userManager = userManager; + _logger = logger; + } + + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>>> GetAll() + { + var groups = await _repo.GetAllOrderedAsync(); + var ids = groups.Select(g => g.Id).ToList(); + var counts = await _context.UserGroupMembers + .Where(m => ids.Contains(m.UserGroupId)) + .GroupBy(m => m.UserGroupId) + .Select(x => new { x.Key, Count = x.Count() }) + .ToDictionaryAsync(x => x.Key, x => x.Count); + + var items = groups.Select(g => ToListItem(g, counts.GetValueOrDefault(g.Id, 0))).ToList(); + return Ok(ApiResponse>.SuccessResult(items)); + } + + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetById(Guid id) + { + var g = await _repo.GetByIdAsync(id); + if (g == null) + return NotFound(ApiResponse.ErrorResult("User group not found")); + + var memberCount = await _context.UserGroupMembers.CountAsync(m => m.UserGroupId == id); + return Ok(ApiResponse.SuccessResult(ToDto(g, memberCount))); + } + + [HttpPost] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task>> Create([FromBody] CreateUserGroupRequest request) + { + var userId = GetUserId(); + if (userId == null) + return Unauthorized(); + + if (await _repo.CodeExistsAsync(request.Code)) + return Conflict(ApiResponse.ErrorResult( + $"A user group with code '{request.Code}' already exists")); + + var group = new UserGroup + { + Id = Guid.NewGuid(), + Code = request.Code, + Name = request.Name, + Description = request.Description ?? string.Empty, + IconUrl = request.IconUrl, + CreatedBy = userId, + }; + + await _repo.AddAsync(group); + + _logger.LogInformation("Created user group {Code} ({Id}) by {UserId}", group.Code, group.Id, userId); + + return CreatedAtAction(nameof(GetById), new { id = group.Id }, + ApiResponse.SuccessResult(ToDto(group, 0))); + } + + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> Update(Guid id, [FromBody] UpdateUserGroupRequest request) + { + var group = await _repo.GetByIdAsync(id); + if (group == null) + return NotFound(ApiResponse.ErrorResult("User group not found")); + + group.Name = request.Name; + group.Description = request.Description ?? string.Empty; + group.IconUrl = request.IconUrl; + + await _repo.UpdateAsync(group); + + var memberCount = await _context.UserGroupMembers.CountAsync(m => m.UserGroupId == id); + return Ok(ApiResponse.SuccessResult(ToDto(group, memberCount))); + } + + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Delete(Guid id) + { + var group = await _repo.GetByIdAsync(id); + if (group == null) + return NotFound(); + + await _repo.DeleteAsync(group); + return NoContent(); + } + + // ── Members ────────────────────────────────────────────────────────────── + + [HttpGet("{id:guid}/members")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>>> GetMembers(Guid id) + { + var exists = await _context.UserGroups.AnyAsync(g => g.Id == id); + if (!exists) + return NotFound(ApiResponse>.ErrorResult("User group not found")); + + var members = await _context.UserGroupMembers + .Where(m => m.UserGroupId == id) + .ToListAsync(); + + var userIds = members.Select(m => m.UserId).ToList(); + var users = await _userManager.Users + .Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id); + + var dtos = members.Select(m => + { + users.TryGetValue(m.UserId, out var user); + return new UserGroupMemberDto + { + UserId = m.UserId, + UserName = user?.UserName, + Email = user?.Email, + FullName = user?.FullName, + AddedAt = m.AddedAt, + }; + }).ToList(); + + return Ok(ApiResponse>.SuccessResult(dtos)); + } + + [HttpPost("{id:guid}/members")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task>> AddMember(Guid id, [FromBody] AddUserGroupMemberRequest request) + { + var exists = await _context.UserGroups.AnyAsync(g => g.Id == id); + if (!exists) + return NotFound(ApiResponse.ErrorResult("User group not found")); + + var user = await _userManager.FindByIdAsync(request.UserId); + if (user == null) + return NotFound(ApiResponse.ErrorResult("User not found")); + + var existing = await _repo.GetMemberAsync(id, request.UserId); + if (existing != null) + return Conflict(ApiResponse.ErrorResult("User is already a member")); + + var member = await _repo.AddMemberAsync(id, request.UserId); + + return CreatedAtAction(nameof(GetMembers), new { id }, ApiResponse.SuccessResult( + new UserGroupMemberDto + { + UserId = member.UserId, + UserName = user.UserName, + Email = user.Email, + FullName = user.FullName, + AddedAt = member.AddedAt, + })); + } + + [HttpDelete("{id:guid}/members/{userId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task RemoveMember(Guid id, string userId) + { + await _repo.RemoveMemberAsync(id, userId); + return NoContent(); + } + + /// + /// Reemplaza el conjunto completo de miembros de un grupo de forma atómica. + /// + [HttpPut("{id:guid}/members")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>>> SetMembers( + Guid id, [FromBody] SetUserGroupMembersRequest request) + { + var exists = await _context.UserGroups.AnyAsync(g => g.Id == id); + if (!exists) + return NotFound(ApiResponse>.ErrorResult("User group not found")); + + var requestedIds = request.UserIds.Distinct().ToList(); + + // Validate that all requested users exist + var validUserIds = await _userManager.Users + .Where(u => requestedIds.Contains(u.Id)) + .Select(u => u.Id) + .ToListAsync(); + + var current = await _context.UserGroupMembers + .Where(m => m.UserGroupId == id) + .ToListAsync(); + + var toRemove = current.Where(m => !validUserIds.Contains(m.UserId)).ToList(); + _context.UserGroupMembers.RemoveRange(toRemove); + + var currentIds = current.Select(m => m.UserId).ToHashSet(); + foreach (var uid in validUserIds.Where(u => !currentIds.Contains(u))) + { + _context.UserGroupMembers.Add(new UserGroupMember + { + UserGroupId = id, + UserId = uid, + AddedAt = DateTime.UtcNow, + }); + } + + await _context.SaveChangesAsync(); + + return await GetMembers(id); + } + + // ── Proyectos accesibles ────────────────────────────────────────────────── + + /// + /// Devuelve los proyectos a los que tiene acceso este UserGroup. + /// + [HttpGet("{id:guid}/projects")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>>> GetProjects(Guid id) + { + var exists = await _context.UserGroups.AnyAsync(g => g.Id == id); + if (!exists) + return NotFound(ApiResponse>.ErrorResult("UserGroup not found")); + + var accesses = await _context.UserGroupProjectAccesses + .Where(a => a.UserGroupId == id) + .Include(a => a.Project) + .ThenInclude(p => p!.Group) + .ToListAsync(); + + var dtos = accesses.Select(a => new UserGroupProjectDto + { + ProjectId = a.ProjectId, + ProjectCode = a.Project?.Code ?? string.Empty, + ProjectName = a.Project?.Name ?? string.Empty, + GroupName = a.Project?.Group?.Name, + CreatedAt = a.CreatedAt, + }).ToList(); + + return Ok(ApiResponse>.SuccessResult(dtos)); + } + + /// + /// Reemplaza de forma atómica la lista de proyectos accesibles para este UserGroup. + /// Enviar lista vacía quita todos los accesos. + /// + [HttpPut("{id:guid}/projects")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>>> SetProjects( + Guid id, [FromBody] SetUserGroupProjectsRequest request) + { + var exists = await _context.UserGroups.AnyAsync(g => g.Id == id); + if (!exists) + return NotFound(ApiResponse>.ErrorResult("UserGroup not found")); + + // Validar que los proyectos existen + var validIds = await _context.Projects + .Where(p => request.ProjectIds.Contains(p.Id)) + .Select(p => p.Id) + .ToListAsync(); + + // Reemplazar + var existing = await _context.UserGroupProjectAccesses + .Where(a => a.UserGroupId == id).ToListAsync(); + _context.UserGroupProjectAccesses.RemoveRange(existing); + + foreach (var pid in validIds) + { + _context.UserGroupProjectAccesses.Add(new UserGroupProjectAccess + { + UserGroupId = id, + ProjectId = pid, + CreatedAt = DateTime.UtcNow, + }); + } + + await _context.SaveChangesAsync(); + + return await GetProjects(id); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier); + + private static UserGroupDto ToDto(UserGroup g, int memberCount) => new() + { + Id = g.Id, + Code = g.Code, + Name = g.Name, + Description = g.Description, + IconUrl = g.IconUrl, + CreatedBy = g.CreatedBy, + CreatedAt = g.CreatedAt, + UpdatedAt = g.UpdatedAt, + MemberCount = memberCount, + }; + + private static UserGroupListItemDto ToListItem(UserGroup g, int memberCount) => new() + { + Id = g.Id, + Code = g.Code, + Name = g.Name, + Description = g.Description, + IconUrl = g.IconUrl, + MemberCount = memberCount, + CreatedAt = g.CreatedAt, + UpdatedAt = g.UpdatedAt, + }; +} diff --git a/src/NexusCad.Api/Controllers/VariablesController.cs b/src/NexusCad.Api/Controllers/VariablesController.cs index 185429a..64eeda2 100644 --- a/src/NexusCad.Api/Controllers/VariablesController.cs +++ b/src/NexusCad.Api/Controllers/VariablesController.cs @@ -16,7 +16,7 @@ namespace NexusCad.Api.Controllers; [ApiController] [Route("api/projects/{projectId:guid}/variables")] [Produces("application/json")] -[Authorize(Roles = $"{Roles.Admin},{Roles.Autor}")] +[Authorize(Roles = Roles.Admin)] public class VariablesController : ControllerBase { private readonly AppDbContext _db; diff --git a/src/NexusCad.Api/DTOs/Auth/UserGroupMembershipDto.cs b/src/NexusCad.Api/DTOs/Auth/UserGroupMembershipDto.cs new file mode 100644 index 0000000..1428780 --- /dev/null +++ b/src/NexusCad.Api/DTOs/Auth/UserGroupMembershipDto.cs @@ -0,0 +1,29 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +namespace NexusCad.Api.DTOs.Auth; + +/// +/// Membresía de un usuario en un grupo, tal como la ve el admin desde la edición de usuario. +/// +public class UserGroupMembershipDto +{ + public Guid GroupId { get; set; } + public string GroupCode { get; set; } = string.Empty; + public string GroupName { get; set; } = string.Empty; + public string? GroupIconUrl { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class SetUserMembershipsRequest +{ + /// + /// Lista de grupos a los que debe pertenecer el usuario. Los no incluidos serán eliminados. + /// + public List Memberships { get; set; } = new(); +} + +public class UserMembershipItem +{ + public Guid GroupId { get; set; } +} diff --git a/src/NexusCad.Api/DTOs/Groups/GroupMemberDto.cs b/src/NexusCad.Api/DTOs/Groups/GroupMemberDto.cs index bc97a80..06ea1d1 100644 --- a/src/NexusCad.Api/DTOs/Groups/GroupMemberDto.cs +++ b/src/NexusCad.Api/DTOs/Groups/GroupMemberDto.cs @@ -2,7 +2,6 @@ // Licensed under AGPL v3 // using System.ComponentModel.DataAnnotations; -using NexusCad.Core.Enums; namespace NexusCad.Api.DTOs.Groups; @@ -11,7 +10,6 @@ public class GroupMemberDto public string UserId { get; set; } = string.Empty; public string? UserName { get; set; } public string? Email { get; set; } - public GroupRole Role { get; set; } public DateTime CreatedAt { get; set; } } @@ -19,7 +17,4 @@ public class AddMemberRequest { [Required] public string UserId { get; set; } = string.Empty; - - [Required] - public GroupRole Role { get; set; } = GroupRole.Author; } diff --git a/src/NexusCad.Api/DTOs/Groups/GroupProjectAccessDto.cs b/src/NexusCad.Api/DTOs/Groups/GroupProjectAccessDto.cs new file mode 100644 index 0000000..9932d87 --- /dev/null +++ b/src/NexusCad.Api/DTOs/Groups/GroupProjectAccessDto.cs @@ -0,0 +1,33 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +namespace NexusCad.Api.DTOs.Groups; + +/// +/// Acceso de un grupo a un proyecto concreto. +/// AccessLevel: 0 = Ver, 1 = Configurar +/// +public class GroupProjectAccessDto +{ + public Guid ProjectId { get; set; } + public string ProjectCode { get; set; } = string.Empty; + public string ProjectName { get; set; } = string.Empty; + public int AccessLevel { get; set; } // 0=View, 1=Configure +} + +public class SetGroupProjectAccessRequest +{ + /// + /// Lista completa de accesos. Reemplaza todos los accesos actuales del grupo. + /// Para quitar acceso a todos los proyectos, enviar lista vacía. + /// + public List Items { get; set; } = new(); +} + +public class ProjectAccessEntry +{ + public Guid ProjectId { get; set; } + + /// 0 = View, 1 = Configure + public int AccessLevel { get; set; } = 1; +} diff --git a/src/NexusCad.Api/DTOs/Projects/CreateProjectRequest.cs b/src/NexusCad.Api/DTOs/Projects/CreateProjectRequest.cs index 34327c3..311f00f 100644 --- a/src/NexusCad.Api/DTOs/Projects/CreateProjectRequest.cs +++ b/src/NexusCad.Api/DTOs/Projects/CreateProjectRequest.cs @@ -32,4 +32,11 @@ public class CreateProjectRequest public string FormSchemaJson { get; set; } = string.Empty; public string? IconUrl { get; set; } + + /// + /// Ruta personalizada donde el worker buscará los modelos SolidWorks master de este proyecto. + /// Si null, se usa StorageOptions.GetProjectModelsPath(Code). + /// + [StringLength(500, ErrorMessage = "ModelsPath cannot exceed 500 characters")] + public string? ModelsPath { get; set; } } diff --git a/src/NexusCad.Api/DTOs/Projects/ProjectDto.cs b/src/NexusCad.Api/DTOs/Projects/ProjectDto.cs index f8f2ed5..31a53d0 100644 --- a/src/NexusCad.Api/DTOs/Projects/ProjectDto.cs +++ b/src/NexusCad.Api/DTOs/Projects/ProjectDto.cs @@ -17,6 +17,11 @@ public class ProjectDto public ProjectStatus Status { get; set; } public string FormSchemaJson { get; set; } = string.Empty; public string? IconUrl { get; set; } + /// + /// Ruta personalizada de modelos SolidWorks master para este proyecto. + /// Null = usa la ruta por defecto calculada desde StorageOptions. + /// + public string? ModelsPath { get; set; } public int Version { get; set; } public string CreatedBy { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } diff --git a/src/NexusCad.Api/DTOs/Projects/UpdateProjectRequest.cs b/src/NexusCad.Api/DTOs/Projects/UpdateProjectRequest.cs index 7356404..7295021 100644 --- a/src/NexusCad.Api/DTOs/Projects/UpdateProjectRequest.cs +++ b/src/NexusCad.Api/DTOs/Projects/UpdateProjectRequest.cs @@ -20,4 +20,11 @@ public class UpdateProjectRequest public string? FormSchemaJson { get; set; } public string? IconUrl { get; set; } + + /// + /// Ruta personalizada donde el worker buscará los modelos SolidWorks master de este proyecto. + /// Si null, se usa StorageOptions.GetProjectModelsPath(Code). + /// + [StringLength(500, ErrorMessage = "ModelsPath cannot exceed 500 characters")] + public string? ModelsPath { get; set; } } diff --git a/src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs b/src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs new file mode 100644 index 0000000..ec7ffaf --- /dev/null +++ b/src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs @@ -0,0 +1,103 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using System.ComponentModel.DataAnnotations; + +namespace NexusCad.Api.DTOs.UserGroups; + +public class UserGroupDto +{ + public Guid Id { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string? IconUrl { get; set; } + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public int MemberCount { get; set; } +} + +public class UserGroupListItemDto +{ + public Guid Id { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string? IconUrl { get; set; } + public int MemberCount { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} + +public class CreateUserGroupRequest +{ + [Required, StringLength(80, MinimumLength = 2)] + [RegularExpression("^[a-z0-9][a-z0-9-]*$", + ErrorMessage = "Code must be lowercase letters, digits or '-'.")] + public string Code { get; set; } = string.Empty; + + [Required, StringLength(200, MinimumLength = 1)] + public string Name { get; set; } = string.Empty; + + [StringLength(1000)] + public string? Description { get; set; } + + [StringLength(500)] + public string? IconUrl { get; set; } +} + +public class UpdateUserGroupRequest +{ + [Required, StringLength(200, MinimumLength = 1)] + public string Name { get; set; } = string.Empty; + + [StringLength(1000)] + public string? Description { get; set; } + + [StringLength(500)] + public string? IconUrl { get; set; } +} + +public class UserGroupMemberDto +{ + public string UserId { get; set; } = string.Empty; + public string? UserName { get; set; } + public string? Email { get; set; } + public string? FullName { get; set; } + public DateTime AddedAt { get; set; } +} + +public class AddUserGroupMemberRequest +{ + [Required] + public string UserId { get; set; } = string.Empty; +} + +/// +/// Reemplaza todos los miembros de un grupo de una vez. +/// +public class SetUserGroupMembersRequest +{ + public List UserIds { get; set; } = new(); +} + +/// +/// Proyecto al que tiene acceso un UserGroup. +/// +public class UserGroupProjectDto +{ + public Guid ProjectId { get; set; } + public string ProjectCode { get; set; } = string.Empty; + public string ProjectName { get; set; } = string.Empty; + public string? GroupName { get; set; } + public DateTime CreatedAt { get; set; } +} + +/// +/// Reemplaza la lista de proyectos accesibles para un UserGroup. +/// +public class SetUserGroupProjectsRequest +{ + public List ProjectIds { get; set; } = new(); +} diff --git a/src/NexusCad.Api/NexusCad.Api.csproj b/src/NexusCad.Api/NexusCad.Api.csproj index c28d663..451a04e 100644 --- a/src/NexusCad.Api/NexusCad.Api.csproj +++ b/src/NexusCad.Api/NexusCad.Api.csproj @@ -26,7 +26,7 @@ - + diff --git a/src/NexusCad.Api/Program.cs b/src/NexusCad.Api/Program.cs index 2aae723..b43474a 100644 --- a/src/NexusCad.Api/Program.cs +++ b/src/NexusCad.Api/Program.cs @@ -57,7 +57,7 @@ Title = "NexusCad API", Version = "v1", Description = "API para configuración paramétrica de productos CAD con SolidWorks", - Contact = new Microsoft.OpenApi.Models.OpenApiContact + Contact = new Microsoft.OpenApi.OpenApiContact { Name = "NexusCad Team", Url = new Uri("https://github.com/JaviFRx/NexusCad") @@ -73,27 +73,20 @@ } // Configurar autenticación JWT en Swagger - c.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme + c.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.OpenApiSecurityScheme { Description = "JWT Authorization header usando el esquema Bearer. Ejemplo: \"Bearer {token}\"", Name = "Authorization", - In = Microsoft.OpenApi.Models.ParameterLocation.Header, - Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey, + In = Microsoft.OpenApi.ParameterLocation.Header, + Type = Microsoft.OpenApi.SecuritySchemeType.ApiKey, Scheme = "Bearer" }); - c.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement + c.AddSecurityRequirement(doc => new Microsoft.OpenApi.OpenApiSecurityRequirement { { - new Microsoft.OpenApi.Models.OpenApiSecurityScheme - { - Reference = new Microsoft.OpenApi.Models.OpenApiReference - { - Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, - Id = "Bearer" - } - }, - Array.Empty() + new Microsoft.OpenApi.OpenApiSecuritySchemeReference("Bearer", doc, null), + new List() } }); }); @@ -101,6 +94,10 @@ // Rules engine builder.Services.AddSingleton(); + // Storage configuration + builder.Services.Configure( + builder.Configuration.GetSection(NexusCad.Core.Configuration.StorageOptions.SectionName)); + // SignalR builder.Services.AddSignalR(); @@ -127,6 +124,7 @@ // Repositories builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/NexusCad.Api/appsettings.json b/src/NexusCad.Api/appsettings.json index cc1686b..af72a34 100644 --- a/src/NexusCad.Api/appsettings.json +++ b/src/NexusCad.Api/appsettings.json @@ -1,4 +1,5 @@ { + "Urls": "http://localhost:5140", "ConnectionStrings": { "DefaultConnection": "Host=localhost;Port=5432;Database=nexuscad;Username=nexuscad;Password=nexuscad_dev_password" }, @@ -12,6 +13,9 @@ "Serilog": { "SeqUrl": "http://localhost:5341" }, + "Storage": { + "RootPath": "C:\\NexusCad" + }, "JwtSettings": { "SecretKey": "NexusCad-Super-Secret-Key-For-Development-Only-Change-In-Production-12345678", "Issuer": "NexusCad.Api", diff --git a/src/NexusCad.Core/Configuration/StorageOptions.cs b/src/NexusCad.Core/Configuration/StorageOptions.cs new file mode 100644 index 0000000..35752b4 --- /dev/null +++ b/src/NexusCad.Core/Configuration/StorageOptions.cs @@ -0,0 +1,83 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +namespace NexusCad.Core.Configuration; + +/// +/// Configuración centralizada de rutas de almacenamiento de NexusCad. +/// Todas las rutas de archivos del sistema se derivan de aquí. +/// Registro en appsettings.json bajo la sección "Storage". +/// +public sealed class StorageOptions +{ + public const string SectionName = "Storage"; + + /// + /// Ruta raíz base de NexusCad en disco. + /// El resto de rutas, si no se especifican, se derivan de aquí. + /// Ejemplo: "C:\NexusCad" o "D:\Empresa\NexusCad" + /// + public string RootPath { get; set; } = @"C:\NexusCad"; + + /// + /// Donde se almacenan los modelos SolidWorks master capturados (plantillas). + /// Equivale al "workspace" de DriveWorks — archivos fuente que el worker copia y modifica. + /// Si null → {RootPath}\Models + /// + public string? ModelsPath { get; set; } + + /// + /// Donde se escriben los archivos SolidWorks generados por el worker (SLDPRT, SLDASM, SLDDRW). + /// Si null → {RootPath}\Output + /// + public string? OutputPath { get; set; } + + /// + /// Donde se guardan los documentos generados (PDF, DOCX) listos para descarga. + /// Si null → {RootPath}\Documents + /// + public string? DocumentsPath { get; set; } + + /// + /// Donde se almacenan los bundles de formularios subidos por `nexuscad push` (@nexuscad/cli). + /// La API carga estos bundles en el iframe sandbox del runtime web. + /// Si null → {RootPath}\Bundles + /// + public string? BundlesPath { get; set; } + + /// + /// Directorio de trabajo temporal para jobs en curso. + /// El worker crea un subdirectorio por job y lo elimina al completar. + /// Si null → {RootPath}\Temp + /// + public string? TempPath { get; set; } + + // ── Resolvers ────────────────────────────────────────────────────────────── + + public string GetModelsPath() => ModelsPath ?? Path.Combine(RootPath, "Models"); + public string GetOutputPath() => OutputPath ?? Path.Combine(RootPath, "Output"); + public string GetDocumentsPath() => DocumentsPath ?? Path.Combine(RootPath, "Documents"); + public string GetBundlesPath() => BundlesPath ?? Path.Combine(RootPath, "Bundles"); + public string GetTempPath() => TempPath ?? Path.Combine(RootPath, "Temp"); + + /// + /// Directorio de modelos master de un proyecto concreto. + /// Ejemplo: {ModelsPath}\CHAIR-V2\ + /// + public string GetProjectModelsPath(string projectCode) => + Path.Combine(GetModelsPath(), projectCode); + + /// + /// Directorio de salida de un job concreto. + /// Ejemplo: {OutputPath}\{jobId}\ + /// + public string GetJobOutputPath(Guid jobId) => + Path.Combine(GetOutputPath(), jobId.ToString()); + + /// + /// Directorio temporal de trabajo de un job concreto. + /// Ejemplo: {TempPath}\{jobId}\ + /// + public string GetJobTempPath(Guid jobId) => + Path.Combine(GetTempPath(), jobId.ToString()); +} diff --git a/src/NexusCad.Core/Constants/Roles.cs b/src/NexusCad.Core/Constants/Roles.cs index 5dc9b8b..58e50a9 100644 --- a/src/NexusCad.Core/Constants/Roles.cs +++ b/src/NexusCad.Core/Constants/Roles.cs @@ -4,32 +4,18 @@ namespace NexusCad.Core.Constants; /// -/// Roles de usuario del sistema +/// Roles de usuario del sistema. El único rol global es Admin; el resto del +/// control de acceso se gestiona mediante UserGroups y sus proyectos asignados. /// public static class Roles { /// - /// Administrador - acceso total al sistema + /// Administrador - acceso total al sistema. /// public const string Admin = "Admin"; /// - /// Autor - diseña proyectos, captura modelos, escribe reglas y formularios + /// Lista de todos los roles disponibles. /// - public const string Autor = "Autor"; - - /// - /// Comercial - rellena especificaciones, genera cotizaciones - /// - public const string Comercial = "Comercial"; - - /// - /// Cliente - solo formularios públicos, acceso limitado - /// - public const string Cliente = "Cliente"; - - /// - /// Lista de todos los roles disponibles - /// - public static readonly string[] All = { Admin, Autor, Comercial, Cliente }; + public static readonly string[] All = { Admin }; } diff --git a/src/NexusCad.Core/Entities/Group.cs b/src/NexusCad.Core/Entities/Group.cs index f10e72c..e0fc287 100644 --- a/src/NexusCad.Core/Entities/Group.cs +++ b/src/NexusCad.Core/Entities/Group.cs @@ -50,4 +50,9 @@ public class Group public ICollection Memberships { get; set; } = new List(); public ICollection Projects { get; set; } = new List(); + + /// + /// Acceso explícito a proyectos. Si está vacío, todos los proyectos del grupo son accesibles (modo legacy). + /// + public ICollection ProjectAccesses { get; set; } = new List(); } diff --git a/src/NexusCad.Core/Entities/GroupMembership.cs b/src/NexusCad.Core/Entities/GroupMembership.cs index 437c86a..8a0ce27 100644 --- a/src/NexusCad.Core/Entities/GroupMembership.cs +++ b/src/NexusCad.Core/Entities/GroupMembership.cs @@ -1,12 +1,10 @@ // Copyright (C) 2026 JaviFRx // Licensed under AGPL v3 // -using NexusCad.Core.Enums; - namespace NexusCad.Core.Entities; /// -/// Asociación N:M entre Group y ApplicationUser con un rol específico dentro del grupo. +/// Asociación N:M entre Group (workspace) y ApplicationUser. /// PK compuesta (GroupId, UserId). /// public class GroupMembership @@ -18,8 +16,6 @@ public class GroupMembership /// public string UserId { get; set; } = string.Empty; - public GroupRole Role { get; set; } = GroupRole.Author; - public DateTime CreatedAt { get; set; } public Group? Group { get; set; } diff --git a/src/NexusCad.Core/Entities/GroupProjectAccess.cs b/src/NexusCad.Core/Entities/GroupProjectAccess.cs new file mode 100644 index 0000000..2442379 --- /dev/null +++ b/src/NexusCad.Core/Entities/GroupProjectAccess.cs @@ -0,0 +1,32 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using NexusCad.Core.Enums; + +namespace NexusCad.Core.Entities; + +/// +/// Control de acceso de un grupo (workspace) a un proyecto concreto. +/// PK compuesta (GroupId, ProjectId). +/// Si NO existe entrada para un proyecto, ese proyecto NO es visible para el grupo +/// (excepto para Admins globales que ven todo). +/// Si el grupo no tiene NINGUNA entrada, se asume acceso Configure a todos sus propios proyectos +/// (modo legacy/backward-compatible). +/// +public class GroupProjectAccess +{ + public Guid GroupId { get; set; } + + public Guid ProjectId { get; set; } + + /// + /// View = solo consulta; Configure = puede ejecutar y generar. + /// + public ProjectAccessLevel AccessLevel { get; set; } = ProjectAccessLevel.Configure; + + public DateTime CreatedAt { get; set; } + + // Navigation + public Group? Group { get; set; } + public Project? Project { get; set; } +} diff --git a/src/NexusCad.Core/Entities/Project.cs b/src/NexusCad.Core/Entities/Project.cs index 8cf6876..f6ee29f 100644 --- a/src/NexusCad.Core/Entities/Project.cs +++ b/src/NexusCad.Core/Entities/Project.cs @@ -75,6 +75,13 @@ public class Project /// public DateTime? PublishedAt { get; set; } + /// + /// Ruta en disco donde se almacenan los modelos SolidWorks master de este proyecto. + /// Sobreescribe Group.WorkspacePath y Storage.ModelsPath para este proyecto concreto. + /// Si null, el worker usa StorageOptions.GetProjectModelsPath(Code). + /// + public string? ModelsPath { get; set; } + /// /// Fecha de última modificación /// diff --git a/src/NexusCad.Core/Entities/UserGroup.cs b/src/NexusCad.Core/Entities/UserGroup.cs new file mode 100644 index 0000000..2c269ca --- /dev/null +++ b/src/NexusCad.Core/Entities/UserGroup.cs @@ -0,0 +1,38 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +namespace NexusCad.Core.Entities; + +/// +/// Grupo personalizado de usuarios (distinto de los roles globales de Identity +/// y de Group/Workspace que es contenedor de proyectos). Sirve para agrupar +/// usuarios en categorías definidas por el admin (ej. "Comercial Madrid", +/// "Autores externos") y, en el futuro, otorgar permisos a workspaces a nivel +/// de grupo en lugar de usuario. +/// +public class UserGroup +{ + public Guid Id { get; set; } + + /// Slug único (ej. "comercial-madrid"). + public string Code { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public string? IconUrl { get; set; } + + public string CreatedBy { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } + + public DateTime UpdatedAt { get; set; } + + public ICollection Members { get; set; } = new List(); + + /// + /// Proyectos a los que tienen acceso los miembros de este grupo. + /// + public ICollection ProjectAccesses { get; set; } = new List(); +} diff --git a/src/NexusCad.Core/Entities/UserGroupMember.cs b/src/NexusCad.Core/Entities/UserGroupMember.cs new file mode 100644 index 0000000..13b088f --- /dev/null +++ b/src/NexusCad.Core/Entities/UserGroupMember.cs @@ -0,0 +1,20 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +namespace NexusCad.Core.Entities; + +/// +/// Pertenencia de un ApplicationUser a un UserGroup. +/// PK compuesta (UserGroupId, UserId). +/// +public class UserGroupMember +{ + public Guid UserGroupId { get; set; } + + /// Id del ApplicationUser (string, Identity). + public string UserId { get; set; } = string.Empty; + + public DateTime AddedAt { get; set; } + + public UserGroup? UserGroup { get; set; } +} diff --git a/src/NexusCad.Core/Entities/UserGroupProjectAccess.cs b/src/NexusCad.Core/Entities/UserGroupProjectAccess.cs new file mode 100644 index 0000000..df89aa2 --- /dev/null +++ b/src/NexusCad.Core/Entities/UserGroupProjectAccess.cs @@ -0,0 +1,22 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +namespace NexusCad.Core.Entities; + +/// +/// Acceso de un UserGroup a un Project concreto. +/// PK compuesta (UserGroupId, ProjectId). +/// Si un UserGroup no tiene ninguna entrada, sus miembros no ven ningún proyecto. +/// +public class UserGroupProjectAccess +{ + public Guid UserGroupId { get; set; } + + public Guid ProjectId { get; set; } + + public DateTime CreatedAt { get; set; } + + // Navigation + public UserGroup? UserGroup { get; set; } + public Project? Project { get; set; } +} diff --git a/src/NexusCad.Core/Enums/ProjectAccessLevel.cs b/src/NexusCad.Core/Enums/ProjectAccessLevel.cs new file mode 100644 index 0000000..67233b2 --- /dev/null +++ b/src/NexusCad.Core/Enums/ProjectAccessLevel.cs @@ -0,0 +1,16 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +namespace NexusCad.Core.Enums; + +/// +/// Nivel de acceso de un grupo a un proyecto concreto. +/// +public enum ProjectAccessLevel +{ + /// Solo puede ver el formulario y el historial de especificaciones. + View = 0, + + /// Puede ejecutar el configurador y generar documentos/modelos. + Configure = 1, +} diff --git a/src/NexusCad.Core/Interfaces/IGroupRepository.cs b/src/NexusCad.Core/Interfaces/IGroupRepository.cs index b5779d4..ef88665 100644 --- a/src/NexusCad.Core/Interfaces/IGroupRepository.cs +++ b/src/NexusCad.Core/Interfaces/IGroupRepository.cs @@ -2,7 +2,6 @@ // Licensed under AGPL v3 // using NexusCad.Core.Entities; -using NexusCad.Core.Enums; namespace NexusCad.Core.Interfaces; @@ -20,7 +19,7 @@ public interface IGroupRepository : IRepository Task GetMembershipAsync(Guid groupId, string userId, CancellationToken cancellationToken = default); - Task AddMembershipAsync(Guid groupId, string userId, GroupRole role, CancellationToken cancellationToken = default); + Task AddMembershipAsync(Guid groupId, string userId, CancellationToken cancellationToken = default); Task RemoveMembershipAsync(Guid groupId, string userId, CancellationToken cancellationToken = default); } diff --git a/src/NexusCad.Core/Interfaces/IUserGroupRepository.cs b/src/NexusCad.Core/Interfaces/IUserGroupRepository.cs new file mode 100644 index 0000000..bf5771b --- /dev/null +++ b/src/NexusCad.Core/Interfaces/IUserGroupRepository.cs @@ -0,0 +1,27 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using NexusCad.Core.Entities; + +namespace NexusCad.Core.Interfaces; + +public interface IUserGroupRepository : IRepository +{ + Task GetByCodeAsync(string code, CancellationToken cancellationToken = default); + + Task GetWithMembersAsync(Guid id, CancellationToken cancellationToken = default); + + Task> GetAllOrderedAsync(CancellationToken cancellationToken = default); + + Task CodeExistsAsync(string code, Guid? excludeId = null, CancellationToken cancellationToken = default); + + Task> GetMembersAsync(Guid userGroupId, CancellationToken cancellationToken = default); + + Task GetMemberAsync(Guid userGroupId, string userId, CancellationToken cancellationToken = default); + + Task AddMemberAsync(Guid userGroupId, string userId, CancellationToken cancellationToken = default); + + Task RemoveMemberAsync(Guid userGroupId, string userId, CancellationToken cancellationToken = default); + + Task> GetGroupsForUserAsync(string userId, CancellationToken cancellationToken = default); +} diff --git a/src/NexusCad.Infrastructure/Data/AppDbContext.cs b/src/NexusCad.Infrastructure/Data/AppDbContext.cs index 5ab358f..5575537 100644 --- a/src/NexusCad.Infrastructure/Data/AppDbContext.cs +++ b/src/NexusCad.Infrastructure/Data/AppDbContext.cs @@ -20,6 +20,7 @@ public AppDbContext(DbContextOptions options) : base(options) // Entidades de dominio public DbSet Groups => Set(); public DbSet GroupMemberships => Set(); + public DbSet GroupProjectAccesses => Set(); public DbSet Projects => Set(); public DbSet Specifications => Set(); public DbSet Variables => Set(); @@ -35,6 +36,9 @@ public AppDbContext(DbContextOptions options) : base(options) public DbSet CapturedReplacementModels => Set(); public DbSet CapturedFileFormats => Set(); public DbSet DimensionRules => Set(); + public DbSet UserGroups => Set(); + public DbSet UserGroupMembers => Set(); + public DbSet UserGroupProjectAccesses => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -80,6 +84,25 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.UserId); }); + // Configurar GroupProjectAccess (PK compuesta) + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.GroupId, e.ProjectId }); + + entity.HasOne(e => e.Group) + .WithMany(g => g.ProjectAccesses) + .HasForeignKey(e => e.GroupId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.Project) + .WithMany() + .HasForeignKey(e => e.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(e => e.GroupId); + entity.HasIndex(e => e.ProjectId); + }); + // Configurar Project modelBuilder.Entity(entity => { @@ -383,6 +406,50 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); }); + // UserGroup — grupos personalizados de usuarios (distintos de Group/Workspace y de roles globales) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Code).IsRequired().HasMaxLength(80); + entity.Property(e => e.Name).IsRequired().HasMaxLength(200); + entity.Property(e => e.Description).HasMaxLength(1000); + entity.Property(e => e.IconUrl).HasMaxLength(500); + entity.Property(e => e.CreatedBy).IsRequired().HasMaxLength(450); + + entity.HasIndex(e => e.Code).IsUnique(); + entity.HasIndex(e => e.CreatedAt); + + entity.HasMany(g => g.Members) + .WithOne(m => m.UserGroup) + .HasForeignKey(m => m.UserGroupId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.UserGroupId, e.UserId }); + entity.Property(e => e.UserId).IsRequired().HasMaxLength(450); + entity.HasIndex(e => e.UserId); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.UserGroupId, e.ProjectId }); + + entity.HasOne(e => e.UserGroup) + .WithMany(g => g.ProjectAccesses) + .HasForeignKey(e => e.UserGroupId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.Project) + .WithMany() + .HasForeignKey(e => e.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(e => e.UserGroupId); + entity.HasIndex(e => e.ProjectId); + }); + // Configurar GeneratedDocument modelBuilder.Entity(entity => { @@ -511,6 +578,15 @@ private void UpdateTimestamps() dimRule.CreatedAt = now; dimRule.UpdatedAt = now; } + else if (entry.Entity is UserGroup userGroup) + { + userGroup.CreatedAt = now; + userGroup.UpdatedAt = now; + } + else if (entry.Entity is UserGroupMember ugMember) + { + ugMember.AddedAt = now; + } } else if (entry.State == EntityState.Modified) { @@ -542,6 +618,10 @@ private void UpdateTimestamps() { dimRuleUpd.UpdatedAt = now; } + else if (entry.Entity is UserGroup userGroupUpd) + { + userGroupUpd.UpdatedAt = now; + } } } } diff --git a/src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.Designer.cs b/src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.Designer.cs new file mode 100644 index 0000000..16b1a89 --- /dev/null +++ b/src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.Designer.cs @@ -0,0 +1,1358 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NexusCad.Infrastructure.Data; + +#nullable disable + +namespace NexusCad.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260529091846_AddProjectModelsPath")] + partial class AddProjectModelsPath + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.27"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedCustomProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PropertyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName", "Configuration") + .IsUnique(); + + b.ToTable("CapturedCustomProperties"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDimension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasColumnType("REAL"); + + b.Property("FeatureName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SketchName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName") + .IsUnique(); + + b.ToTable("CapturedDimensions"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDrawing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExportDxf") + .HasColumnType("INTEGER"); + + b.Property("ExportPdf") + .HasColumnType("INTEGER"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SheetName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "RelativePath", "SheetName") + .IsUnique(); + + b.ToTable("CapturedDrawings"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFeature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultSuppressed") + .HasColumnType("INTEGER"); + + b.Property("FeatureType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName") + .IsUnique(); + + b.ToTable("CapturedFeatures"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFileFormat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OptionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Format") + .IsUnique(); + + b.ToTable("CapturedFileFormats"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IsMaster") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMaster"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ProjectId", "RelativePath", "Configuration") + .IsUnique(); + + b.ToTable("CapturedModels"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedReplacementModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PathsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Token") + .IsUnique(); + + b.ToTable("CapturedReplacementModels"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DimensionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedDimensionId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Expression") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedDimensionId"); + + b.HasIndex("ProjectId", "CapturedDimensionId") + .IsUnique(); + + b.ToTable("DimensionRules"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DocumentTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("DisplayOrder") + .HasColumnType("INTEGER"); + + b.Property("DocxFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExpectedVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TemplateContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("DisplayOrder"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("DocumentTemplates"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GeneratedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("FileSizeBytes") + .HasColumnType("INTEGER"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("SpecificationId") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SpecificationId"); + + b.HasIndex("TemplateId"); + + b.ToTable("GeneratedDocuments"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CalculatedVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("GeneratedFilesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("InputVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProjectCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SpecificationId") + .HasColumnType("TEXT"); + + b.Property("StackTrace") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SpecificationId"); + + b.HasIndex("Status"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("GenerationJobs"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AutopilotSettingsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SecuritySettingsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WorkspacePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupMembership", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("GroupId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.LookupTable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("GroupId", "Name") + .IsUnique(); + + b.ToTable("LookupTables"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("FormSchemaJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PublishedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("GroupId"); + + b.HasIndex("Status"); + + b.HasIndex("GroupId", "Code") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Specification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("ComputedValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("GenerationStartedAt") + .HasColumnType("TEXT"); + + b.Property("InputValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Status"); + + b.HasIndex("ProjectId", "Status"); + + b.ToTable("Specifications"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Variable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Dependencies") + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EvaluationOrder") + .HasColumnType("INTEGER"); + + b.Property("Expression") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("IsReadOnly") + .HasColumnType("INTEGER"); + + b.Property("IsRequired") + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EvaluationOrder"); + + b.HasIndex("IsReadOnly"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Variables"); + }); + + modelBuilder.Entity("NexusCad.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedCustomProperty", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("CustomProperties") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDimension", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Dimensions") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDrawing", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Drawings") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFeature", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Features") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFileFormat", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("FileFormats") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedReplacementModel", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DimensionRule", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedDimension", "CapturedDimension") + .WithMany() + .HasForeignKey("CapturedDimensionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedDimension"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GeneratedDocument", b => + { + b.HasOne("NexusCad.Core.Entities.Specification", "Specification") + .WithMany() + .HasForeignKey("SpecificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.DocumentTemplate", "Template") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Specification"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GenerationJob", b => + { + b.HasOne("NexusCad.Core.Entities.Specification", "Specification") + .WithMany() + .HasForeignKey("SpecificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Specification"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupMembership", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.LookupTable", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("Projects") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Specification", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany("Specifications") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Variable", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany("Variables") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.Navigation("CustomProperties"); + + b.Navigation("Dimensions"); + + b.Navigation("Drawings"); + + b.Navigation("Features"); + + b.Navigation("FileFormats"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Group", b => + { + b.Navigation("Memberships"); + + b.Navigation("Projects"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.Navigation("Specifications"); + + b.Navigation("Variables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.cs b/src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.cs new file mode 100644 index 0000000..6ccdd71 --- /dev/null +++ b/src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusCad.Infrastructure.Migrations +{ + /// + public partial class AddProjectModelsPath : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ModelsPath", + table: "Projects", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ModelsPath", + table: "Projects"); + } + } +} diff --git a/src/NexusCad.Infrastructure/Migrations/20260529103851_AddGroupProjectAccess.Designer.cs b/src/NexusCad.Infrastructure/Migrations/20260529103851_AddGroupProjectAccess.Designer.cs new file mode 100644 index 0000000..db16e89 --- /dev/null +++ b/src/NexusCad.Infrastructure/Migrations/20260529103851_AddGroupProjectAccess.Designer.cs @@ -0,0 +1,1405 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NexusCad.Infrastructure.Data; + +#nullable disable + +namespace NexusCad.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260529103851_AddGroupProjectAccess")] + partial class AddGroupProjectAccess + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.27"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedCustomProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PropertyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName", "Configuration") + .IsUnique(); + + b.ToTable("CapturedCustomProperties"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDimension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasColumnType("REAL"); + + b.Property("FeatureName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SketchName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName") + .IsUnique(); + + b.ToTable("CapturedDimensions"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDrawing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExportDxf") + .HasColumnType("INTEGER"); + + b.Property("ExportPdf") + .HasColumnType("INTEGER"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SheetName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "RelativePath", "SheetName") + .IsUnique(); + + b.ToTable("CapturedDrawings"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFeature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultSuppressed") + .HasColumnType("INTEGER"); + + b.Property("FeatureType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName") + .IsUnique(); + + b.ToTable("CapturedFeatures"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFileFormat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OptionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Format") + .IsUnique(); + + b.ToTable("CapturedFileFormats"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IsMaster") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMaster"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ProjectId", "RelativePath", "Configuration") + .IsUnique(); + + b.ToTable("CapturedModels"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedReplacementModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PathsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Token") + .IsUnique(); + + b.ToTable("CapturedReplacementModels"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DimensionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedDimensionId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Expression") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedDimensionId"); + + b.HasIndex("ProjectId", "CapturedDimensionId") + .IsUnique(); + + b.ToTable("DimensionRules"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DocumentTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("DisplayOrder") + .HasColumnType("INTEGER"); + + b.Property("DocxFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExpectedVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TemplateContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("DisplayOrder"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("DocumentTemplates"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GeneratedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("FileSizeBytes") + .HasColumnType("INTEGER"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("SpecificationId") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SpecificationId"); + + b.HasIndex("TemplateId"); + + b.ToTable("GeneratedDocuments"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CalculatedVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("GeneratedFilesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("InputVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProjectCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SpecificationId") + .HasColumnType("TEXT"); + + b.Property("StackTrace") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SpecificationId"); + + b.HasIndex("Status"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("GenerationJobs"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AutopilotSettingsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SecuritySettingsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WorkspacePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupMembership", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("GroupId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupProjectAccess", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("AccessLevel") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "ProjectId"); + + b.HasIndex("GroupId"); + + b.HasIndex("ProjectId"); + + b.ToTable("GroupProjectAccesses"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.LookupTable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("GroupId", "Name") + .IsUnique(); + + b.ToTable("LookupTables"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("FormSchemaJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ModelsPath") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PublishedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("GroupId"); + + b.HasIndex("Status"); + + b.HasIndex("GroupId", "Code") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Specification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("ComputedValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("GenerationStartedAt") + .HasColumnType("TEXT"); + + b.Property("InputValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Status"); + + b.HasIndex("ProjectId", "Status"); + + b.ToTable("Specifications"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Variable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Dependencies") + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EvaluationOrder") + .HasColumnType("INTEGER"); + + b.Property("Expression") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("IsReadOnly") + .HasColumnType("INTEGER"); + + b.Property("IsRequired") + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EvaluationOrder"); + + b.HasIndex("IsReadOnly"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Variables"); + }); + + modelBuilder.Entity("NexusCad.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedCustomProperty", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("CustomProperties") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDimension", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Dimensions") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDrawing", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Drawings") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFeature", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Features") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFileFormat", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("FileFormats") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedReplacementModel", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DimensionRule", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedDimension", "CapturedDimension") + .WithMany() + .HasForeignKey("CapturedDimensionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedDimension"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GeneratedDocument", b => + { + b.HasOne("NexusCad.Core.Entities.Specification", "Specification") + .WithMany() + .HasForeignKey("SpecificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.DocumentTemplate", "Template") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Specification"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GenerationJob", b => + { + b.HasOne("NexusCad.Core.Entities.Specification", "Specification") + .WithMany() + .HasForeignKey("SpecificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Specification"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupMembership", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupProjectAccess", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("ProjectAccesses") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.LookupTable", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("Projects") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Specification", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany("Specifications") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Variable", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany("Variables") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.Navigation("CustomProperties"); + + b.Navigation("Dimensions"); + + b.Navigation("Drawings"); + + b.Navigation("Features"); + + b.Navigation("FileFormats"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Group", b => + { + b.Navigation("Memberships"); + + b.Navigation("ProjectAccesses"); + + b.Navigation("Projects"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.Navigation("Specifications"); + + b.Navigation("Variables"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NexusCad.Infrastructure/Migrations/20260529103851_AddGroupProjectAccess.cs b/src/NexusCad.Infrastructure/Migrations/20260529103851_AddGroupProjectAccess.cs new file mode 100644 index 0000000..dbfe376 --- /dev/null +++ b/src/NexusCad.Infrastructure/Migrations/20260529103851_AddGroupProjectAccess.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusCad.Infrastructure.Migrations +{ + /// + public partial class AddGroupProjectAccess : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "GroupProjectAccesses", + columns: table => new + { + GroupId = table.Column(type: "TEXT", nullable: false), + ProjectId = table.Column(type: "TEXT", nullable: false), + AccessLevel = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GroupProjectAccesses", x => new { x.GroupId, x.ProjectId }); + table.ForeignKey( + name: "FK_GroupProjectAccesses_Groups_GroupId", + column: x => x.GroupId, + principalTable: "Groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GroupProjectAccesses_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GroupProjectAccesses_GroupId", + table: "GroupProjectAccesses", + column: "GroupId"); + + migrationBuilder.CreateIndex( + name: "IX_GroupProjectAccesses_ProjectId", + table: "GroupProjectAccesses", + column: "ProjectId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GroupProjectAccesses"); + } + } +} diff --git a/src/NexusCad.Infrastructure/Migrations/20260601074349_AddUserGroups.Designer.cs b/src/NexusCad.Infrastructure/Migrations/20260601074349_AddUserGroups.Designer.cs new file mode 100644 index 0000000..73b94a2 --- /dev/null +++ b/src/NexusCad.Infrastructure/Migrations/20260601074349_AddUserGroups.Designer.cs @@ -0,0 +1,1486 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NexusCad.Infrastructure.Data; + +#nullable disable + +namespace NexusCad.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260601074349_AddUserGroups")] + partial class AddUserGroups + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.27"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedCustomProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PropertyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName", "Configuration") + .IsUnique(); + + b.ToTable("CapturedCustomProperties"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDimension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasColumnType("REAL"); + + b.Property("FeatureName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SketchName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName") + .IsUnique(); + + b.ToTable("CapturedDimensions"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDrawing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExportDxf") + .HasColumnType("INTEGER"); + + b.Property("ExportPdf") + .HasColumnType("INTEGER"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SheetName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "RelativePath", "SheetName") + .IsUnique(); + + b.ToTable("CapturedDrawings"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFeature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultSuppressed") + .HasColumnType("INTEGER"); + + b.Property("FeatureType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName") + .IsUnique(); + + b.ToTable("CapturedFeatures"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFileFormat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OptionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Format") + .IsUnique(); + + b.ToTable("CapturedFileFormats"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IsMaster") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMaster"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ProjectId", "RelativePath", "Configuration") + .IsUnique(); + + b.ToTable("CapturedModels"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedReplacementModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PathsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Token") + .IsUnique(); + + b.ToTable("CapturedReplacementModels"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DimensionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedDimensionId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Expression") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedDimensionId"); + + b.HasIndex("ProjectId", "CapturedDimensionId") + .IsUnique(); + + b.ToTable("DimensionRules"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DocumentTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("DisplayOrder") + .HasColumnType("INTEGER"); + + b.Property("DocxFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExpectedVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TemplateContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("DisplayOrder"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("DocumentTemplates"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GeneratedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("FileSizeBytes") + .HasColumnType("INTEGER"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("SpecificationId") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SpecificationId"); + + b.HasIndex("TemplateId"); + + b.ToTable("GeneratedDocuments"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CalculatedVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("GeneratedFilesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("InputVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProjectCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SpecificationId") + .HasColumnType("TEXT"); + + b.Property("StackTrace") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SpecificationId"); + + b.HasIndex("Status"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("GenerationJobs"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AutopilotSettingsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SecuritySettingsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WorkspacePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupMembership", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("GroupId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupProjectAccess", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("AccessLevel") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "ProjectId"); + + b.HasIndex("GroupId"); + + b.HasIndex("ProjectId"); + + b.ToTable("GroupProjectAccesses"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.LookupTable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("GroupId", "Name") + .IsUnique(); + + b.ToTable("LookupTables"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("FormSchemaJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ModelsPath") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PublishedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("GroupId"); + + b.HasIndex("Status"); + + b.HasIndex("GroupId", "Code") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Specification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("ComputedValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("GenerationStartedAt") + .HasColumnType("TEXT"); + + b.Property("InputValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Status"); + + b.HasIndex("ProjectId", "Status"); + + b.ToTable("Specifications"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.ToTable("UserGroups"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroupMember", b => + { + b.Property("UserGroupId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserGroupId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("UserGroupMembers"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Variable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Dependencies") + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EvaluationOrder") + .HasColumnType("INTEGER"); + + b.Property("Expression") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("IsReadOnly") + .HasColumnType("INTEGER"); + + b.Property("IsRequired") + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EvaluationOrder"); + + b.HasIndex("IsReadOnly"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Variables"); + }); + + modelBuilder.Entity("NexusCad.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedCustomProperty", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("CustomProperties") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDimension", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Dimensions") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDrawing", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Drawings") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFeature", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Features") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFileFormat", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("FileFormats") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedReplacementModel", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DimensionRule", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedDimension", "CapturedDimension") + .WithMany() + .HasForeignKey("CapturedDimensionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedDimension"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GeneratedDocument", b => + { + b.HasOne("NexusCad.Core.Entities.Specification", "Specification") + .WithMany() + .HasForeignKey("SpecificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.DocumentTemplate", "Template") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Specification"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GenerationJob", b => + { + b.HasOne("NexusCad.Core.Entities.Specification", "Specification") + .WithMany() + .HasForeignKey("SpecificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Specification"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupMembership", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupProjectAccess", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("ProjectAccesses") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.LookupTable", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("Projects") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Specification", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany("Specifications") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroupMember", b => + { + b.HasOne("NexusCad.Core.Entities.UserGroup", "UserGroup") + .WithMany("Members") + .HasForeignKey("UserGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserGroup"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Variable", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany("Variables") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.Navigation("CustomProperties"); + + b.Navigation("Dimensions"); + + b.Navigation("Drawings"); + + b.Navigation("Features"); + + b.Navigation("FileFormats"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Group", b => + { + b.Navigation("Memberships"); + + b.Navigation("ProjectAccesses"); + + b.Navigation("Projects"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.Navigation("Specifications"); + + b.Navigation("Variables"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroup", b => + { + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NexusCad.Infrastructure/Migrations/20260601074349_AddUserGroups.cs b/src/NexusCad.Infrastructure/Migrations/20260601074349_AddUserGroups.cs new file mode 100644 index 0000000..cf75498 --- /dev/null +++ b/src/NexusCad.Infrastructure/Migrations/20260601074349_AddUserGroups.cs @@ -0,0 +1,78 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusCad.Infrastructure.Migrations +{ + /// + public partial class AddUserGroups : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserGroups", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Code = table.Column(type: "TEXT", maxLength: 80, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 1000, nullable: false), + IconUrl = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedBy = table.Column(type: "TEXT", maxLength: 450, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserGroups", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UserGroupMembers", + columns: table => new + { + UserGroupId = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", maxLength: 450, nullable: false), + AddedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserGroupMembers", x => new { x.UserGroupId, x.UserId }); + table.ForeignKey( + name: "FK_UserGroupMembers_UserGroups_UserGroupId", + column: x => x.UserGroupId, + principalTable: "UserGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserGroupMembers_UserId", + table: "UserGroupMembers", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserGroups_Code", + table: "UserGroups", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserGroups_CreatedAt", + table: "UserGroups", + column: "CreatedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserGroupMembers"); + + migrationBuilder.DropTable( + name: "UserGroups"); + } + } +} diff --git a/src/NexusCad.Infrastructure/Migrations/20260601111304_AddUserGroupProjectAccess_RemoveMembershipRole.Designer.cs b/src/NexusCad.Infrastructure/Migrations/20260601111304_AddUserGroupProjectAccess_RemoveMembershipRole.Designer.cs new file mode 100644 index 0000000..a8569d5 --- /dev/null +++ b/src/NexusCad.Infrastructure/Migrations/20260601111304_AddUserGroupProjectAccess_RemoveMembershipRole.Designer.cs @@ -0,0 +1,1524 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NexusCad.Infrastructure.Data; + +#nullable disable + +namespace NexusCad.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260601111304_AddUserGroupProjectAccess_RemoveMembershipRole")] + partial class AddUserGroupProjectAccess_RemoveMembershipRole + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.27"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedCustomProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PropertyType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName", "Configuration") + .IsUnique(); + + b.ToTable("CapturedCustomProperties"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDimension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasColumnType("REAL"); + + b.Property("FeatureName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SketchName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName") + .IsUnique(); + + b.ToTable("CapturedDimensions"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDrawing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExportDxf") + .HasColumnType("INTEGER"); + + b.Property("ExportPdf") + .HasColumnType("INTEGER"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SheetName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "RelativePath", "SheetName") + .IsUnique(); + + b.ToTable("CapturedDrawings"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFeature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DefaultSuppressed") + .HasColumnType("INTEGER"); + + b.Property("FeatureType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SwName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Alias") + .IsUnique(); + + b.HasIndex("CapturedModelId", "SwName") + .IsUnique(); + + b.ToTable("CapturedFeatures"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFileFormat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedModelId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OptionsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("CapturedModelId", "Format") + .IsUnique(); + + b.ToTable("CapturedFileFormats"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DocumentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IsMaster") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsMaster"); + + b.HasIndex("ProjectId"); + + b.HasIndex("ProjectId", "RelativePath", "Configuration") + .IsUnique(); + + b.ToTable("CapturedModels"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedReplacementModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PathsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId", "Token") + .IsUnique(); + + b.ToTable("CapturedReplacementModels"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DimensionRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CapturedDimensionId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Expression") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("LastTestResult") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CapturedDimensionId"); + + b.HasIndex("ProjectId", "CapturedDimensionId") + .IsUnique(); + + b.ToTable("DimensionRules"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DocumentTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("DisplayOrder") + .HasColumnType("INTEGER"); + + b.Property("DocxFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExpectedVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TemplateContent") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("DisplayOrder"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("DocumentTemplates"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GeneratedDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("FileSizeBytes") + .HasColumnType("INTEGER"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("SpecificationId") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SpecificationId"); + + b.HasIndex("TemplateId"); + + b.ToTable("GeneratedDocuments"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GenerationJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CalculatedVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("GeneratedFilesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("InputVariablesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProjectCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SpecificationId") + .HasColumnType("TEXT"); + + b.Property("StackTrace") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SpecificationId"); + + b.HasIndex("Status"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("GenerationJobs"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AutopilotSettingsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SecuritySettingsJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WorkspacePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupMembership", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupProjectAccess", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("AccessLevel") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "ProjectId"); + + b.HasIndex("GroupId"); + + b.HasIndex("ProjectId"); + + b.ToTable("GroupProjectAccesses"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.LookupTable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DataJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("GroupId", "Name") + .IsUnique(); + + b.ToTable("LookupTables"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("FormSchemaJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ModelsPath") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PublishedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("GroupId"); + + b.HasIndex("Status"); + + b.HasIndex("GroupId", "Code") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Specification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("ComputedValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("GenerationStartedAt") + .HasColumnType("TEXT"); + + b.Property("InputValuesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubmittedAt") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Status"); + + b.HasIndex("ProjectId", "Status"); + + b.ToTable("Specifications"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.ToTable("UserGroups"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroupMember", b => + { + b.Property("UserGroupId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserGroupId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("UserGroupMembers"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroupProjectAccess", b => + { + b.Property("UserGroupId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserGroupId", "ProjectId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserGroupId"); + + b.ToTable("UserGroupProjectAccesses"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Variable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DefaultValue") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Dependencies") + .HasColumnType("jsonb"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EvaluationOrder") + .HasColumnType("INTEGER"); + + b.Property("Expression") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("IsReadOnly") + .HasColumnType("INTEGER"); + + b.Property("IsRequired") + .HasColumnType("INTEGER"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EvaluationOrder"); + + b.HasIndex("IsReadOnly"); + + b.HasIndex("ProjectId", "Name") + .IsUnique(); + + b.ToTable("Variables"); + }); + + modelBuilder.Entity("NexusCad.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("NexusCad.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedCustomProperty", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("CustomProperties") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDimension", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Dimensions") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedDrawing", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Drawings") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFeature", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("Features") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedFileFormat", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedModel", "CapturedModel") + .WithMany("FileFormats") + .HasForeignKey("CapturedModelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedModel"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedReplacementModel", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.DimensionRule", b => + { + b.HasOne("NexusCad.Core.Entities.CapturedDimension", "CapturedDimension") + .WithMany() + .HasForeignKey("CapturedDimensionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CapturedDimension"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GeneratedDocument", b => + { + b.HasOne("NexusCad.Core.Entities.Specification", "Specification") + .WithMany() + .HasForeignKey("SpecificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.DocumentTemplate", "Template") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Specification"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GenerationJob", b => + { + b.HasOne("NexusCad.Core.Entities.Specification", "Specification") + .WithMany() + .HasForeignKey("SpecificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Specification"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupMembership", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("Memberships") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.GroupProjectAccess", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("ProjectAccesses") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.LookupTable", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("Projects") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Specification", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany("Specifications") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroupMember", b => + { + b.HasOne("NexusCad.Core.Entities.UserGroup", "UserGroup") + .WithMany("Members") + .HasForeignKey("UserGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserGroup"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroupProjectAccess", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.UserGroup", "UserGroup") + .WithMany("ProjectAccesses") + .HasForeignKey("UserGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("UserGroup"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Variable", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany("Variables") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.CapturedModel", b => + { + b.Navigation("CustomProperties"); + + b.Navigation("Dimensions"); + + b.Navigation("Drawings"); + + b.Navigation("Features"); + + b.Navigation("FileFormats"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Group", b => + { + b.Navigation("Memberships"); + + b.Navigation("ProjectAccesses"); + + b.Navigation("Projects"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.Project", b => + { + b.Navigation("Specifications"); + + b.Navigation("Variables"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroup", b => + { + b.Navigation("Members"); + + b.Navigation("ProjectAccesses"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/NexusCad.Infrastructure/Migrations/20260601111304_AddUserGroupProjectAccess_RemoveMembershipRole.cs b/src/NexusCad.Infrastructure/Migrations/20260601111304_AddUserGroupProjectAccess_RemoveMembershipRole.cs new file mode 100644 index 0000000..f7c6030 --- /dev/null +++ b/src/NexusCad.Infrastructure/Migrations/20260601111304_AddUserGroupProjectAccess_RemoveMembershipRole.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusCad.Infrastructure.Migrations +{ + /// + public partial class AddUserGroupProjectAccess_RemoveMembershipRole : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Role", + table: "GroupMemberships"); + + migrationBuilder.CreateTable( + name: "UserGroupProjectAccesses", + columns: table => new + { + UserGroupId = table.Column(type: "TEXT", nullable: false), + ProjectId = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserGroupProjectAccesses", x => new { x.UserGroupId, x.ProjectId }); + table.ForeignKey( + name: "FK_UserGroupProjectAccesses_Projects_ProjectId", + column: x => x.ProjectId, + principalTable: "Projects", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserGroupProjectAccesses_UserGroups_UserGroupId", + column: x => x.UserGroupId, + principalTable: "UserGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserGroupProjectAccesses_ProjectId", + table: "UserGroupProjectAccesses", + column: "ProjectId"); + + migrationBuilder.CreateIndex( + name: "IX_UserGroupProjectAccesses_UserGroupId", + table: "UserGroupProjectAccesses", + column: "UserGroupId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserGroupProjectAccesses"); + + migrationBuilder.AddColumn( + name: "Role", + table: "GroupMemberships", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/src/NexusCad.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/NexusCad.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 9fe75ff..a790b18 100644 --- a/src/NexusCad.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusCad.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -758,9 +758,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedAt") .HasColumnType("TEXT"); - b.Property("Role") - .HasColumnType("INTEGER"); - b.HasKey("GroupId", "UserId"); b.HasIndex("UserId"); @@ -768,6 +765,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("GroupMemberships"); }); + modelBuilder.Entity("NexusCad.Core.Entities.GroupProjectAccess", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("AccessLevel") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "ProjectId"); + + b.HasIndex("GroupId"); + + b.HasIndex("ProjectId"); + + b.ToTable("GroupProjectAccesses"); + }); + modelBuilder.Entity("NexusCad.Core.Entities.LookupTable", b => { b.Property("Id") @@ -841,6 +861,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(500) .HasColumnType("TEXT"); + b.Property("ModelsPath") + .HasColumnType("TEXT"); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -944,6 +967,91 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Specifications"); }); + modelBuilder.Entity("NexusCad.Core.Entities.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.ToTable("UserGroups"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroupMember", b => + { + b.Property("UserGroupId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserGroupId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("UserGroupMembers"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroupProjectAccess", b => + { + b.Property("UserGroupId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("UserGroupId", "ProjectId"); + + b.HasIndex("ProjectId"); + + b.HasIndex("UserGroupId"); + + b.ToTable("UserGroupProjectAccesses"); + }); + modelBuilder.Entity("NexusCad.Core.Entities.Variable", b => { b.Property("Id") @@ -1281,6 +1389,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Group"); }); + modelBuilder.Entity("NexusCad.Core.Entities.GroupProjectAccess", b => + { + b.HasOne("NexusCad.Core.Entities.Group", "Group") + .WithMany("ProjectAccesses") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Project"); + }); + modelBuilder.Entity("NexusCad.Core.Entities.LookupTable", b => { b.HasOne("NexusCad.Core.Entities.Group", "Group") @@ -1312,6 +1439,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Project"); }); + modelBuilder.Entity("NexusCad.Core.Entities.UserGroupMember", b => + { + b.HasOne("NexusCad.Core.Entities.UserGroup", "UserGroup") + .WithMany("Members") + .HasForeignKey("UserGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserGroup"); + }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroupProjectAccess", b => + { + b.HasOne("NexusCad.Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NexusCad.Core.Entities.UserGroup", "UserGroup") + .WithMany("ProjectAccesses") + .HasForeignKey("UserGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("UserGroup"); + }); + modelBuilder.Entity("NexusCad.Core.Entities.Variable", b => { b.HasOne("NexusCad.Core.Entities.Project", "Project") @@ -1340,6 +1497,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Memberships"); + b.Navigation("ProjectAccesses"); + b.Navigation("Projects"); }); @@ -1349,6 +1508,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Variables"); }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroup", b => + { + b.Navigation("Members"); + + b.Navigation("ProjectAccesses"); + }); #pragma warning restore 612, 618 } } diff --git a/src/NexusCad.Infrastructure/Repositories/GroupRepository.cs b/src/NexusCad.Infrastructure/Repositories/GroupRepository.cs index c5c32b2..d31b329 100644 --- a/src/NexusCad.Infrastructure/Repositories/GroupRepository.cs +++ b/src/NexusCad.Infrastructure/Repositories/GroupRepository.cs @@ -3,7 +3,6 @@ // using Microsoft.EntityFrameworkCore; using NexusCad.Core.Entities; -using NexusCad.Core.Enums; using NexusCad.Core.Interfaces; using NexusCad.Infrastructure.Data; @@ -48,13 +47,12 @@ public async Task CodeExistsAsync(string code, Guid? excludeId = null, Can => _context.Set() .FirstOrDefaultAsync(m => m.GroupId == groupId && m.UserId == userId, cancellationToken); - public async Task AddMembershipAsync(Guid groupId, string userId, GroupRole role, CancellationToken cancellationToken = default) + public async Task AddMembershipAsync(Guid groupId, string userId, CancellationToken cancellationToken = default) { var membership = new GroupMembership { GroupId = groupId, UserId = userId, - Role = role, CreatedAt = DateTime.UtcNow, }; await _context.Set().AddAsync(membership, cancellationToken); diff --git a/src/NexusCad.Infrastructure/Repositories/UserGroupRepository.cs b/src/NexusCad.Infrastructure/Repositories/UserGroupRepository.cs new file mode 100644 index 0000000..4aa0615 --- /dev/null +++ b/src/NexusCad.Infrastructure/Repositories/UserGroupRepository.cs @@ -0,0 +1,72 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using Microsoft.EntityFrameworkCore; +using NexusCad.Core.Entities; +using NexusCad.Core.Interfaces; +using NexusCad.Infrastructure.Data; + +namespace NexusCad.Infrastructure.Repositories; + +public class UserGroupRepository : GenericRepository, IUserGroupRepository +{ + public UserGroupRepository(AppDbContext context) : base(context) + { + } + + public Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) + => _dbSet.FirstOrDefaultAsync(g => g.Code == code, cancellationToken); + + public Task GetWithMembersAsync(Guid id, CancellationToken cancellationToken = default) + => _dbSet + .Include(g => g.Members) + .FirstOrDefaultAsync(g => g.Id == id, cancellationToken); + + public Task> GetAllOrderedAsync(CancellationToken cancellationToken = default) + => _dbSet.OrderBy(g => g.Name).ToListAsync(cancellationToken); + + public async Task CodeExistsAsync(string code, Guid? excludeId = null, CancellationToken cancellationToken = default) + { + var query = _dbSet.Where(g => g.Code == code); + if (excludeId.HasValue) + query = query.Where(g => g.Id != excludeId.Value); + return await query.AnyAsync(cancellationToken); + } + + public Task> GetMembersAsync(Guid userGroupId, CancellationToken cancellationToken = default) + => _context.Set() + .Where(m => m.UserGroupId == userGroupId) + .ToListAsync(cancellationToken); + + public Task GetMemberAsync(Guid userGroupId, string userId, CancellationToken cancellationToken = default) + => _context.Set() + .FirstOrDefaultAsync(m => m.UserGroupId == userGroupId && m.UserId == userId, cancellationToken); + + public async Task AddMemberAsync(Guid userGroupId, string userId, CancellationToken cancellationToken = default) + { + var member = new UserGroupMember + { + UserGroupId = userGroupId, + UserId = userId, + AddedAt = DateTime.UtcNow, + }; + await _context.Set().AddAsync(member, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return member; + } + + public async Task RemoveMemberAsync(Guid userGroupId, string userId, CancellationToken cancellationToken = default) + { + var member = await GetMemberAsync(userGroupId, userId, cancellationToken); + if (member == null) + return; + _context.Set().Remove(member); + await _context.SaveChangesAsync(cancellationToken); + } + + public Task> GetGroupsForUserAsync(string userId, CancellationToken cancellationToken = default) + => _dbSet + .Where(g => g.Members.Any(m => m.UserId == userId)) + .OrderBy(g => g.Name) + .ToListAsync(cancellationToken); +} diff --git a/src/NexusCad.Infrastructure/Seeders/DefaultGroupSeeder.cs b/src/NexusCad.Infrastructure/Seeders/DefaultGroupSeeder.cs index 694df10..8f78889 100644 --- a/src/NexusCad.Infrastructure/Seeders/DefaultGroupSeeder.cs +++ b/src/NexusCad.Infrastructure/Seeders/DefaultGroupSeeder.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using NexusCad.Core.Entities; -using NexusCad.Core.Enums; using NexusCad.Infrastructure.Data; using NexusCad.Infrastructure.Identity; @@ -59,7 +58,6 @@ public static async Task SeedAsync(AppDbContext context, UserManager( builder.Configuration.GetSection(JobPollingOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(StorageOptions.SectionName)); + // Register services builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/NexusCad.SwWorker/Services/JobProcessor.cs b/src/NexusCad.SwWorker/Services/JobProcessor.cs index 6c75b4f..f19306a 100644 --- a/src/NexusCad.SwWorker/Services/JobProcessor.cs +++ b/src/NexusCad.SwWorker/Services/JobProcessor.cs @@ -2,7 +2,7 @@ // Licensed under AGPL v3 // using Microsoft.Extensions.Options; -using NexusCad.SwWorker.Configuration; +using NexusCad.Core.Configuration; using NexusCad.SwWorker.Models; namespace NexusCad.SwWorker.Services; @@ -16,18 +16,18 @@ public sealed class JobProcessor : IJobProcessor { private readonly INexusCadApiClient _apiClient; private readonly ISolidWorksService _solidWorksService; - private readonly SolidWorksOptions _options; + private readonly StorageOptions _storage; private readonly ILogger _logger; public JobProcessor( INexusCadApiClient apiClient, ISolidWorksService solidWorksService, - IOptions options, + IOptions storageOptions, ILogger logger) { _apiClient = apiClient; _solidWorksService = solidWorksService; - _options = options.Value; + _storage = storageOptions.Value; _logger = logger; } @@ -49,8 +49,8 @@ public async Task ProcessJobAsync(GenerationJob job, CancellationToken can var spec = specResponse.Data; - // Create job directory - var jobDir = Path.Combine(_options.WorkingDirectory, job.Id.ToString()); + // Create job working directory + var jobDir = _storage.GetJobTempPath(job.Id); Directory.CreateDirectory(jobDir); _logger.LogInformation("Job directory created: {JobDir}", jobDir); @@ -185,9 +185,8 @@ private object EvaluateSimpleFormula(string formula, Dictionary private string GetTemplatePath(string projectCode) { - // In production, templates would be stored in a configured location - var templatesDir = Path.Combine(_options.WorkingDirectory, "Templates"); - return Path.Combine(templatesDir, projectCode, "template.SLDPRT"); + var templatesDir = _storage.GetProjectModelsPath(projectCode); + return Path.Combine(templatesDir, "template.SLDPRT"); } private bool ShouldGenerateDrawing(SpecificationDetail spec) diff --git a/src/NexusCad.SwWorker/appsettings.json b/src/NexusCad.SwWorker/appsettings.json index f677ac4..40de5d9 100644 --- a/src/NexusCad.SwWorker/appsettings.json +++ b/src/NexusCad.SwWorker/appsettings.json @@ -11,11 +11,13 @@ "Username": "swworker@nexuscad.local", "Password": "SwWorker123!" }, + "Storage": { + "RootPath": "C:\\NexusCad" + }, "SolidWorks": { "StartupTimeoutSeconds": 60, "SaveTimeoutSeconds": 30, - "MaxConcurrentJobs": 1, - "WorkingDirectory": "C:\\NexusCad\\Jobs" + "MaxConcurrentJobs": 1 }, "JobPolling": { "IntervalSeconds": 10, diff --git a/web/NexusCad.Web.esproj b/web/NexusCad.Web.esproj index eabb545..a9216b3 100644 --- a/web/NexusCad.Web.esproj +++ b/web/NexusCad.Web.esproj @@ -1,16 +1,17 @@ - + pnpm dev src/ Jest false - FolderProfile pnpm build pnpm clean C:\Program Files\nodejs\node.exe false + true + $(BuildCommand) - + - \ No newline at end of file + diff --git a/web/Properties/launchSettings.json b/web/Properties/launchSettings.json new file mode 100644 index 0000000..9937054 --- /dev/null +++ b/web/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "NexusCad.Web": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "http://localhost:3000", + "environmentVariables": { + "NODE_ENV": "development" + }, + "applicationUrl": "http://localhost:3000", + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" + } + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index a2421ef..996c8ea 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,11 +5,14 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { useAuthStore } from './store/authStore' import Login from './pages/Login' import Dashboard from './pages/Dashboard' +import HomePage from './pages/HomePage' import { ProjectCatalogPage } from './pages/ProjectCatalogPage' import { ProjectConfigurePage } from './pages/ProjectConfigurePage' import { FormPreviewPage } from './pages/FormPreviewPage' import SpecificationDetail from './pages/SpecificationDetail' import History from './pages/History' +import UsersPage from './pages/admin/UsersPage' +import UserEditPage from './pages/admin/UserEditPage' import AppLayout from './components/Layout/AppLayout' // Protected route wrapper @@ -23,6 +26,21 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { return <>{children} } +// Admin-only route wrapper +function AdminRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, user } = useAuthStore() + const roles: string[] = Array.isArray(user?.roles) + ? (user.roles as string[]) + : typeof user?.roles === 'string' + ? [user.roles as string] + : [] + + if (!isAuthenticated) return + if (!roles.includes('Admin')) return + + return <>{children} +} + function App() { return ( @@ -37,19 +55,39 @@ function App() { } > - } /> + } /> + } /> } /> } /> } /> } /> } /> } /> + + {/* Admin section */} + + + + } + /> + + + + } + /> - } /> + } /> ) } export default App + diff --git a/web/src/components/Layout/Header.tsx b/web/src/components/Layout/Header.tsx index a00618c..ba114d5 100644 --- a/web/src/components/Layout/Header.tsx +++ b/web/src/components/Layout/Header.tsx @@ -14,9 +14,11 @@ import { } from '@mui/material' import { AccountCircle, + Home as HomeIcon, Dashboard as DashboardIcon, ViewList, History as HistoryIcon, + AdminPanelSettings as AdminIcon, } from '@mui/icons-material' import { useState } from 'react' import { useAuthStore } from '../../store/authStore' @@ -27,6 +29,13 @@ export default function Header() { const { user, clearAuth } = useAuthStore() const [anchorEl, setAnchorEl] = useState(null) + const roles: string[] = Array.isArray(user?.roles) + ? (user.roles as string[]) + : typeof user?.roles === 'string' + ? [user.roles as string] + : [] + const isAdmin = roles.includes('Admin') + const handleMenu = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget) } @@ -50,6 +59,13 @@ export default function Header() { + + {isAdmin && ( + + )} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..7ef5436 --- /dev/null +++ b/web/src/pages/HomePage.tsx @@ -0,0 +1,510 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +import { + Container, + Typography, + Box, + Grid, + Card, + CardContent, + Chip, + Divider, + Paper, + List, + ListItem, + ListItemIcon, + ListItemText, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Alert, +} from '@mui/material' +import { + Extension, + Settings, + Web, + Memory, + ArrowForward, + Person, + Build, + CheckCircle, + Circle, + Code, + Folder, + Rule, + Schema, + Download, + Tune, + Description, +} from '@mui/icons-material' +import { useAuthStore } from '../store/authStore' + +const STEP_COLORS = ['primary.main', 'warning.main', 'success.main', 'error.main'] as const + +const steps = [ + { + number: '1', + title: 'Captura', + tool: 'SolidWorks Add-in', + icon: , + color: 'primary' as const, + items: ['Captured Models', 'Cotas paramétricas (mm/deg)', 'Features (supresión)', 'Custom Properties', 'Drawings', 'File Formats'], + }, + { + number: '2', + title: 'Configuración', + tool: 'NexusCad Admin', + icon: , + color: 'warning' as const, + items: ['Model Dimensions', 'Variables (entrada / derivada)', 'Model Rules (Roslyn C#)', 'Publicar proyecto'], + }, + { + number: '3', + title: 'Especificación', + tool: 'NexusCad Web', + icon: , + color: 'success' as const, + items: ['Login', 'Catálogo de proyectos', 'Formulario dinámico', 'Enviar especificación'], + }, + { + number: '4', + title: 'Generación', + tool: 'NexusCad SwWorker', + icon: , + color: 'error' as const, + items: ['Evalúa Model Rules', 'Aplica cotas en SolidWorks', 'Exporta STEP / DXF / PDF', 'Archivos disponibles para descarga'], + }, +] + +const roles = [ + { + icon: , + role: 'Ingeniero / Autor del modelo', + tool: 'SolidWorks + SwAddIn', + description: 'Captura cotas, features y propiedades del modelo 3D. Es el punto de entrada: sin capturas no hay nada que configurar ni generar.', + color: 'primary.main', + }, + { + icon: , + role: 'Autor del configurador', + tool: 'NexusCad Admin', + description: 'Define Variables, Model Rules y publica el proyecto. Conecta las cotas del modelo con los parámetros que controlará el cliente.', + color: 'warning.main', + }, + { + icon: , + role: 'Cliente / Comercial', + tool: 'NexusCad Web', + description: 'Rellena los parámetros del configurador en el navegador y lanza la generación. Recibe los archivos generados.', + color: 'success.main', + }, +] + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ) +} + +function BulletItem({ text, color }: { text: string; color: string }) { + return ( + + + + + {text}} + /> + + ) +} + +export default function HomePage() { + const user = useAuthStore((state) => state.user) + + return ( + + {/* Hero */} + + `linear-gradient(135deg, ${t.palette.primary.dark} 0%, ${t.palette.primary.main} 60%, ${t.palette.primary.light} 100%)`, + color: 'white', + }} + > + + Bienvenido{user?.fullName ? `, ${user.fullName}` : ''} a NexusCad + + + Plataforma de configuración de productos paramétricos integrada con SolidWorks. + Captura, configura, especifica y genera automáticamente variantes de tus modelos 3D. + + + + {/* Flujo de trabajo */} + Flujo de trabajo general + + NexusCad sigue cuatro etapas encadenadas. El Add-in de SolidWorks es el{' '} + punto de entrada: sin capturas no hay nada que configurar ni generar. + + + + {steps.map((step, i) => ( + + + + + + {step.icon} + + + + {step.title} + + + + + {step.tool} + + + {step.items.map((item) => ( + + ))} + + + + {i < steps.length - 1 && ( + + + + )} + + + ))} + + + {/* Orden recomendado */} + + + Orden de trabajo recomendado + + + {[ + 'SolidWorks + Add-in — abre el modelo, captura cotas, features y propiedades.', + 'Admin → Model Dimensions — revisa y ajusta los valores por defecto de las cotas.', + 'Admin → Variables — define qué parámetros controla el cliente y cuáles calcula el sistema.', + 'Admin → Model Rules — conecta cada cota con una expresión C# que usa las variables. Usa "Probar todas" para verificar.', + 'Admin → Publicar — publica el proyecto para que aparezca en la web.', + 'Web — el cliente o comercial rellena el formulario y envía la especificación.', + 'SwWorker — procesa el job automáticamente y genera los archivos.', + ].map((text, i) => ( + + + + + {text}} + /> + + ))} + + + + + + {/* Roles */} + Roles de usuario + + {roles.map((r) => ( + + + + + {r.icon} + + {r.role} + {r.tool} + + + {r.description} + + + + ))} + + + + + {/* NexusCad Capture */} + + NexusCad Capture (SolidWorks Add-in) + + + NexusCad.SwAddIn se instala como complemento COM dentro de SolidWorks. Al cargarlo verás un{' '} + Task Pane con el título NexusCad Capture en el panel lateral derecho. + + + + {[ + { + icon: , + title: 'Captured Models', + text: 'Vínculo entre un archivo SolidWorks (.SLDPRT, .SLDASM o .SLDDRW) y un proyecto de NexusCad. Contenedor de todas las capturas. Solo puede haber un Master por proyecto.', + }, + { + icon: , + title: 'Dimensiones', + text: 'Cotas paramétricas del modelo (formato Nombre@Sketch). Longitudes en mm, ángulos en deg. Captura individual o masiva con sincronización automática de altas y bajas.', + }, + { + icon: , + title: 'Features', + text: 'Operaciones del FeatureManager cuyo estado de supresión el sistema puede controlar. Útil para agujeros opcionales, nervios condicionales, etc.', + }, + { + icon: , + title: 'Custom Properties', + text: 'Propiedades personalizadas del documento SolidWorks. Se importan en bloque y NexusCad las propaga a los archivos generados o las usa en plantillas.', + }, + { + icon: , + title: 'Drawings', + text: 'Planos (.SLDDRW) asociados al modelo. El SwWorker los regenerará y exportará al generar una variante. PDF activado por defecto.', + }, + { + icon: , + title: 'File Formats', + text: 'Formatos de exportación por variante: STEP, DXF, PDF, IGES, STL, Parasolid. El checkbox de cada formato controla si el SwWorker lo exportará.', + }, + ].map((item) => ( + + + + + {item.icon} + {item.title} + + {item.text} + + + + ))} + + + + Instalación: ejecuta .\dev-scripts\register-swaddin.ps1 (PowerShell elevado). + En SolidWorks: Herramientas → Complementos → ☑ NexusCad Capture. + + + + + {/* Admin */} + + NexusCad Admin + + + Aplicación de escritorio donde el autor del configurador trabaja sobre el proyecto después de capturar el modelo. + Tres secciones principales bajo Stage 2: Specification. + + + + + + + + + Model Dimensions + + + Vista de solo lectura y edición ligera para revisar las cotas capturadas y ajustar sus valores por + defecto sin abrir SolidWorks. Doble clic en Valor para editar, + luego Guardar valores. + + + + + + + + + + + Variables + + + Parámetros del configurador. Dos tipos: + + + + + + + Tipos disponibles: String, Int, Decimal, Bool, DateTime, List + + + + + + + + + + + Model Rules + + + Conectan cada cota capturada con una expresión C# evaluada por Roslyn. Ejemplos: + + +
Alto
+
Alto + 50
+
{'Alto > 300 ? 90.0 : 45.0'}
+
Math.Round(Largo / 3, 1)
+
+ + Usa Probar todas para validar antes de guardar. + +
+
+
+
+ + + + {/* Web */} + + NexusCad Web (portal del cliente) + + + Aplicación React para clientes y comerciales. El formulario se genera dinámicamente a partir del esquema de + variables del proyecto. + + + + + + + + Campos del formulario + + + + + Tipo de variable + Campo generado + + + + {[ + ['String', 'Texto libre'], + ['Int / Decimal', 'Número (con step decimal)'], + ['Bool', 'Desplegable Sí / No'], + ['DateTime', 'Selector de fecha'], + ].map(([tipo, campo]) => ( + + {tipo} + {campo} + + ))} + +
+ + Las variables derivadas no aparecen — el sistema las calcula automáticamente. + +
+
+
+ + + + + + Estados de una especificación + + + {[ + { estado: 'Borrador', desc: 'Guardada pero no enviada', color: 'text.secondary' }, + { estado: 'Enviada', desc: 'En cola para generación', color: 'info.main' }, + { estado: 'Generando', desc: 'El SwWorker está procesando el job', color: 'warning.main' }, + { estado: 'Completada', desc: 'Archivos listos para descargar', color: 'success.main' }, + { estado: 'Error', desc: 'La generación falló — ver mensaje de error', color: 'error.main' }, + ].map((item) => ( + + + + + + {item.estado} — {item.desc} + + } + /> + + ))} + + + + +
+ + + + {/* SwWorker */} + + NexusCad SwWorker (generación) + + + Servicio Windows que consume jobs de la cola y abre SolidWorks por COM Interop para generar variantes del + modelo. Aplica los valores calculados por las Model Rules y exporta los archivos configurados. + + + + + + + Requisitos de entorno + + + {[ + 'SolidWorks instalado en la misma máquina que el worker.', + 'NexusCad.Api accesible en la URL configurada (por defecto http://localhost:5140).', + 'Credenciales válidas en appsettings.json → ApiSettings.', + ].map((req) => ( + + + + + {req}} + /> + + ))} + + + + + + SolidWorks no es thread-safe. El número de procesos worker en paralelo está limitado al + número de licencias disponibles. + + + Renovación de token: el worker renueva el JWT automáticamente. Mensajes{' '} + IDX10223 en los logs se recuperan solos. + + + +
+ ) +} diff --git a/web/src/pages/admin/UserEditPage.tsx b/web/src/pages/admin/UserEditPage.tsx new file mode 100644 index 0000000..545311e --- /dev/null +++ b/web/src/pages/admin/UserEditPage.tsx @@ -0,0 +1,379 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +import { useEffect, useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { + Box, + Typography, + Tabs, + Tab, + Paper, + TextField, + FormControlLabel, + Switch, + Button, + Alert, + CircularProgress, + Checkbox, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Select, + MenuItem, + FormControl, + InputLabel, + Chip, + Divider, + IconButton, + Tooltip, +} from '@mui/material' +import { ArrowBack as ArrowBackIcon, Save as SaveIcon } from '@mui/icons-material' +import { + adminService, + GROUP_ROLES, + type UserDto, + type GroupListItem, + type UserGroupMembership, + type MembershipItem, +} from '../../services/admin.service' +import { useAuthStore } from '../../store/authStore' + +const AVAILABLE_ROLES = ['Admin', 'Autor', 'Comercial', 'Cliente'] + +interface TabPanelProps { + children?: React.ReactNode + value: number + index: number +} + +function TabPanel({ children, value, index }: TabPanelProps) { + return value === index ? {children} : null +} + +export default function UserEditPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const currentUser = useAuthStore((s) => s.user) + + const [tab, setTab] = useState(0) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [successMsg, setSuccessMsg] = useState(null) + const [user, setUser] = useState(null) + const [fullName, setFullName] = useState('') + const [isActive, setIsActive] = useState(true) + const [selectedRoles, setSelectedRoles] = useState([]) + const [newPassword, setNewPassword] = useState('') + + // Group memberships state + const [allGroups, setAllGroups] = useState([]) + const [memberships, setMemberships] = useState>(new Map()) // groupId -> role + + const isEditingSelf = currentUser?.id === id + + useEffect(() => { + if (!id) return + Promise.all([ + adminService.getUser(id), + adminService.getUserMemberships(id), + adminService.getAllGroups(), + ]) + .then(([userRes, membRes, groupsRes]) => { + if (userRes.success && userRes.data) { + setUser(userRes.data) + setFullName(userRes.data.fullName) + setIsActive(userRes.data.isActive) + setSelectedRoles(userRes.data.roles) + } + if (membRes.success) { + const map = new Map() + membRes.data.forEach((m: UserGroupMembership) => map.set(m.groupId, m.role)) + setMemberships(map) + } + if (groupsRes.success) { + setAllGroups(groupsRes.data) + } + }) + .catch(() => setError('Error cargando datos del usuario')) + .finally(() => setLoading(false)) + }, [id]) + + const handleSaveBasic = async () => { + if (!id) return + setSaving(true) + setError(null) + setSuccessMsg(null) + try { + const res = await adminService.updateUser(id, { + fullName, + isActive, + roles: selectedRoles, + newPassword: newPassword || undefined, + }) + if (res.success) { + setSuccessMsg('Usuario actualizado correctamente') + setNewPassword('') + } else { + setError(res.message ?? 'Error al guardar') + } + } catch { + setError('Error de conexión') + } finally { + setSaving(false) + } + } + + const handleToggleGroup = (groupId: string) => { + setMemberships((prev) => { + const next = new Map(prev) + if (next.has(groupId)) { + next.delete(groupId) + } else { + next.set(groupId, 2) // default: Commercial + } + return next + }) + } + + const handleRoleChange = (groupId: string, role: number) => { + setMemberships((prev) => { + const next = new Map(prev) + next.set(groupId, role) + return next + }) + } + + const handleSaveMemberships = async () => { + if (!id) return + setSaving(true) + setError(null) + setSuccessMsg(null) + try { + const items: MembershipItem[] = Array.from(memberships.entries()).map(([groupId, role]) => ({ + groupId, + role, + })) + const res = await adminService.setUserMemberships(id, items) + if (res.success) { + setSuccessMsg('Acceso a grupos actualizado correctamente') + } else { + setError(res.message ?? 'Error al guardar membresías') + } + } catch { + setError('Error de conexión') + } finally { + setSaving(false) + } + } + + if (loading) return + + return ( + + {/* Header */} + + + navigate('/admin/users')} size="small"> + + + + + Editar usuario: {user?.email} + + + + {error && ( + setError(null)}> + {error} + + )} + {successMsg && ( + setSuccessMsg(null)}> + {successMsg} + + )} + + + setTab(v)} sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}> + + + + + {/* ── Tab 0: Basic info ── */} + + + setFullName(e.target.value)} + fullWidth + /> + + setIsActive(e.target.checked)} + disabled={isEditingSelf} + /> + } + label={isActive ? 'Usuario activo' : 'Usuario inactivo'} + /> + + + + Roles globales + + + {AVAILABLE_ROLES.map((role) => ( + { + if (isEditingSelf && role === 'Admin') return + setSelectedRoles((prev) => + prev.includes(role) ? prev.filter((r) => r !== role) : [...prev, role] + ) + }} + /> + ))} + + + Haz clic en un rol para activarlo o desactivarlo. + + + + + + + + Nueva contraseña (opcional) + + setNewPassword(e.target.value)} + placeholder="Dejar vacío para no cambiar" + fullWidth + autoComplete="new-password" + /> + + + + + + + + + {/* ── Tab 1: Group memberships ── */} + + + + Marca los grupos a los que este usuario tiene acceso. El rol determina qué puede hacer dentro de cada grupo + (y sus proyectos). + + + {allGroups.length === 0 ? ( + No hay grupos creados todavía. + ) : ( + + + + + Acceso + Grupo + Código + Rol en el grupo + + + + {allGroups.map((group) => { + const hasAccess = memberships.has(group.id) + const role = memberships.get(group.id) ?? 2 + + return ( + + + handleToggleGroup(group.id)} + /> + + + + {group.iconUrl && ( + + )} + {group.name} + + {group.description && ( + + {group.description} + + )} + + + + + + {hasAccess ? ( + + Rol + + + ) : ( + + Sin acceso + + )} + + + ) + })} + +
+
+ )} + + + + +
+
+
+
+ ) +} diff --git a/web/src/pages/admin/UsersPage.tsx b/web/src/pages/admin/UsersPage.tsx new file mode 100644 index 0000000..3f7abd9 --- /dev/null +++ b/web/src/pages/admin/UsersPage.tsx @@ -0,0 +1,115 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Box, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + IconButton, + Chip, + Button, + Alert, + CircularProgress, + Tooltip, +} from '@mui/material' +import { Edit as EditIcon, PersonAdd as PersonAddIcon } from '@mui/icons-material' +import { adminService, type UserDto } from '../../services/admin.service' + +export default function UsersPage() { + const navigate = useNavigate() + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + adminService + .getUsers() + .then((res) => { + if (res.success) setUsers(res.data) + else setError(res.message ?? 'Error cargando usuarios') + }) + .catch(() => setError('Error de conexión')) + .finally(() => setLoading(false)) + }, []) + + if (loading) return + if (error) return {error} + + return ( + + + Gestión de Usuarios + + + + + + + + Nombre + Email + Roles + Estado + Último acceso + Acciones + + + + {users.map((user) => ( + + {user.fullName} + {user.email} + + + {user.roles.map((role) => ( + + ))} + + + + + + + {user.lastLoginAt + ? new Date(user.lastLoginAt).toLocaleDateString('es-ES', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : '—'} + + + + navigate(`/admin/users/${user.id}`)} size="small"> + + + + + + ))} + +
+
+
+ ) +} diff --git a/web/src/services/admin.service.ts b/web/src/services/admin.service.ts new file mode 100644 index 0000000..3f7ce7e --- /dev/null +++ b/web/src/services/admin.service.ts @@ -0,0 +1,93 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +import api from './api' +import type { ApiResponse } from '../types/api.types' + +export interface UserDto { + id: string + email: string + fullName: string + roles: string[] + isActive: boolean + createdAt: string + lastLoginAt?: string +} + +export interface GroupListItem { + id: string + code: string + name: string + description: string + iconUrl?: string + createdAt: string + updatedAt: string +} + +export interface UserGroupMembership { + groupId: string + groupCode: string + groupName: string + groupIconUrl?: string + role: number // GroupRole enum: 0=Owner,1=Author,2=Commercial,3=Client + createdAt: string +} + +export interface MembershipItem { + groupId: string + role: number +} + +export interface UpdateUserRequest { + fullName: string + isActive: boolean + roles: string[] + newPassword?: string +} + +export const GROUP_ROLES: Record = { + 0: 'Propietario', + 1: 'Autor', + 2: 'Comercial', + 3: 'Cliente', +} + +export const adminService = { + // Users + async getUsers(): Promise> { + const { data } = await api.get('/api/auth/users') + return data + }, + + async getUser(id: string): Promise> { + const { data } = await api.get(`/api/auth/users/${id}`) + return data + }, + + async updateUser(id: string, request: UpdateUserRequest): Promise> { + const { data } = await api.put(`/api/auth/users/${id}`, request) + return data + }, + + async deleteUser(id: string): Promise> { + const { data } = await api.delete(`/api/auth/users/${id}`) + return data + }, + + // Group memberships + async getUserMemberships(userId: string): Promise> { + const { data } = await api.get(`/api/auth/users/${userId}/memberships`) + return data + }, + + async setUserMemberships(userId: string, memberships: MembershipItem[]): Promise> { + const { data } = await api.put(`/api/auth/users/${userId}/memberships`, { memberships }) + return data + }, + + // Groups (to populate the picker) + async getAllGroups(): Promise> { + const { data } = await api.get('/api/groups') + return data + }, +}