Perfil (features/profile)
Pantalla del usuario autenticado: datos de solo lectura, avatar (con recorte) y firma de mensajes. Incluye además la página global de mensajes destacados (profile/starred-messages). Los endpoints users/me/* y get-me pertenecen al módulo User del backend.
Rutas
ProfileModule se carga bajo profile en shared/routes/content.routes.ts solo con AuthGuard (sin permissionGuard: es autoservicio).
| Ruta | Componente / módulo | Guard | Permiso |
|---|---|---|---|
profile | ProfilePageComponent | AuthGuard | ninguno |
profile/starred-messages | StarredMessagesModule (lazy) → StarredMessagesPageComponent | AuthGuard | ninguno |
Accesos: el desplegable de la cuenta en el header (shared/components/header/elements/my-account, enlace routerLink="/profile") y la entrada de menú starred-messages (PAGE_URLS.PROFILE_STARRED_MESSAGES, icono bookmark; el comentario del menú indica que la entidad está exenta de permisos en el backend).
:::caution Destino de redirección por permiso denegado
permissionGuard redirige a /profile/profile-detail, ruta que no existe en ProfileRoutingModule. Ver Rutas.
:::
Archivos
| Archivo | Rol |
|---|---|
profile.module.ts, profile-routing.module.ts | Declaran página y cropper; importan ReactiveFormsModule, FormsModule, SharedModule e ImageCropperComponent (standalone, ngx-image-cropper) |
pages/profile-page/profile-page.component.{ts,html,scss} | Resumen, datos, avatar y firma |
components/avatar-cropper-dialog/avatar-cropper-dialog.component.* | Modal con image-cropper redondo y deslizador de zoom |
components/avatar-cropper-dialog/avatar-cropper-dialog.service.ts | open(file): Promise<File | null> sobre NgbModal |
starred-messages/starred-messages.module.ts, starred-messages-routing.module.ts | Módulo lazy de destacados |
starred-messages/pages/starred-messages-page/* | Listado global de destacados |
Constantes y servicios externos: AVATAR_CROPPER_MODAL_OPTIONS en core/helpers/global/profile.consants.ts (modal centrado, backdrop: 'static', keyboard: false, size: 'md', windowClass: 'avatar-cropper-dialog-modal'; el nombre del archivo lleva el typo consants), UserAvatarsService.getAvatarUrl (devuelve user.avatarUrl o undefined), UserService y StarredMessageService.
Perfil
ProfilePageComponent se suscribe a selectUser (slice auth) y rellena currentUser, fullName (firstname/firstName + lastName, con respaldo en username) y la firma original. Muestra:
- Tarjeta resumen: avatar (foto si hay
avatarUrl, si no iniciales calculadas defullName), nombre, email, insignia del tipo de usuario (users.userTypes.<userType>) e insignia activo/inactivo (isActive !== false). - Tarjeta de datos: usuario, email, tipo de usuario y "miembro desde" (
createdAt) si existe. - Tarjeta de firma.
Los datos personales no son editables aquí; para eso, un administrador usa Usuarios.
Firma
Formulario con un único campo signature: Validators.maxLength(1000) (SIGNATURE_MAX_LENGTH, también como maxlength HTML) y contador profile.signature.charactersCount. Hay vista previa (signature-preview) con un mensaje de ejemplo y la firma debajo, o un texto de "vacío".
- El botón guardar se deshabilita si el formulario es inválido, hay un guardado en curso o la firma (con
trim) no cambió respecto de la original (isSignatureDirty). "Restablecer" aparece solo si hay cambios. onSubmit()llamaUserService.updateMySignature(signature)→PATCH /users/me/signaturecon{ signature }. En éxito sincroniza el formulario con la respuesta, despachaUserActions.userProfileUpdateSuccess({ user: response.data })y muestra el toastprofile.signature.updateSuccess.- La firma se usa luego al enviar mensajes: en
ChatConversationComponentel compositor tienesignatureEnabled(activo por defecto) y envíasignature: truesalvo en modo nota (ver Chat).
Avatar
- El input de archivo (
#profileAvatarInput) acepta soloimage/jpeg(AVATAR_ALLOWED_TYPE) y hasta 2 MB (AVATAR_MAX_SIZE = 2 * 1024 * 1024). Si falla, muestraprofile.avatar.errors.invalidTypeotooLargey no abre el recorte. El código lo describe como espejo de la validación del backend. AvatarCropperDialogService.open(file)abre el modal: recorte cuadrado y redondo (aspectRatio1,roundCropper), salidablobJPEG calidad 90 redimensionada a 512 px de ancho, zoom entre 1 y 3 (MIN_SCALE,MAX_SCALE, paso 0,05) y arrastre de la imagen.onTransformChangeconserva el desplazamiento al cambiar el zoom. Confirmar construyenew File([blob], 'avatar.jpg', { type: 'image/jpeg' })y cierra el modal con él; cancelar descarta y el servicio resuelvenull. El botón confirmar se deshabilita hasta que existe un blob recortado._uploadCroppedAvatarcrea unURL.createObjectURLcomo vista previa local y llamaUserService.updateMyAvatar(file)→PATCH /users/me/avatarconFormData(file). En éxito despachauserProfileUpdateSuccesscon el usuario actual fusionado ({ ...currentUser, avatarUrl }) para no perder permisos ni flags. En error revoca la vista previa, limpia y muestraprofile.avatar.errors.uploadFailed.ngOnDestroyrevoca el object URL.
Mensajes destacados
StarredMessagesPageComponent lista todos los mensajes que el agente marcó con estrella, de contacto e internos mezclados, del más nuevo al más viejo.
- Carga:
StarredMessageService.findGlobal(page, 20, messageType?)→GET /starred-messagesconpage,limitymessageTypeopcional. RespuestaStarredMessagePaginatedData(results,metaconpage,limit,total,totalPages). Paginación "cargar más":loadMore()pide la página siguiente mientrasmeta.page < meta.totalPagesy concatena. - Filtro por tipo con tres botones: todos,
contact,internal(setFilter; volver a elegir el activo no hace nada). - Cada fila muestra insignia de tipo, remitente (
getSenderName, con respaldostarredMessages.senderFallback), vista previa (nombre de archivo si hay adjunto, si no el cuerpo; miniatura si es imagen, icono según mimetype en otro caso) y fecha (date: 'short'). Los helpersgetStarredSenderName,isStarredFileImageygetStarredFileIconClassviven encore/helpers/global/starred-message.constant.ts. - Actualización: escucha
StarredMessageService.changed$(emite tras untoggleconfirmado, desde los chats) y recarga desde la página 1. - Clic en una fila (
openItem):contactnavega a/whaticket/chatconqueryParams { chatId: <ticketId>, highlightMessageId };internalnavega a/internal-chat/conversationscon{ conversationId, highlightMessageId }. Si faltamessage.ticketIdoconversationIdmuestra un toast de errorstarredMessages.page.openErrory no navega.
Otros métodos de StarredMessageService (toggle, getIdsByScope, findByContact, findByInternalConversation, fetchMessageWindow) los usan los chats; es un servicio simple con RxJS, sin NgRx.
Estado y sockets
Lee y escribe el slice auth (selectUser, acción userProfileUpdateSuccess, cuyo reducer reemplaza user). No hay efecto que escriba el usuario actualizado en secureStorage: la sesión almacenada se refresca la próxima vez que se llama get-me (por ejemplo al recargar). No usa sockets propios.
Pruebas
| Spec | Cubre |
|---|---|
profile-page.component.spec.ts | Rechazo de archivo no JPG o mayor de 2 MB, cancelación del recorte, subida del archivo recortado y usuario fusionado en el store, vista previa tras confirmar, bandera de ocupado, fallo de subida |
avatar-cropper-dialog.component.spec.ts | Zoom acotado a 1–3 con nuevo objeto transform, conservación del arrastre, confirmar con y sin blob, cancelar |
avatar-cropper-dialog.service.spec.ts | Pasa el archivo al componente; resuelve el File o null al cancelar |
starred-messages-page.component.spec.ts | Carga inicial, error, recarga por changed$, filtro, vista previa, remitente, helpers de archivo y ramas de openItem |
No hay spec de la página para la firma.
Gotchas e inconsistencias
- Solo JPEG: un PNG o WebP se rechaza en el cliente aunque sea una imagen válida.
- La validación del tipo es por
file.type(MIME informado por el navegador), no por extensión ni por contenido. - La página de perfil no consulta el backend al abrirse: depende del
userdel store, cargado al iniciar sesión conget-me. - El nombre del archivo de constantes es
profile.consants.ts(typo existente en el repositorio). - Un fallo de subida de avatar muestra el error propio y, además, el toast del
ErrorInterceptor(ver Auth, interceptores y RBAC).