From fe7301f22de68bfd4e2d16cb9261e988925364d7 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Thu, 28 May 2026 10:45:52 +0200 Subject: [PATCH 01/31] fix(slnx): use canonical Platform mapping for SwAddIn x64 VS rejected the prior form with "project configuration does not exist". The vs-solutionpersistence parser requires Platform/BuildType elements whose Solution attribute is a combined BuildType|Platform string. Replace the two Configuration entries with a single canonical rule mapping all solution build types under Any CPU to the SwAddIn's x64 project platform. Co-Authored-By: Claude Opus 4.7 (1M context) --- NexusCad.slnx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/NexusCad.slnx b/NexusCad.slnx index 5fbe4bd..53650d1 100644 --- a/NexusCad.slnx +++ b/NexusCad.slnx @@ -14,8 +14,7 @@ - - + From 8862985a6111a467865d899d18c69cc941629d22 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Fri, 29 May 2026 10:38:00 +0200 Subject: [PATCH 02/31] feat(web): add Home page with full product documentation - Create HomePage.tsx with hero banner, 4-step workflow, roles, and detailed sections for Capture, Admin, Web and SwWorker components - Add /home route as root entry point (replaces /projects redirect) - Add 'Inicio' nav button as first item in Header using HomeIcon - Keep /dashboard route and stats page unchanged Based on MANUAL.es.md content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/src/App.tsx | 6 +- web/src/components/Layout/Header.tsx | 8 + web/src/pages/HomePage.tsx | 510 +++++++++++++++++++++++++++ 3 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 web/src/pages/HomePage.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index a2421ef..cd6cabb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ 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' @@ -37,7 +38,8 @@ function App() { } > - } /> + } /> + } /> } /> } /> } /> @@ -46,7 +48,7 @@ function App() { } /> - } /> + } /> ) diff --git a/web/src/components/Layout/Header.tsx b/web/src/components/Layout/Header.tsx index a00618c..6b52869 100644 --- a/web/src/components/Layout/Header.tsx +++ b/web/src/components/Layout/Header.tsx @@ -14,6 +14,7 @@ import { } from '@mui/material' import { AccountCircle, + Home as HomeIcon, Dashboard as DashboardIcon, ViewList, History as HistoryIcon, @@ -50,6 +51,13 @@ export default function Header() { + + + + + diff --git a/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs b/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs new file mode 100644 index 0000000..ac21ba3 --- /dev/null +++ b/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs @@ -0,0 +1,53 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using System.Windows; +using System.Windows.Controls; +using Microsoft.Win32; +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 += (_, _) => NameBox.Focus(); + } + + private void BrowseButton_Click(object sender, RoutedEventArgs e) + { + var dlg = new OpenFolderDialog + { + Title = "Select the workspace folder for this group" + }; + 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, GroupEditDialogViewModel viewModel) + { + var dialog = new GroupEditDialog(viewModel) { Owner = owner }; + return dialog.ShowDialog() == true; + } +} diff --git a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml index aab528f..601693c 100644 --- a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml +++ b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml @@ -20,7 +20,6 @@ - @@ -43,222 +42,425 @@ 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..bc1320b 100644 --- a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml.cs +++ b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml.cs @@ -23,6 +23,7 @@ private async void OnDataContextChanged(object sender, DependencyPropertyChanged if (e.NewValue is SecuritySettingsViewModel viewModel) { await viewModel.LoadUsersCommand.ExecuteAsync(null); + await viewModel.LoadGroupsCommand.ExecuteAsync(null); } } From b603aaa3bd46b3e6e4f24304ccb0108ae537f807 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Fri, 29 May 2026 11:06:33 +0200 Subject: [PATCH 08/31] feat(admin): user edit page with group access control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add admin section to the web app for managing user group memberships: Backend: - GET /api/auth/users/{id}/memberships returns user's group memberships - PUT /api/auth/users/{id}/memberships replaces user's group memberships - New DTOs: UserGroupMembershipDto, SetUserMembershipsRequest, UserMembershipItem Frontend: - /admin/users user list page (table with roles, status, last login) - /admin/users/:id user edit page with two tabs: - 'Datos básicos': name, active switch, global roles (chips), optional new password - 'Acceso a grupos': checkbox table select which groups the user can see and assign their role within each group (Owner/Author/Commercial/Client) - AdminRoute guard: non-admin users redirected to /home - Header: 'Administración' nav link visible only to Admin role users - admin.service.ts: API calls for users and group memberships Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Controllers/AuthController.cs | 120 ++++++ .../DTOs/Auth/UserGroupMembershipDto.cs | 33 ++ web/src/App.tsx | 36 ++ web/src/components/Layout/Header.tsx | 17 + web/src/pages/admin/UserEditPage.tsx | 379 ++++++++++++++++++ web/src/pages/admin/UsersPage.tsx | 115 ++++++ web/src/services/admin.service.ts | 93 +++++ 7 files changed, 793 insertions(+) create mode 100644 src/NexusCad.Api/DTOs/Auth/UserGroupMembershipDto.cs create mode 100644 web/src/pages/admin/UserEditPage.tsx create mode 100644 web/src/pages/admin/UsersPage.tsx create mode 100644 web/src/services/admin.service.ts diff --git a/src/NexusCad.Api/Controllers/AuthController.cs b/src/NexusCad.Api/Controllers/AuthController.cs index 4575c62..433f18f 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,115 @@ 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, + Role = m.Role, + 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 or update + foreach (var item in desired) + { + var existingMembership = existing.FirstOrDefault(e => e.GroupId == item.GroupId); + if (existingMembership != null) + { + existingMembership.Role = item.Role; + } + else + { + await _groupRepository.AddMembershipAsync(item.GroupId, id, item.Role); + } + } + + 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, + Role = m.Role, + 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/DTOs/Auth/UserGroupMembershipDto.cs b/src/NexusCad.Api/DTOs/Auth/UserGroupMembershipDto.cs new file mode 100644 index 0000000..0867801 --- /dev/null +++ b/src/NexusCad.Api/DTOs/Auth/UserGroupMembershipDto.cs @@ -0,0 +1,33 @@ +// Copyright (C) 2026 JaviFRx +// Licensed under AGPL v3 +// +using NexusCad.Core.Enums; + +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 GroupRole Role { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class SetUserMembershipsRequest +{ + /// + /// Lista de membresías deseada. Las existentes que no aparezcan aquí serán eliminadas. + /// + public List Memberships { get; set; } = new(); +} + +public class UserMembershipItem +{ + public Guid GroupId { get; set; } + public GroupRole Role { get; set; } = GroupRole.Commercial; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index cd6cabb..996c8ea 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,8 @@ 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 @@ -24,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 ( @@ -46,6 +63,24 @@ function App() { } /> } /> } /> + + {/* Admin section */} + + + + } + /> + + + + } + /> } /> @@ -55,3 +90,4 @@ function App() { } export default App + diff --git a/web/src/components/Layout/Header.tsx b/web/src/components/Layout/Header.tsx index 6b52869..ba114d5 100644 --- a/web/src/components/Layout/Header.tsx +++ b/web/src/components/Layout/Header.tsx @@ -18,6 +18,7 @@ import { Dashboard as DashboardIcon, ViewList, History as HistoryIcon, + AdminPanelSettings as AdminIcon, } from '@mui/icons-material' import { useState } from 'react' import { useAuthStore } from '../../store/authStore' @@ -28,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) } @@ -79,6 +87,15 @@ export default function Header() { > Historial + {isAdmin && ( + + )} 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 + }, +} From ab22d98f922fac539865d682a47e7a90d16ff0d2 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Fri, 29 May 2026 11:19:02 +0200 Subject: [PATCH 09/31] feat(project): add ModelsPath field to Project Each project can now specify its own SolidWorks master models folder. Priority chain: Project.ModelsPath > Group.WorkspacePath > StorageOptions default. Changes: - Project entity: nullable ModelsPath property - EF migration: AddProjectModelsPath - API DTOs: ModelsPath on ProjectDto, CreateProjectRequest, UpdateProjectRequest - ProjectsController: persist and return ModelsPath on all write endpoints - Admin WPF ProjectDto / UpdateProjectRequest: add ModelsPath - ProjectEditorViewModel: ModelsPath property + BrowseModelsPathCommand (FolderBrowserDialog via UseWindowsForms) - ProjectEditorView.xaml: 'Ruta de modelos SolidWorks' field with Browse button inside Project Information card Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/NexusCad.Admin/Models/ProjectDto.cs | 6 + src/NexusCad.Admin/NexusCad.Admin.csproj | 1 + .../ViewModels/ProjectEditorViewModel.cs | 34 +- .../Views/ProjectEditorView.xaml | 36 + .../Controllers/ProjectsController.cs | 7 + .../DTOs/Projects/CreateProjectRequest.cs | 7 + src/NexusCad.Api/DTOs/Projects/ProjectDto.cs | 5 + .../DTOs/Projects/UpdateProjectRequest.cs | 7 + src/NexusCad.Core/Entities/Project.cs | 7 + ...529091846_AddProjectModelsPath.Designer.cs | 1358 +++++++++++++++++ .../20260529091846_AddProjectModelsPath.cs | 22 + 11 files changed, 1489 insertions(+), 1 deletion(-) create mode 100644 src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.Designer.cs create mode 100644 src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.cs diff --git a/src/NexusCad.Admin/Models/ProjectDto.cs b/src/NexusCad.Admin/Models/ProjectDto.cs index 8692e6a..624ce4d 100644 --- a/src/NexusCad.Admin/Models/ProjectDto.cs +++ b/src/NexusCad.Admin/Models/ProjectDto.cs @@ -20,6 +20,10 @@ public class ProjectDto public int Status { get; set; } public string FormSchemaJson { get; set; } = "{}"; public string? IconUrl { get; set; } + /// + /// 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/NexusCad.Admin.csproj b/src/NexusCad.Admin/NexusCad.Admin.csproj index 574400e..f810dac 100644 --- a/src/NexusCad.Admin/NexusCad.Admin.csproj +++ b/src/NexusCad.Admin/NexusCad.Admin.csproj @@ -6,6 +6,7 @@ enable enable true + true NexusCad.Admin Resources\logo.ico diff --git a/src/NexusCad.Admin/ViewModels/ProjectEditorViewModel.cs b/src/NexusCad.Admin/ViewModels/ProjectEditorViewModel.cs index 3cb644a..0e66e99 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,21 @@ private void OpenRulesEditor() ErrorMessage = "Rules editor will be implemented in a future update"; } + private void BrowseModelsPath() + { + var dialog = new System.Windows.Forms.FolderBrowserDialog + { + Description = "Seleccionar carpeta de modelos SolidWorks del proyecto", + UseDescriptionForTitle = true, + SelectedPath = ModelsPath ?? string.Empty + }; + + if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK) + { + ModelsPath = dialog.SelectedPath; + } + } + private void OpenFormDesigner() { var formDesignerVm = new FormDesignerViewModel(); @@ -364,6 +395,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/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.Api/Controllers/ProjectsController.cs b/src/NexusCad.Api/Controllers/ProjectsController.cs index 2b6611a..a0560af 100644 --- a/src/NexusCad.Api/Controllers/ProjectsController.cs +++ b/src/NexusCad.Api/Controllers/ProjectsController.cs @@ -116,6 +116,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, @@ -253,6 +254,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 +273,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, @@ -311,6 +314,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 +334,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, @@ -385,6 +390,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, @@ -436,6 +442,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/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.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.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..1e7d039 --- /dev/null +++ b/src/NexusCad.Infrastructure/Migrations/20260529091846_AddProjectModelsPath.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NexusCad.Infrastructure.Migrations +{ + /// + public partial class AddProjectModelsPath : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} From e71fa1f15fa3b633c2878d4ab16a58bb850f8a50 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Fri, 29 May 2026 11:20:41 +0200 Subject: [PATCH 10/31] feat(admin-wpf): user group access control in Edit User dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TabControl with 'Datos básicos' and 'Acceso a grupos' tabs - Role descriptions shown per role (Admin, Autor, Comercial, Cliente) - Group membership tab: checkbox per group + role ComboBox (Owner/Autor/Comercial/Cliente) - EditUser() loads current memberships before opening dialog - EditUser() saves memberships after user confirms - New UserGroupMembershipDto, SetUserMembershipsRequest, MembershipItem DTOs for WPF - GetUserMembershipsAsync / SetUserMembershipsAsync added to INexusCadApi Refit interface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Models/UserGroupMembershipDto.cs | 31 ++ src/NexusCad.Admin/Services/INexusCadApi.cs | 8 + .../ViewModels/SecuritySettingsViewModel.cs | 50 ++- .../ViewModels/UserEditDialogViewModel.cs | 42 +++ src/NexusCad.Admin/Views/UserEditDialog.xaml | 301 +++++++++++++----- .../Views/UserEditDialog.xaml.cs | 67 +++- 6 files changed, 408 insertions(+), 91 deletions(-) create mode 100644 src/NexusCad.Admin/Models/UserGroupMembershipDto.cs 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/Services/INexusCadApi.cs b/src/NexusCad.Admin/Services/INexusCadApi.cs index 81ef25f..af8ee1d 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")] diff --git a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs index 74d4ecf..1f7a185 100644 --- a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs @@ -121,6 +121,34 @@ private async Task EditUser() SelectedRoles = new ObservableCollection(SelectedUser.Roles) }; + // Load all groups and current memberships to populate the groups tab + var allGroups = new List(); + var currentMemberships = new List(); + try + { + var groupsResponse = await _api.GetGroupsAsync(); + if (groupsResponse.Success && groupsResponse.Data != null) + allGroups = groupsResponse.Data; + + var membResponse = await _api.GetUserMembershipsAsync(SelectedUser.Id); + if (membResponse.Success && membResponse.Data != null) + currentMemberships = membResponse.Data; + } + catch { /* non-critical — groups tab will just be empty */ } + + foreach (var g in allGroups) + { + 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, + Role = existing?.Role ?? 2, + }); + } + var owner = Application.Current.MainWindow; var confirmed = UserEditDialog.ShowEditDialog(owner!, dialogVm); if (!confirmed) @@ -132,6 +160,7 @@ private async Task EditUser() ErrorMessage = null; SuccessMessage = null; + // Update basic user data var request = new UpdateUserRequest { FullName = dialogVm.FullName, @@ -146,7 +175,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, Role = g.Role }) + .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 { diff --git a/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs index 3785a6d..8b77b30 100644 --- a/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs @@ -3,9 +3,26 @@ // 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; + + [ObservableProperty] + private int role = 2; // default: Commercial +} + public partial class UserEditDialogViewModel : ObservableObject { [ObservableProperty] @@ -30,6 +47,30 @@ public partial class UserEditDialogViewModel : ObservableObject public List AvailableRoles { get; } + /// + /// Descripción de cada rol global para mostrar al usuario. + /// + public static Dictionary RoleDescriptions { get; } = new() + { + ["Admin"] = "Acceso completo: gestión de usuarios, grupos, proyectos y reglas.", + ["Autor"] = "Crea y edita proyectos, formularios y reglas dentro de sus grupos.", + ["Comercial"] = "Rellena configuradores y genera documentos/modelos.", + ["Cliente"] = "Solo puede acceder a formularios públicos (sin área de admin).", + }; + + /// + /// Grupos con indicador de acceso y rol. Se rellena antes de abrir el diálogo. + /// + public ObservableCollection GroupItems { get; } = new(); + + public static List<(int Value, string Label)> GroupRoles { get; } = new() + { + (0, "Propietario"), + (1, "Autor"), + (2, "Comercial"), + (3, "Cliente"), + }; + public UserEditDialogViewModel(List availableRoles) { AvailableRoles = availableRoles; @@ -66,3 +107,4 @@ public static bool IsPasswordStrong(string password) => && password.Any(char.IsLower) && password.Any(char.IsDigit); } + diff --git a/src/NexusCad.Admin/Views/UserEditDialog.xaml b/src/NexusCad.Admin/Views/UserEditDialog.xaml index 6b8eb18..b8fae4b 100644 --- a/src/NexusCad.Admin/Views/UserEditDialog.xaml +++ b/src/NexusCad.Admin/Views/UserEditDialog.xaml @@ -1,4 +1,4 @@ - @@ -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,214 @@ - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 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/SecuritySettingsView.xaml b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml index 601693c..d758d16 100644 --- a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml +++ b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml @@ -1,4 +1,4 @@ - @@ -308,7 +308,7 @@ BorderThickness="0,0,0,1" Padding="20,12"> - - - + + + + + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" diff --git a/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml.cs b/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml.cs index da2e0cf..95d5ac5 100644 --- a/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml.cs +++ b/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml.cs @@ -2,7 +2,6 @@ // Licensed under AGPL v3 // using System.Windows; -using System.Windows.Controls; using Microsoft.Win32; using NexusCad.Admin.ViewModels; @@ -17,7 +16,19 @@ public WorkspaceEditDialog(WorkspaceEditDialogViewModel viewModel) InitializeComponent(); ViewModel = viewModel; DataContext = viewModel; - Loaded += (_, _) => NameBox.Focus(); + 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) diff --git a/src/NexusCad.Api/Controllers/GroupsController.cs b/src/NexusCad.Api/Controllers/GroupsController.cs index ced2d23..0b95b7b 100644 --- a/src/NexusCad.Api/Controllers/GroupsController.cs +++ b/src/NexusCad.Api/Controllers/GroupsController.cs @@ -314,6 +314,104 @@ private async Task UserCanManageAsync(Guid groupId) return membership != null && membership.Role == GroupRole.Owner; } + // ── 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() { Id = g.Id, 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.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/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/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.Infrastructure/Data/AppDbContext.cs b/src/NexusCad.Infrastructure/Data/AppDbContext.cs index 5ab358f..4c63531 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(); @@ -80,6 +81,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 => { 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/AppDbContextModelSnapshot.cs b/src/NexusCad.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 9fe75ff..22ea898 100644 --- a/src/NexusCad.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusCad.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -768,6 +768,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 +864,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(500) .HasColumnType("TEXT"); + b.Property("ModelsPath") + .HasColumnType("TEXT"); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -1281,6 +1307,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") @@ -1340,6 +1385,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Memberships"); + b.Navigation("ProjectAccesses"); + b.Navigation("Projects"); }); From 9e59762bffb0d662f050b34546bb90dd5ccbd7aa Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Fri, 29 May 2026 12:51:10 +0200 Subject: [PATCH 16/31] fix(admin-wpf): remove orphan XML after in WorkspaceEditDialog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Views/WorkspaceEditDialog.xaml | 179 +----------------- 1 file changed, 8 insertions(+), 171 deletions(-) diff --git a/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml b/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml index 893521a..12fbb5c 100644 --- a/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml +++ b/src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml @@ -1,4 +1,4 @@ - @@ -67,7 +67,7 @@ FontSize="18" FontWeight="SemiBold" Foreground="White" /> - @@ -78,8 +78,8 @@ - - + + @@ -88,18 +88,18 @@ - - + - @@ -138,7 +138,7 @@ - + @@ -267,166 +267,3 @@ - - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks" - xmlns:vm="clr-namespace:NexusCad.Admin.ViewModels" - mc:Ignorable="d" - d:DataContext="{d:DesignInstance Type=vm:WorkspaceEditDialogViewModel}" - Title="Edit Workspace" - Height="480" - Width="540" - WindowStartupLocation="CenterOwner" - ResizeMode="NoResize" - WindowStyle="SingleBorderWindow" - Background="#F5F5F5"> - - - #0078D7 - #212529 - #6C757D - #DEE2E6 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From eef97ed225974d67bfe0039dfd0d0696c13e875d Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Fri, 29 May 2026 13:10:02 +0200 Subject: [PATCH 17/31] fix(api): bind service to port 5140 via appsettings Urls El servicio Windows no usa launchSettings.json, por lo que Kestrel arrancaba en el puerto 5000 por defecto y el Admin (que apunta al 5140) no podia conectar. Se fija Urls en appsettings.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/NexusCad.Api/appsettings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/NexusCad.Api/appsettings.json b/src/NexusCad.Api/appsettings.json index 95f9095..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" }, From 1752e699c009607a5f77c560db3746c2e2e6b458 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Mon, 1 Jun 2026 09:56:39 +0200 Subject: [PATCH 18/31] =?UTF-8?q?feat(usergroups):=20UserGroup=20CRUD=20?= =?UTF-8?q?=E2=80=94=20entity,=20repo,=20API,=20WPF=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Core: UserGroup + UserGroupMember entities, IUserGroupRepository - Infrastructure: UserGroupRepository, AppDbContext mapping, migration 20260601074349_AddUserGroups - API: UserGroupsController (GET/POST/PUT/DELETE + members set), DTOs, register IUserGroupRepository in Program.cs - Admin WPF: GroupEditDialog (config + members tabs), GroupEditDialogViewModel, SecuritySettingsViewModel Groups tab, INexusCadApi endpoints, WorkspacesListChanged propagation to MainWindowViewModel sidebar --- src/NexusCad.Admin/Models/UserGroupDtos.cs | 67 + .../Services/CurrentWorkspaceService.cs | 19 + src/NexusCad.Admin/Services/INexusCadApi.cs | 29 + .../ViewModels/GroupEditDialogViewModel.cs | 88 + .../ViewModels/MainWindowViewModel.cs | 11 + .../ViewModels/SecuritySettingsViewModel.cs | 314 +++- src/NexusCad.Admin/Views/GroupEditDialog.xaml | 244 +++ .../Views/GroupEditDialog.xaml.cs | 58 + .../Views/SecuritySettingsView.xaml | 186 ++- .../Views/SecuritySettingsView.xaml.cs | 1 + .../Controllers/UserGroupsController.cs | 285 ++++ .../DTOs/UserGroups/UserGroupDtos.cs | 83 + src/NexusCad.Api/Program.cs | 1 + src/NexusCad.Core/Entities/UserGroup.cs | 33 + src/NexusCad.Core/Entities/UserGroupMember.cs | 20 + .../Interfaces/IUserGroupRepository.cs | 27 + .../Data/AppDbContext.cs | 41 + .../20260601074349_AddUserGroups.Designer.cs | 1486 +++++++++++++++++ .../20260601074349_AddUserGroups.cs | 78 + .../Migrations/AppDbContextModelSnapshot.cs | 81 + .../Repositories/UserGroupRepository.cs | 72 + 21 files changed, 3217 insertions(+), 7 deletions(-) create mode 100644 src/NexusCad.Admin/Models/UserGroupDtos.cs create mode 100644 src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs create mode 100644 src/NexusCad.Admin/Views/GroupEditDialog.xaml create mode 100644 src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs create mode 100644 src/NexusCad.Api/Controllers/UserGroupsController.cs create mode 100644 src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs create mode 100644 src/NexusCad.Core/Entities/UserGroup.cs create mode 100644 src/NexusCad.Core/Entities/UserGroupMember.cs create mode 100644 src/NexusCad.Core/Interfaces/IUserGroupRepository.cs create mode 100644 src/NexusCad.Infrastructure/Migrations/20260601074349_AddUserGroups.Designer.cs create mode 100644 src/NexusCad.Infrastructure/Migrations/20260601074349_AddUserGroups.cs create mode 100644 src/NexusCad.Infrastructure/Repositories/UserGroupRepository.cs diff --git a/src/NexusCad.Admin/Models/UserGroupDtos.cs b/src/NexusCad.Admin/Models/UserGroupDtos.cs new file mode 100644 index 0000000..ff10b6e --- /dev/null +++ b/src/NexusCad.Admin/Models/UserGroupDtos.cs @@ -0,0 +1,67 @@ +// 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(); +} diff --git a/src/NexusCad.Admin/Services/CurrentWorkspaceService.cs b/src/NexusCad.Admin/Services/CurrentWorkspaceService.cs index 3ced6ee..f43fcc7 100644 --- a/src/NexusCad.Admin/Services/CurrentWorkspaceService.cs +++ b/src/NexusCad.Admin/Services/CurrentWorkspaceService.cs @@ -11,6 +11,12 @@ 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; } @@ -18,6 +24,12 @@ public interface ICurrentWorkspaceService 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(); } /// @@ -39,6 +51,8 @@ public sealed class CurrentWorkspaceService : ICurrentWorkspaceService public event EventHandler? CurrentWorkspaceChanged; + public event EventHandler? WorkspacesListChanged; + public WorkspaceDto? Current { get; private set; } public bool HasActiveWorkspace => Current != null; @@ -50,6 +64,11 @@ public async Task SetCurrentAsync(WorkspaceDto? workspace) CurrentWorkspaceChanged?.Invoke(this, EventArgs.Empty); } + public void NotifyWorkspacesListChanged() + { + WorkspacesListChanged?.Invoke(this, EventArgs.Empty); + } + public async Task RestoreLastAsync(INexusCadApi api) { var storedId = await LoadStoredIdAsync(); diff --git a/src/NexusCad.Admin/Services/INexusCadApi.cs b/src/NexusCad.Admin/Services/INexusCadApi.cs index 0c2d52c..f031a68 100644 --- a/src/NexusCad.Admin/Services/INexusCadApi.cs +++ b/src/NexusCad.Admin/Services/INexusCadApi.cs @@ -112,6 +112,35 @@ Task> GetProjectsAsync( [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); + // LookupTables endpoints [Get("/api/lookuptables")] [Headers("Authorization: Bearer")] diff --git a/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs new file mode 100644 index 0000000..84dc458 --- /dev/null +++ b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs @@ -0,0 +1,88 @@ +// 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)); +} + +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; 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(); + + 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 13de0fb..2e62aa3 100644 --- a/src/NexusCad.Admin/ViewModels/MainWindowViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/MainWindowViewModel.cs @@ -88,6 +88,7 @@ public MainWindowViewModel( _api = api; _navigationService.CurrentViewChanged += OnNavigationViewChanged; _currentWorkspaceService.CurrentWorkspaceChanged += OnCurrentWorkspaceChanged; + _currentWorkspaceService.WorkspacesListChanged += OnWorkspacesListChanged; Stages = new ObservableCollection { @@ -149,6 +150,13 @@ private void OnCurrentWorkspaceChanged(object? sender, EventArgs e) ApplyCurrentWorkspace(); } + private async void OnWorkspacesListChanged(object? sender, EventArgs e) + { + // Otro VM (SecuritySettings, etc.) ha creado/editado/borrado un workspace. + // Refrescamos la lista del sidebar para mantenerla coherente. + await RefreshWorkspacesAsync(); + } + private void ApplyCurrentWorkspace() { var g = _currentWorkspaceService.Current; @@ -358,6 +366,7 @@ private async Task NewWorkspace() await RefreshWorkspacesAsync(); ApplyCurrentWorkspace(); await RefreshProjectsAsync(); + _currentWorkspaceService.NotifyWorkspacesListChanged(); } } @@ -426,6 +435,7 @@ private async Task RenameWorkspace(WorkspaceListItemDto? item) } await RefreshWorkspacesAsync(); + _currentWorkspaceService.NotifyWorkspacesListChanged(); } catch (Refit.ApiException apiEx) { @@ -452,6 +462,7 @@ private async Task TryDeleteWorkspaceAsync(Guid workspaceId, string label, bool await RefreshWorkspacesAsync(); ApplyCurrentWorkspace(); await RefreshProjectsAsync(); + _currentWorkspaceService.NotifyWorkspacesListChanged(); } catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode == 409 && !force) { diff --git a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs index ca5a0ad..a110cb0 100644 --- a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs @@ -19,6 +19,7 @@ public partial class SecuritySettingsViewModel : ObservableObject { private readonly INexusCadApi _api; private readonly IAuthService _authService; + private readonly ICurrentWorkspaceService _currentWorkspaceService; // ── Users ────────────────────────────────────────────────────────────── @@ -61,10 +62,45 @@ public partial class SecuritySettingsViewModel : ObservableObject [ObservableProperty] private string? workspacesSuccessMessage; - public SecuritySettingsViewModel(INexusCadApi api, IAuthService authService) + // ── 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] @@ -337,6 +373,9 @@ private async Task CreateWorkspace() WorkspacesErrorMessage = null; await LoadWorkspacesAsync(); SelectedWorkspace = Workspaces.FirstOrDefault(g => g.Id == created.Id); + _suppressExternalReload = true; + try { _currentWorkspaceService.NotifyWorkspacesListChanged(); } + finally { _suppressExternalReload = false; } } [RelayCommand(CanExecute = nameof(CanEditWorkspace))] @@ -431,6 +470,9 @@ private async Task EditWorkspace() WorkspacesSuccessMessage = "Workspace updated successfully"; await LoadWorkspacesAsync(); SelectedWorkspace = Workspaces.FirstOrDefault(g => g.Id == response.Data.Id); + _suppressExternalReload = true; + try { _currentWorkspaceService.NotifyWorkspacesListChanged(); } + finally { _suppressExternalReload = false; } } else { @@ -478,6 +520,9 @@ private async Task DeleteWorkspace() 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) { @@ -558,4 +603,271 @@ 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()); + + 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(), + 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}"; + } + } + + 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); + + 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; + } + + 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 + } + } } diff --git a/src/NexusCad.Admin/Views/GroupEditDialog.xaml b/src/NexusCad.Admin/Views/GroupEditDialog.xaml new file mode 100644 index 0000000..d4618d9 --- /dev/null +++ b/src/NexusCad.Admin/Views/GroupEditDialog.xaml @@ -0,0 +1,244 @@ + + + + + #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..ae299c4 --- /dev/null +++ b/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs @@ -0,0 +1,58 @@ +// 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(); + }; + viewModel.UserItems.CollectionChanged += (_, _) => RefreshNoUsersText(); + } + + private void RefreshNoUsersText() + { + NoUsersText.Visibility = ViewModel.UserItems.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/SecuritySettingsView.xaml b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml index d758d16..c1670a8 100644 --- a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml +++ b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml @@ -283,15 +283,15 @@ - + - - + @@ -441,15 +441,189 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +/// 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); + } + + // ── 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/DTOs/UserGroups/UserGroupDtos.cs b/src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs new file mode 100644 index 0000000..d29a9e0 --- /dev/null +++ b/src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs @@ -0,0 +1,83 @@ +// 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(); +} diff --git a/src/NexusCad.Api/Program.cs b/src/NexusCad.Api/Program.cs index 62d68b7..ca7fff9 100644 --- a/src/NexusCad.Api/Program.cs +++ b/src/NexusCad.Api/Program.cs @@ -131,6 +131,7 @@ // Repositories builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/NexusCad.Core/Entities/UserGroup.cs b/src/NexusCad.Core/Entities/UserGroup.cs new file mode 100644 index 0000000..6c77084 --- /dev/null +++ b/src/NexusCad.Core/Entities/UserGroup.cs @@ -0,0 +1,33 @@ +// 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(); +} 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/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 4c63531..bbd344b 100644 --- a/src/NexusCad.Infrastructure/Data/AppDbContext.cs +++ b/src/NexusCad.Infrastructure/Data/AppDbContext.cs @@ -36,6 +36,8 @@ 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(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -403,6 +405,32 @@ 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); + }); + // Configurar GeneratedDocument modelBuilder.Entity(entity => { @@ -531,6 +559,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) { @@ -562,6 +599,10 @@ private void UpdateTimestamps() { dimRuleUpd.UpdatedAt = now; } + else if (entry.Entity is UserGroup userGroupUpd) + { + userGroupUpd.UpdatedAt = now; + } } } } 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/AppDbContextModelSnapshot.cs b/src/NexusCad.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 22ea898..850ea91 100644 --- a/src/NexusCad.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/NexusCad.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -970,6 +970,71 @@ 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.Variable", b => { b.Property("Id") @@ -1357,6 +1422,17 @@ 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.Variable", b => { b.HasOne("NexusCad.Core.Entities.Project", "Project") @@ -1396,6 +1472,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Variables"); }); + + modelBuilder.Entity("NexusCad.Core.Entities.UserGroup", b => + { + b.Navigation("Members"); + }); #pragma warning restore 612, 618 } } 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); +} From a6055016b46cbd56ede4a84e9687716659fc8c75 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Mon, 1 Jun 2026 12:34:27 +0200 Subject: [PATCH 19/31] fix(admin): expose group-load errors in user create/edit dialogs --- nexuscad.db.bak-pre-uninstall-20260529-131623 | Bin 0 -> 462848 bytes src/NexusCad.Admin/Models/RegisterRequest.cs | 3 - .../Models/UpdateUserRequest.cs | 2 - .../ViewModels/GroupEditDialogViewModel.cs | 14 ++- .../ViewModels/SecuritySettingsViewModel.cs | 108 +++++++++++++++--- .../ViewModels/UserEditDialogViewModel.cs | 28 +---- src/NexusCad.Admin/Views/GroupEditDialog.xaml | 21 +++- .../Views/SecuritySettingsView.xaml | 29 +---- src/NexusCad.Admin/Views/UserEditDialog.xaml | 41 +------ .../Views/UserEditDialog.xaml.cs | 44 +------ 10 files changed, 136 insertions(+), 154 deletions(-) create mode 100644 nexuscad.db.bak-pre-uninstall-20260529-131623 diff --git a/nexuscad.db.bak-pre-uninstall-20260529-131623 b/nexuscad.db.bak-pre-uninstall-20260529-131623 new file mode 100644 index 0000000000000000000000000000000000000000..2b7c4f6e87630608d04e33f17882c201c20eabf7 GIT binary patch literal 462848 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 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/ViewModels/GroupEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs index 84dc458..7b3eff9 100644 --- a/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs @@ -13,6 +13,7 @@ namespace NexusCad.Admin.ViewModels; public class GroupUserItem : INotifyPropertyChanged { private bool _isMember; + private int _role = 3; // default: Cliente public string UserId { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; @@ -24,6 +25,12 @@ public bool IsMember set { _isMember = value; OnPropertyChanged(); } } + public int Role + { + get => _role; + set { _role = value; OnPropertyChanged(); } + } + public event PropertyChangedEventHandler? PropertyChanged; protected void OnPropertyChanged([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); @@ -55,7 +62,12 @@ public bool IsEditMode public string Code { get => _code; - set { _code = value; OnPropertyChanged(); OnPropertyChanged(nameof(Title)); } + set + { + _code = (value ?? string.Empty).ToLowerInvariant().Replace(" ", "-"); + OnPropertyChanged(); + OnPropertyChanged(nameof(Title)); + } } public string Name diff --git a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs index a110cb0..525b725 100644 --- a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs @@ -149,30 +149,35 @@ 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 groups and current memberships to populate the groups tab - var allGroups = new List(); + // 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.GetWorkspacesAsync(); + var groupsResponse = await _api.GetUserGroupsAsync(); if (groupsResponse.Success && groupsResponse.Data != null) - allGroups = groupsResponse.Data; + 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 { /* non-critical — groups tab will just be empty */ } + catch (Exception ex) + { + groupsLoadError = $"Error cargando grupos: {ex.Message}"; + } - foreach (var g in allGroups) + foreach (var g in allUserGroups) { var existing = currentMemberships.FirstOrDefault(m => m.GroupId == g.Id); dialogVm.GroupItems.Add(new GroupMembershipItem @@ -185,6 +190,9 @@ private async Task EditUser() }); } + if (groupsLoadError != null) + ErrorMessage = groupsLoadError; + var owner = Application.Current.MainWindow; var confirmed = UserEditDialog.ShowEditDialog(owner!, dialogVm); if (!confirmed) @@ -201,7 +209,6 @@ private async Task EditUser() { FullName = dialogVm.FullName, IsActive = dialogVm.IsActive, - Roles = dialogVm.SelectedRoles.ToList(), NewPassword = string.IsNullOrWhiteSpace(dialogVm.Password) ? null : dialogVm.Password }; @@ -259,7 +266,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; @@ -547,11 +562,43 @@ private async Task DeleteWorkspace() [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, + Role = 2, + }); + } + } + 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) @@ -568,12 +615,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, Role = g.Role }) + .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); @@ -589,6 +652,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, Role = g.Role }) + .ToList() + }; + await _api.SetUserMembershipsAsync(SelectedUser.Id, membRequest); + } + catch { /* non-critical */ } + } + } } catch (Refit.ApiException apiEx) { @@ -666,7 +748,7 @@ private async Task CreateUserGroup() var request = new CreateUserGroupRequest { - Code = dialogVm.Code.Trim(), + Code = dialogVm.Code.Trim().ToLowerInvariant().Replace(" ", "-"), Name = dialogVm.Name.Trim(), Description = string.IsNullOrWhiteSpace(dialogVm.Description) ? null : dialogVm.Description, }; diff --git a/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs index 8b77b30..70c195b 100644 --- a/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs @@ -37,29 +37,13 @@ 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 List AvailableRoles { get; } - - /// - /// Descripción de cada rol global para mostrar al usuario. - /// - public static Dictionary RoleDescriptions { get; } = new() - { - ["Admin"] = "Acceso completo: gestión de usuarios, grupos, proyectos y reglas.", - ["Autor"] = "Crea y edita proyectos, formularios y reglas dentro de sus grupos.", - ["Comercial"] = "Rellena configuradores y genera documentos/modelos.", - ["Cliente"] = "Solo puede acceder a formularios públicos (sin área de admin).", - }; + public string Title => IsEditMode ? "Editar usuario" : "Nuevo usuario"; /// - /// Grupos con indicador de acceso y rol. Se rellena antes de abrir el diálogo. + /// Grupos con indicador de acceso y rol dentro del grupo. Se rellena antes de abrir el diálogo. /// public ObservableCollection GroupItems { get; } = new(); @@ -71,10 +55,7 @@ public partial class UserEditDialogViewModel : ObservableObject (3, "Cliente"), }; - public UserEditDialogViewModel(List availableRoles) - { - AvailableRoles = availableRoles; - } + public UserEditDialogViewModel() { } public bool IsValid() { @@ -93,9 +74,6 @@ public bool IsValid() return false; } - if (!SelectedRoles.Any()) - return false; - return true; } diff --git a/src/NexusCad.Admin/Views/GroupEditDialog.xaml b/src/NexusCad.Admin/Views/GroupEditDialog.xaml index d4618d9..c4d9c64 100644 --- a/src/NexusCad.Admin/Views/GroupEditDialog.xaml +++ b/src/NexusCad.Admin/Views/GroupEditDialog.xaml @@ -153,16 +153,18 @@ - + + + - + - + + + + + + + + + diff --git a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml index c1670a8..6bc648c 100644 --- a/src/NexusCad.Admin/Views/SecuritySettingsView.xaml +++ b/src/NexusCad.Admin/Views/SecuritySettingsView.xaml @@ -1,4 +1,4 @@ - @@ -195,32 +195,7 @@ Binding="{Binding FullName}" Width="*" MinWidth="150"/> - - - - - - - - - - - - - - - - - - - - - + diff --git a/src/NexusCad.Admin/Views/UserEditDialog.xaml b/src/NexusCad.Admin/Views/UserEditDialog.xaml index b8fae4b..86b59e8 100644 --- a/src/NexusCad.Admin/Views/UserEditDialog.xaml +++ b/src/NexusCad.Admin/Views/UserEditDialog.xaml @@ -1,4 +1,4 @@ - @@ -141,43 +141,6 @@ Margin="0,0,0,16" FontSize="13" /> - - - - - - - - - - - - - - - - - - - - (container); - if (cb?.Tag is string role) - { - cb.IsChecked = ViewModel.SelectedRoles.Contains(role); - var descBlock = FindNamedChild(container, "RoleDesc"); - if (descBlock != null && UserEditDialogViewModel.RoleDescriptions.TryGetValue(role, out var desc)) - descBlock.Text = desc; - } - } - } + // ── Grupos ─────────────────────────────────────────────────────────────── private void RefreshGroupComboBoxes() { @@ -118,22 +96,6 @@ private void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e) ViewModel.Password = PwdBox.Password; } - private void RoleCheckBox_Changed(object sender, RoutedEventArgs e) - { - if (sender is CheckBox cb && cb.Tag is string role) - { - if (cb.IsChecked == true) - { - if (!ViewModel.SelectedRoles.Contains(role)) - ViewModel.SelectedRoles.Add(role); - } - else - { - ViewModel.SelectedRoles.Remove(role); - } - } - } - private void OkButton_Click(object sender, RoutedEventArgs e) { var errorText = (TextBlock?)FindName("ErrorText"); @@ -144,8 +106,8 @@ private void OkButton_Click(object sender, RoutedEventArgs e) if (errorText != null) { errorText.Text = ViewModel.IsEditMode - ? "Nombre y al menos un rol son obligatorios. Si cambias la contraseña debe tener 8+ caracteres con mayúscula, minúscula y número." - : "Nombre, email, contraseña (8+ chars con mayúscula, minúscula y número) y al menos un rol son obligatorios."; + ? "El nombre es obligatorio. Si cambias la contraseña debe tener 8+ caracteres con mayúscula, minúscula y número." + : "Nombre, email y contraseña (8+ chars con mayúscula, minúscula y número) son obligatorios."; errorText.Visibility = Visibility.Visible; } return; From a38b1dc976e57e97cb6a1160dab07f0436196831 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Mon, 1 Jun 2026 13:13:23 +0200 Subject: [PATCH 20/31] refactor(security): replace roles with group-based project access - Remove GroupRole enum usage from all controllers and DTOs - Simplify GroupMembership to membership-only (no role column) - Add UserGroupProjectAccess entity for per-group project visibility - Remove role ComboBox from GroupEditDialog and UserEditDialog - Reduce system roles to Admin only - Add EF migration: AddUserGroupProjectAccess_RemoveMembershipRole --- .../ViewModels/GroupEditDialogViewModel.cs | 7 - .../ViewModels/SecuritySettingsViewModel.cs | 7 +- .../ViewModels/UserEditDialogViewModel.cs | 13 +- src/NexusCad.Admin/Views/GroupEditDialog.xaml | 16 +- src/NexusCad.Admin/Views/UserEditDialog.xaml | 16 - .../Views/UserEditDialog.xaml.cs | 53 - .../Controllers/AuthController.cs | 12 +- .../Controllers/CapturesController.cs | 2 +- .../Controllers/DimensionRulesController.cs | 2 +- .../Controllers/GroupsController.cs | 15 +- .../Controllers/LookupTablesController.cs | 2 +- .../Controllers/ProjectsController.cs | 40 +- .../Controllers/SpecificationsController.cs | 13 +- .../Controllers/UserGroupsController.cs | 70 + .../Controllers/VariablesController.cs | 2 +- .../DTOs/Auth/UserGroupMembershipDto.cs | 6 +- .../DTOs/Groups/GroupMemberDto.cs | 5 - .../DTOs/UserGroups/UserGroupDtos.cs | 20 + src/NexusCad.Core/Constants/Roles.cs | 24 +- src/NexusCad.Core/Entities/GroupMembership.cs | 6 +- src/NexusCad.Core/Entities/UserGroup.cs | 5 + .../Entities/UserGroupProjectAccess.cs | 22 + .../Interfaces/IGroupRepository.cs | 3 +- .../Data/AppDbContext.cs | 19 + ...ectAccess_RemoveMembershipRole.Designer.cs | 1524 +++++++++++++++++ ...GroupProjectAccess_RemoveMembershipRole.cs | 68 + .../Migrations/AppDbContextModelSnapshot.cs | 44 +- .../Repositories/GroupRepository.cs | 4 +- .../Seeders/DefaultGroupSeeder.cs | 2 - 29 files changed, 1836 insertions(+), 186 deletions(-) create mode 100644 src/NexusCad.Core/Entities/UserGroupProjectAccess.cs create mode 100644 src/NexusCad.Infrastructure/Migrations/20260601111304_AddUserGroupProjectAccess_RemoveMembershipRole.Designer.cs create mode 100644 src/NexusCad.Infrastructure/Migrations/20260601111304_AddUserGroupProjectAccess_RemoveMembershipRole.cs diff --git a/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs index 7b3eff9..bbd1e25 100644 --- a/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs @@ -13,7 +13,6 @@ namespace NexusCad.Admin.ViewModels; public class GroupUserItem : INotifyPropertyChanged { private bool _isMember; - private int _role = 3; // default: Cliente public string UserId { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; @@ -25,12 +24,6 @@ public bool IsMember set { _isMember = value; OnPropertyChanged(); } } - public int Role - { - get => _role; - set { _role = value; OnPropertyChanged(); } - } - public event PropertyChangedEventHandler? PropertyChanged; protected void OnPropertyChanged([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); diff --git a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs index 525b725..c7bc0c1 100644 --- a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs @@ -186,7 +186,6 @@ private async Task EditUser() GroupCode = g.Code, GroupName = g.Name, HasAccess = existing != null, - Role = existing?.Role ?? 2, }); } @@ -224,7 +223,7 @@ private async Task EditUser() { Memberships = dialogVm.GroupItems .Where(g => g.HasAccess) - .Select(g => new MembershipItem { GroupId = g.GroupId, Role = g.Role }) + .Select(g => new MembershipItem { GroupId = g.GroupId }) .ToList() }; try @@ -629,7 +628,7 @@ private async Task CreateUser() var membRequest = new SetUserMembershipsRequest { Memberships = selectedGroups - .Select(g => new MembershipItem { GroupId = g.GroupId, Role = g.Role }) + .Select(g => new MembershipItem { GroupId = g.GroupId }) .ToList() }; await _api.SetUserMembershipsAsync(response.Data.Id, membRequest); @@ -663,7 +662,7 @@ private async Task CreateUser() var membRequest = new SetUserMembershipsRequest { Memberships = selectedGroups - .Select(g => new MembershipItem { GroupId = g.GroupId, Role = g.Role }) + .Select(g => new MembershipItem { GroupId = g.GroupId }) .ToList() }; await _api.SetUserMembershipsAsync(SelectedUser.Id, membRequest); diff --git a/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs index 70c195b..0d2a21d 100644 --- a/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/UserEditDialogViewModel.cs @@ -18,9 +18,6 @@ public partial class GroupMembershipItem : ObservableObject [ObservableProperty] private bool hasAccess; - - [ObservableProperty] - private int role = 2; // default: Commercial } public partial class UserEditDialogViewModel : ObservableObject @@ -43,18 +40,10 @@ public partial class UserEditDialogViewModel : ObservableObject public string Title => IsEditMode ? "Editar usuario" : "Nuevo usuario"; /// - /// Grupos con indicador de acceso y rol dentro del grupo. Se rellena antes de abrir el diálogo. + /// Grupos a los que puede pertenecer el usuario. Se rellena antes de abrir el diálogo. /// public ObservableCollection GroupItems { get; } = new(); - public static List<(int Value, string Label)> GroupRoles { get; } = new() - { - (0, "Propietario"), - (1, "Autor"), - (2, "Comercial"), - (3, "Cliente"), - }; - public UserEditDialogViewModel() { } public bool IsValid() diff --git a/src/NexusCad.Admin/Views/GroupEditDialog.xaml b/src/NexusCad.Admin/Views/GroupEditDialog.xaml index c4d9c64..2fd44d6 100644 --- a/src/NexusCad.Admin/Views/GroupEditDialog.xaml +++ b/src/NexusCad.Admin/Views/GroupEditDialog.xaml @@ -1,4 +1,4 @@ - @@ -154,12 +154,10 @@ - - @@ -175,7 +173,6 @@ - - - - - - - diff --git a/src/NexusCad.Admin/Views/UserEditDialog.xaml b/src/NexusCad.Admin/Views/UserEditDialog.xaml index 86b59e8..a163a2c 100644 --- a/src/NexusCad.Admin/Views/UserEditDialog.xaml +++ b/src/NexusCad.Admin/Views/UserEditDialog.xaml @@ -183,11 +183,9 @@ - - @@ -203,7 +201,6 @@ - - - - - - - - diff --git a/src/NexusCad.Admin/Views/UserEditDialog.xaml.cs b/src/NexusCad.Admin/Views/UserEditDialog.xaml.cs index 3468101..a94eac7 100644 --- a/src/NexusCad.Admin/Views/UserEditDialog.xaml.cs +++ b/src/NexusCad.Admin/Views/UserEditDialog.xaml.cs @@ -23,38 +23,11 @@ public UserEditDialog(UserEditDialogViewModel viewModel) private void OnLoaded(object sender, RoutedEventArgs e) { FullNameBox.Focus(); - RefreshGroupComboBoxes(); RefreshNoGroupsText(); } // ── Grupos ─────────────────────────────────────────────────────────────── - private void RefreshGroupComboBoxes() - { - if (GroupItemsControl == null) return; - GroupItemsControl.UpdateLayout(); - for (int i = 0; i < GroupItemsControl.Items.Count; i++) - { - var container = GroupItemsControl.ItemContainerGenerator.ContainerFromIndex(i) as ContentPresenter; - if (container == null) continue; - container.ApplyTemplate(); - - // Ensure ComboBox shows the correct integer tag selection - var combo = FindNamedChild(container, "RoleCombo"); - if (combo != null && combo.DataContext is ViewModels.GroupMembershipItem item) - { - foreach (ComboBoxItem ci in combo.Items) - { - if (ci.Tag is string tagStr && int.TryParse(tagStr, out int tagVal) && tagVal == item.Role) - { - combo.SelectedItem = ci; - break; - } - } - } - } - } - private void RefreshNoGroupsText() { if (NoGroupsText == null) return; @@ -63,32 +36,6 @@ private void RefreshNoGroupsText() : Visibility.Collapsed; } - // ── Helpers ────────────────────────────────────────────────────────────── - - private static T? FindVisualChild(DependencyObject parent) where T : DependencyObject - { - for (int i = 0; i < System.Windows.Media.VisualTreeHelper.GetChildrenCount(parent); i++) - { - var child = System.Windows.Media.VisualTreeHelper.GetChild(parent, i); - if (child is T result) return result; - var found = FindVisualChild(child); - if (found != null) return found; - } - return null; - } - - private static T? FindNamedChild(DependencyObject parent, string name) where T : FrameworkElement - { - for (int i = 0; i < System.Windows.Media.VisualTreeHelper.GetChildrenCount(parent); i++) - { - var child = System.Windows.Media.VisualTreeHelper.GetChild(parent, i); - if (child is T fe && fe.Name == name) return fe; - var found = FindNamedChild(child, name); - if (found != null) return found; - } - return null; - } - // ── Event handlers ─────────────────────────────────────────────────────── private void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e) diff --git a/src/NexusCad.Api/Controllers/AuthController.cs b/src/NexusCad.Api/Controllers/AuthController.cs index 433f18f..361d6ae 100644 --- a/src/NexusCad.Api/Controllers/AuthController.cs +++ b/src/NexusCad.Api/Controllers/AuthController.cs @@ -491,7 +491,6 @@ public async Task>>> GetUs GroupCode = m.Group?.Code ?? string.Empty, GroupName = m.Group?.Name ?? string.Empty, GroupIconUrl = m.Group?.IconUrl, - Role = m.Role, CreatedAt = m.CreatedAt, }).ToList(); @@ -532,17 +531,13 @@ public async Task>>> SetUs var toRemove = existing.Where(e => !desiredGroupIds.Contains(e.GroupId)).ToList(); _context.GroupMemberships.RemoveRange(toRemove); - // Add or update + // 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) + if (existingMembership == null) { - existingMembership.Role = item.Role; - } - else - { - await _groupRepository.AddMembershipAsync(item.GroupId, id, item.Role); + await _groupRepository.AddMembershipAsync(item.GroupId, id); } } @@ -562,7 +557,6 @@ public async Task>>> SetUs GroupCode = m.Group?.Code ?? string.Empty, GroupName = m.Group?.Name ?? string.Empty, GroupIconUrl = m.Group?.IconUrl, - Role = m.Role, CreatedAt = m.CreatedAt, }).ToList(); 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 0b95b7b..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,8 @@ 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 ──────────────────────────────────────────────────────── 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 a0560af..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, @@ -214,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( @@ -298,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) @@ -359,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) @@ -413,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) 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 index 822d13d..c3a77d2 100644 --- a/src/NexusCad.Api/Controllers/UserGroupsController.cs +++ b/src/NexusCad.Api/Controllers/UserGroupsController.cs @@ -254,6 +254,76 @@ public async Task>>> SetMember 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); 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 index 0867801..1428780 100644 --- a/src/NexusCad.Api/DTOs/Auth/UserGroupMembershipDto.cs +++ b/src/NexusCad.Api/DTOs/Auth/UserGroupMembershipDto.cs @@ -1,8 +1,6 @@ // Copyright (C) 2026 JaviFRx // Licensed under AGPL v3 // -using NexusCad.Core.Enums; - namespace NexusCad.Api.DTOs.Auth; /// @@ -14,14 +12,13 @@ public class UserGroupMembershipDto public string GroupCode { get; set; } = string.Empty; public string GroupName { get; set; } = string.Empty; public string? GroupIconUrl { get; set; } - public GroupRole Role { get; set; } public DateTime CreatedAt { get; set; } } public class SetUserMembershipsRequest { /// - /// Lista de membresías deseada. Las existentes que no aparezcan aquí serán eliminadas. + /// Lista de grupos a los que debe pertenecer el usuario. Los no incluidos serán eliminados. /// public List Memberships { get; set; } = new(); } @@ -29,5 +26,4 @@ public class SetUserMembershipsRequest public class UserMembershipItem { public Guid GroupId { get; set; } - public GroupRole Role { get; set; } = GroupRole.Commercial; } 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/UserGroups/UserGroupDtos.cs b/src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs index d29a9e0..ec7ffaf 100644 --- a/src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs +++ b/src/NexusCad.Api/DTOs/UserGroups/UserGroupDtos.cs @@ -81,3 +81,23 @@ 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.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/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/UserGroup.cs b/src/NexusCad.Core/Entities/UserGroup.cs index 6c77084..2c269ca 100644 --- a/src/NexusCad.Core/Entities/UserGroup.cs +++ b/src/NexusCad.Core/Entities/UserGroup.cs @@ -30,4 +30,9 @@ public class UserGroup 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/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/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.Infrastructure/Data/AppDbContext.cs b/src/NexusCad.Infrastructure/Data/AppDbContext.cs index bbd344b..5575537 100644 --- a/src/NexusCad.Infrastructure/Data/AppDbContext.cs +++ b/src/NexusCad.Infrastructure/Data/AppDbContext.cs @@ -38,6 +38,7 @@ public AppDbContext(DbContextOptions options) : base(options) public DbSet DimensionRules => Set(); public DbSet UserGroups => Set(); public DbSet UserGroupMembers => Set(); + public DbSet UserGroupProjectAccesses => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -431,6 +432,24 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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 => { 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 850ea91..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"); @@ -1035,6 +1032,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -1433,6 +1450,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -1476,6 +1512,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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/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 Date: Wed, 3 Jun 2026 12:15:05 +0200 Subject: [PATCH 21/31] fix(admin): remove stale Role property from GroupMembershipItem initialization --- src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs index c7bc0c1..f43c73f 100644 --- a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs @@ -581,7 +581,6 @@ private async Task CreateUser() GroupCode = g.Code, GroupName = g.Name, HasAccess = false, - Role = 2, }); } } From da28fe29445976323482661abb36da8ef0fee239 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Wed, 3 Jun 2026 12:47:53 +0200 Subject: [PATCH 22/31] feat(admin): add project access tab to GroupEditDialog - Add GroupProjectItem class to GroupEditDialogViewModel - Add ProjectItems collection to GroupEditDialogViewModel - Add 'Proyectos' tab in GroupEditDialog.xaml with checkbox list - Update GroupEditDialog.xaml.cs to handle NoProjectsText visibility - Add GetUserGroupProjectsAsync/SetUserGroupProjectsAsync to INexusCadApi - Add UserGroupProjectDto and SetUserGroupProjectsRequest to Admin.Models - Load current project access when editing a group - Save selected projects on confirm (create and edit flow) --- src/NexusCad.Admin/Models/UserGroupDtos.cs | 13 ++++ src/NexusCad.Admin/Services/INexusCadApi.cs | 8 +++ .../ViewModels/GroupEditDialogViewModel.cs | 27 +++++++- .../ViewModels/SecuritySettingsViewModel.cs | 66 +++++++++++++++++++ src/NexusCad.Admin/Views/GroupEditDialog.xaml | 48 ++++++++++++++ .../Views/GroupEditDialog.xaml.cs | 9 +++ 6 files changed, 170 insertions(+), 1 deletion(-) diff --git a/src/NexusCad.Admin/Models/UserGroupDtos.cs b/src/NexusCad.Admin/Models/UserGroupDtos.cs index ff10b6e..00a2fe8 100644 --- a/src/NexusCad.Admin/Models/UserGroupDtos.cs +++ b/src/NexusCad.Admin/Models/UserGroupDtos.cs @@ -65,3 +65,16 @@ 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/Services/INexusCadApi.cs b/src/NexusCad.Admin/Services/INexusCadApi.cs index f031a68..986e861 100644 --- a/src/NexusCad.Admin/Services/INexusCadApi.cs +++ b/src/NexusCad.Admin/Services/INexusCadApi.cs @@ -141,6 +141,14 @@ Task> GetProjectsAsync( [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")] [Headers("Authorization: Bearer")] diff --git a/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs index bbd1e25..5f0b7b6 100644 --- a/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/GroupEditDialogViewModel.cs @@ -1,4 +1,4 @@ -// Copyright (C) 2026 JaviFRx +// Copyright (C) 2026 JaviFRx // Licensed under AGPL v3 // using System.Collections.ObjectModel; @@ -29,6 +29,28 @@ 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; @@ -78,6 +100,9 @@ public string Description /// 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)) diff --git a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs index f43c73f..c95bb24 100644 --- a/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs +++ b/src/NexusCad.Admin/ViewModels/SecuritySettingsViewModel.cs @@ -732,6 +732,7 @@ private async Task CreateUserGroup() // 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); @@ -769,6 +770,18 @@ await _api.SetUserGroupMembersAsync(response.Data.Id, } } + // 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); @@ -826,6 +839,21 @@ private async Task EditUserGroup() 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) @@ -859,6 +887,19 @@ await _api.SetUserGroupMembersAsync(SelectedUserGroup.Id, 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); @@ -950,4 +991,29 @@ private async Task PopulateUserItemsAsync(GroupEditDialogViewModel dialogVm, Has // 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/Views/GroupEditDialog.xaml b/src/NexusCad.Admin/Views/GroupEditDialog.xaml index 2fd44d6..2a7aa5f 100644 --- a/src/NexusCad.Admin/Views/GroupEditDialog.xaml +++ b/src/NexusCad.Admin/Views/GroupEditDialog.xaml @@ -210,6 +210,53 @@ Visibility="Collapsed" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -243,3 +290,4 @@ + diff --git a/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs b/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs index ae299c4..65772e0 100644 --- a/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs +++ b/src/NexusCad.Admin/Views/GroupEditDialog.xaml.cs @@ -22,8 +22,10 @@ public GroupEditDialog(GroupEditDialogViewModel viewModel) else CodeBox.Focus(); RefreshNoUsersText(); + RefreshNoProjectsText(); }; viewModel.UserItems.CollectionChanged += (_, _) => RefreshNoUsersText(); + viewModel.ProjectItems.CollectionChanged += (_, _) => RefreshNoProjectsText(); } private void RefreshNoUsersText() @@ -33,6 +35,13 @@ private void RefreshNoUsersText() : 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; From a7f5fe0e9110c9e306ea2ba4dc8a02e4853019b6 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Wed, 3 Jun 2026 13:02:59 +0200 Subject: [PATCH 23/31] =?UTF-8?q?fix:=20corregir=20encoding=20UTF-8=20en?= =?UTF-8?q?=20XAML=20(s=C3=ADmbolos=20extra=C3=B1os=20en=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/NexusCad.Admin/Views/UserEditDialog.xaml | 14 +++++++------- src/NexusCad.Admin/Views/WorkspaceEditDialog.xaml | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/NexusCad.Admin/Views/UserEditDialog.xaml b/src/NexusCad.Admin/Views/UserEditDialog.xaml index a163a2c..c363422 100644 --- a/src/NexusCad.Admin/Views/UserEditDialog.xaml +++ b/src/NexusCad.Admin/Views/UserEditDialog.xaml @@ -83,8 +83,8 @@ - - + + @@ -118,10 +118,10 @@ Foreground="#212529"> @@ -130,7 +130,7 @@ - @@ -152,7 +152,7 @@ - + @@ -161,7 +161,7 @@ - @@ -78,8 +78,8 @@ - - + + @@ -88,18 +88,18 @@ - - + - From 285f1e34603e1a6cf9c016807e65309980aabc02 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Wed, 3 Jun 2026 13:16:50 +0200 Subject: [PATCH 24/31] fix: Eliminar referencia a launch.json inexistente en NexusCad.Web.esproj --- web/NexusCad.Web.esproj | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/web/NexusCad.Web.esproj b/web/NexusCad.Web.esproj index eabb545..9d4d493 100644 --- a/web/NexusCad.Web.esproj +++ b/web/NexusCad.Web.esproj @@ -1,4 +1,4 @@ - + pnpm dev src/ @@ -10,7 +10,4 @@ C:\Program Files\nodejs\node.exe false - - - - \ No newline at end of file + From 3f16e8c3c1e1e09f2bd31cc2c355c2e64505b5fc Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Wed, 3 Jun 2026 13:20:29 +0200 Subject: [PATCH 25/31] fix: Eliminar PublishProfileName de NexusCad.Web.esproj para corregir error de deploy --- web/NexusCad.Web.esproj | 1 - 1 file changed, 1 deletion(-) diff --git a/web/NexusCad.Web.esproj b/web/NexusCad.Web.esproj index 9d4d493..6eb03f1 100644 --- a/web/NexusCad.Web.esproj +++ b/web/NexusCad.Web.esproj @@ -4,7 +4,6 @@ src/ Jest false - FolderProfile pnpm build pnpm clean C:\Program Files\nodejs\node.exe From 6808ec9e9d579ca69e0e2a3c7da6fd69cecbf625 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:24:46 +0000 Subject: [PATCH 26/31] chore(deps): Bump Swashbuckle.AspNetCore from 7.2.0 to 10.2.1 --- updated-dependencies: - dependency-name: Swashbuckle.AspNetCore dependency-version: 10.2.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- src/NexusCad.Api/NexusCad.Api.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 @@ - + From ed6f875144dec760c5a696287989093788a668c9 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Wed, 3 Jun 2026 13:25:44 +0200 Subject: [PATCH 27/31] fix: Add launch configuration files for NexusCad.Web project - Created .vscode/launch.json with Chrome, Edge, and pnpm dev configurations - Added web/Properties/launchSettings.json for Visual Studio deployment - Configured correct port (3000) matching vite.config.ts settings - Resolves 'El valor no puede ser nulo' deployment error --- .vscode/launch.json | 46 ++++++++++++++++++++++++++++++ web/NexusCad.Web.esproj | 7 +++-- web/Properties/launchSettings.json | 13 +++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 web/Properties/launchSettings.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..fa979b6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,46 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}/web", + "sourceMaps": true, + "sourceMapPathOverrides": { + "webpack:///./~/*": "${webRoot}/node_modules/*", + "webpack:///./*": "${webRoot}/*", + "webpack:///*": "*", + "webpack:///src/*": "${webRoot}/src/*" + } + }, + { + "type": "msedge", + "request": "launch", + "name": "Launch Edge against localhost", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}/web", + "sourceMaps": true, + "sourceMapPathOverrides": { + "webpack:///./~/*": "${webRoot}/node_modules/*", + "webpack:///./*": "${webRoot}/*", + "webpack:///*": "*", + "webpack:///src/*": "${webRoot}/src/*" + } + }, + { + "name": "Launch via pnpm dev", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}/web", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "dev" + ], + "skipFiles": [ + "/**" + ] + } + ] +} diff --git a/web/NexusCad.Web.esproj b/web/NexusCad.Web.esproj index 6eb03f1..4152a00 100644 --- a/web/NexusCad.Web.esproj +++ b/web/NexusCad.Web.esproj @@ -1,4 +1,4 @@ - + pnpm dev src/ @@ -9,4 +9,7 @@ C:\Program Files\nodejs\node.exe false - + + + + \ No newline at end of file diff --git a/web/Properties/launchSettings.json b/web/Properties/launchSettings.json new file mode 100644 index 0000000..fbe6387 --- /dev/null +++ b/web/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "dev": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "http://localhost:3000", + "environmentVariables": { + "NODE_ENV": "development" + }, + "applicationUrl": "http://localhost:3000" + } + } +} From c1523563422de657c37d670c3d9db2da64618f79 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Wed, 3 Jun 2026 13:31:29 +0200 Subject: [PATCH 28/31] =?UTF-8?q?fix(web):=20Resolver=20error=20de=20imple?= =?UTF-8?q?mentaci=C3=B3n=20en=20VS2026?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agregar LaunchCommand y LaunchUrl a NexusCad.Web.esproj - Actualizar launchSettings.json con schema y configuración completa - Crear script CleanVSCache.ps1 para limpiar caché de Visual Studio - Agregar documentación de troubleshooting para VS2026 - Incluir perfiles de lanzamiento con inspectUri para depuración Resolves: Error 'El valor no puede ser nulo. Nombre del parámetro: source' Issue: Problema específico de VS2026 con proyectos esproj --- CleanVSCache.ps1 | 78 ++++++++++++++++++++++ docs/TROUBLESHOOTING-VS2026.md | 104 +++++++++++++++++++++++++++++ web/NexusCad.Web.esproj | 6 +- web/Properties/launchSettings.json | 6 +- 4 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 CleanVSCache.ps1 create mode 100644 docs/TROUBLESHOOTING-VS2026.md 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/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/web/NexusCad.Web.esproj b/web/NexusCad.Web.esproj index 4152a00..6d406bb 100644 --- a/web/NexusCad.Web.esproj +++ b/web/NexusCad.Web.esproj @@ -1,4 +1,4 @@ - + pnpm dev src/ @@ -8,8 +8,10 @@ pnpm clean C:\Program Files\nodejs\node.exe false + pnpm dev + http://localhost:3000 - \ No newline at end of file + diff --git a/web/Properties/launchSettings.json b/web/Properties/launchSettings.json index fbe6387..9937054 100644 --- a/web/Properties/launchSettings.json +++ b/web/Properties/launchSettings.json @@ -1,13 +1,15 @@ { + "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { - "dev": { + "NexusCad.Web": { "commandName": "Project", "launchBrowser": true, "launchUrl": "http://localhost:3000", "environmentVariables": { "NODE_ENV": "development" }, - "applicationUrl": "http://localhost:3000" + "applicationUrl": "http://localhost:3000", + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" } } } From 4bf239456fd17519c8ac6cee2e56d20c88e66612 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Wed, 3 Jun 2026 14:04:23 +0200 Subject: [PATCH 29/31] =?UTF-8?q?fix(web):=20Eliminar=20launch.json=20y=20?= =?UTF-8?q?configuraci=C3=B3n=20incorrecta=20de=20esproj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remover .vscode/launch.json que causaba error 'source null' - Eliminar LaunchCommand/LaunchUrl de NexusCad.Web.esproj - Agregar ShouldRunBuildScript para compilación correcta - Crear documentación SOLUCION-VS2026-ESPROJ.md CAUSA RAÍZ: VS2026 no soporta 'Implementar/Deploy' en proyectos esproj. SOLUCIÓN: Usar F5/Ctrl+F5 (Ejecutar) en lugar de 'Deploy' Los proyectos JavaScript/TypeScript se ejecutan, no se implementan. --- .vscode/launch.json | 46 -------------- docs/SOLUCION-VS2026-ESPROJ.md | 107 +++++++++++++++++++++++++++++++++ web/NexusCad.Web.esproj | 4 +- 3 files changed, 109 insertions(+), 48 deletions(-) delete mode 100644 .vscode/launch.json create mode 100644 docs/SOLUCION-VS2026-ESPROJ.md diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index fa979b6..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "chrome", - "request": "launch", - "name": "Launch Chrome against localhost", - "url": "http://localhost:3000", - "webRoot": "${workspaceFolder}/web", - "sourceMaps": true, - "sourceMapPathOverrides": { - "webpack:///./~/*": "${webRoot}/node_modules/*", - "webpack:///./*": "${webRoot}/*", - "webpack:///*": "*", - "webpack:///src/*": "${webRoot}/src/*" - } - }, - { - "type": "msedge", - "request": "launch", - "name": "Launch Edge against localhost", - "url": "http://localhost:3000", - "webRoot": "${workspaceFolder}/web", - "sourceMaps": true, - "sourceMapPathOverrides": { - "webpack:///./~/*": "${webRoot}/node_modules/*", - "webpack:///./*": "${webRoot}/*", - "webpack:///*": "*", - "webpack:///src/*": "${webRoot}/src/*" - } - }, - { - "name": "Launch via pnpm dev", - "type": "node", - "request": "launch", - "cwd": "${workspaceFolder}/web", - "runtimeExecutable": "pnpm", - "runtimeArgs": [ - "dev" - ], - "skipFiles": [ - "/**" - ] - } - ] -} 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/web/NexusCad.Web.esproj b/web/NexusCad.Web.esproj index 6d406bb..a9216b3 100644 --- a/web/NexusCad.Web.esproj +++ b/web/NexusCad.Web.esproj @@ -8,8 +8,8 @@ pnpm clean C:\Program Files\nodejs\node.exe false - pnpm dev - http://localhost:3000 + true + $(BuildCommand) From 8d32a24d211c86abc6bbf62633ff9b603c1b496f Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Wed, 3 Jun 2026 14:10:03 +0200 Subject: [PATCH 30/31] =?UTF-8?q?docs:=20Agregar=20instrucciones=20urgente?= =?UTF-8?q?s=20y=20script=20de=20ejecuci=C3=B3n=20directa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - INSTRUCCIONES-URGENTE.md: Guía paso a paso para NO usar Deploy - RunWeb.ps1: Script para ejecutar el servidor de desarrollo directamente - Énfasis en usar F5/Ctrl+F5 en lugar de Implementar/Deploy El usuario continúa usando 'Implementar' que no funciona con esproj. Necesita usar 'Depurar/Ejecutar' o terminal directamente. --- INSTRUCCIONES-URGENTE.md | 102 +++++++++++++++++++++++++++++++++++++++ RunWeb.ps1 | 19 ++++++++ 2 files changed, 121 insertions(+) create mode 100644 INSTRUCCIONES-URGENTE.md create mode 100644 RunWeb.ps1 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/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 From 9c633216a4bfdebc44837fbf5242aeaac12c2736 Mon Sep 17 00:00:00 2001 From: Javier Fernandez Date: Wed, 3 Jun 2026 14:56:00 +0200 Subject: [PATCH 31/31] fix(api): adapt Swashbuckle to Microsoft.OpenApi 2.x namespace Microsoft.OpenApi 2.x removed the `.Models` sub-namespace and replaced `OpenApiReference` with typed reference classes. Update Swagger setup to use `OpenApiSecuritySchemeReference` and the `Func` overload of `AddSecurityRequirement`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/NexusCad.Api/Program.cs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/NexusCad.Api/Program.cs b/src/NexusCad.Api/Program.cs index ca7fff9..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() } }); });