Contactos (features/contacts)
CRUD de contactos (clientes que escriben por los canales) del tenant: listado paginado en servidor con filtros, alta, edición, vista de solo lectura y conmutadores rápidos de "activo" y "bloqueado". Contraparte del backend: Contact. Las etiquetas que se asignan se gestionan en Etiquetas.
Ruta
ContactsModule se carga en shared/routes/content.routes.ts bajo contacts (PAGE_URLS.CONTACTS) con canActivate: [AuthGuard, permissionGuard] y data: { permission: 'CONTACT_READ' }. ContactsRoutingModule repite permissionGuard con el mismo permiso en la ruta vacía (sin AuthGuard; ya lo aplica la ruta padre). Entrada de menú: sidebarMenu.contacts, con permission: 'CONTACT_READ' (core/helpers/ui/nav.constant.ts).
Archivos
| Archivo | Rol |
|---|---|
contacts.module.ts / contacts-routing.module.ts | Declaran los cuatro componentes; importan ReactiveFormsModule, FormsModule, NgbModalModule, NgbTooltipModule, NgSelectModule, SharedModule |
pages/contacts-page/contacts-page.component.* | Solo compone shared-breadcrumb (contacts.titleSidebar, ruta padre tickets.titleSidebar) y contacts-contacts-table |
components/contacts-table/contacts-table.component.* | Tabla, paginación, filtros aplicados, conmutadores y apertura del modal; hospeda el filtro |
components/contacts-filter/contacts-filter.component.* | Panel de filtros (incluye selector de etiquetas) |
components/contact-modal-form/contact-modal-form.component.* | Modal de alta, edición y vista |
contacts-routing.module.spec.ts | Única spec de la feature |
Permisos
| Acrónimo | Efecto |
|---|---|
CONTACT_READ | Ruta y menú |
CONTACT_CREATE | Botón "agregar" (contacts.modal.add) |
CONTACT_UPDATE | Botón "editar" por fila y los dos conmutadores (activo y bloqueado) |
El botón "ver" no tiene restricción adicional. La UI no ofrece eliminar, aunque el servicio tiene removeContact y el backend expone DELETE remove/:id con CONTACT_DELETE.
Servicios y endpoints
ContactService (core/services/api/contact.service.ts, providedIn: 'root') usa environment.apiUrl + API_URLS.CONTACTS (/contacts) + API_ENDPOINTS. Todas las mutaciones muestran un toast de éxito con res.message (título contacts.title).
| Método | Endpoint | Detalle |
|---|---|---|
findContactsPaginated(page, limit, filter?) | GET /contacts/get-all | Query page, limit más los filtros; devuelve { results, total } leyendo data.results y data.meta.total |
createContact(data) | POST /contacts/create | Usado por el modal |
updateContact(id, data) | PATCH /contacts/update/:id | id es el _id; lo usan el modal (payload completo) y los conmutadores (parche de un campo) |
assignLabels(id, labelIds) | PATCH /contacts/:id/assign-labels | Body { labelIds }. No lo usa esta feature: lo llama el chat (chat-conversation.component.ts) |
removeContact(id) | DELETE /contacts/remove/:id | Sin consumidores en la UI |
Además, LabelService.findLabelsPaginated(1, 100, { isActive: true }) alimenta el selector de etiquetas del filtro y del modal (ver Etiquetas).
:::note Rutas del backend
Contact lista rutas REST simplificadas (GET /contacts, POST /contacts/:id/labels). Las reales, verificadas en contact.controller.ts, siguen CRUD_ROUTES (get-all, create, update/:id, remove/:id) y :id/assign-labels con PATCH. Ver Endpoints.
:::
Modelos
core/interfaces/api/contact.interface.ts:
| Interfaz | Campos |
|---|---|
Contact | _id, tenantId, externalId, username, phone?, firstname?, secondFirstname?, lastName?, secondLastName?, email?, profilePic?, metadata?: object[], isActive, isBlocked, labels: Label[], createdAt?, updatedAt? |
ContactForm | Igual sin _id/tenantId; labels?: string[] (ids), isActive?, isBlocked? |
ContactFilter | username?, firstname?, lastName?, email?, phone?, isActive?, isBlocked?, labels?: string[] |
ContactPaginatedData | results, meta { total, page, limit } |
ContactMetadataItem | name, description (la usa el chat, no esta feature) |
Constantes de validación en core/helpers/global/contact.constant.ts: EXTERNAL_ID 100, USERNAME 100, NAME 100, EMAIL 150 (longitudes máximas).
Listado y filtros
ContactsTableComponent guarda currentPage, currentLimit (10) y currentFilter; fetchPage pide al servidor y fija count, page y limit en tableConfig (el ordenamiento es local, externalSorting: false). Recargar, aplicar o limpiar filtros reinician en la página 1; onLimitChange también.
Columnas: usuario (título móvil), nombre, segundo nombre, apellido, segundo apellido, teléfono (subtítulo móvil), email, etiquetas (insignias con el color de cada etiqueta), activo, bloqueado, creado y acciones. Los dos conmutadores (app-switch) solo se muestran con CONTACT_UPDATE; patchContactField guarda togglingId para deshabilitarlos durante la petición y, en éxito, aplica el parche a la fila con Object.assign (no recarga).
ContactsFilterComponent usa un objeto plano con ngModel (username, firstname, lastName, email, isActive, isBlocked, labels) y emite filterApply / filterReset. onApply recorta espacios, omite vacíos, convierte 'true'/'false' a booleano y calcula activeFiltersCount. Carga hasta 100 etiquetas activas en ngOnInit. No expone el filtro phone aunque ContactFilter lo declara.
Formulario (ContactModalFormComponent)
Recibe action (ADD, EDIT, VIEW) y contact. En VIEW el formulario queda deshabilitado.
| Campo | Validadores |
|---|---|
externalId | required, maxLength(100) |
username | required, maxLength(100) |
phone | pattern(/^\+?\d{6,15}$/) (opcional) |
firstname, secondFirstname, lastName, secondLastName | maxLength(100) |
email | email, maxLength(150) |
profilePic | sin validadores (input type="url") |
metadata | sin validadores; ver abajo |
isActive | por defecto true |
isBlocked | por defecto false |
labels | ids; ng-select múltiple sobre las etiquetas activas |
metadata se edita en un textarea con JSON: onMetadataChange lo parsea y, si es válido, guarda un arreglo (un objeto suelto se envuelve en uno); si el texto está vacío guarda []; si el JSON es inválido, ignora el cambio en silencio y conserva el último valor válido.
buildBasePayload siempre envía externalId, username, isActive, isBlocked, labels y metadata; los demás campos solo si traen texto tras trim. En edición las etiquetas pobladas (objetos Label) se reducen a ids con typeof l === 'string' ? l : l._id.
Flujo
- Abrir
contacts: el guard compruebaCONTACT_READy la tabla pide la primera página. - Filtrar: el panel emite un
ContactFiltery se vuelve a pedir la página 1. - Alta o edición: modal (
backdrop: 'static', tamañolg) →createContact/updateContact→closed('saved')→ recarga. - Conmutar activo o bloqueado:
updateContact(_id, { isActive })o{ isBlocked }→ toast del backend → la fila se actualiza en sitio.
Estado, sockets y utilidades
No usa NgRx ni sockets; el estado vive en los componentes. Reutiliza DEFAULT_NGX_DATATABLE_CONFIG, BUTTON_ACTIONS, FORMAT_FOR_DATES, app-switch, shared-tables-ngx-datatable y shared-breadcrumb (ver Compartido).
Pruebas
Solo contacts-routing.module.spec.ts: verifica que la ruta vacía lleva data: { permission: 'CONTACT_READ' } y permissionGuard. No hay specs de los componentes ni de ContactService.
Gotchas e inconsistencias
- Cada conmutador dispara un toast de éxito del backend, porque
updateContactlo muestra siempre. - El selector de etiquetas ve como máximo 100 etiquetas activas (página única); si el tenant tiene más, el resto no se puede elegir ni filtrar.
- Las etiquetas inactivas ya asignadas a un contacto no aparecen como chips en el modal (se filtra contra las activas), pero sus ids siguen en el valor del control y se reenvían al guardar.
- El modal no tiene control de
isSubmitting; el botón solo depende de la validez, por lo que un doble clic puede enviar dos peticiones. profilePices uninput type="url"sin validadores de Angular; no hay subida de archivos en esta feature.ContactFilter.phoneyContactService.assignLabels/removeContactexisten pero esta pantalla no los usa.- Todas las URLs usan
_iddel contacto.