Contactos y Sugerencias — Referencia Técnica
← Volver a Contactos y Sugerencias
Dónde vive esto
Backend
apps/backend/graphql/resolvers/user-contact-import.resolver.js— resolvers para importación/sincronización/invitación de contactos (importContacts,syncContacts,inviteContacts,deleteImportedContacts),friendsFromContacts,getInvitedContactsStatusycontactSuggestionsapps/backend/graphql/resolvers/user-recommendations.resolver.js— resolvers parausersYouMayKnow,similarUsersydismissSuggestionapps/backend/graphql/resolvers/user-mentions-tags.resolver.js— resolver paramentionSuggestionsapps/backend/graphql/resolvers/user-validation.resolver.js— resolvers paravalidateUsername,validateEmail,validateUserData,validateBatchUserData,validateProfileContent,validateFieldRealtimeapps/backend/graphql/types/contacts-validation.type.js— definiciones de tipos de importación/validación de contactos (nota: la mutación esinviteContacts, noinviteByEmail)apps/backend/graphql/types/user-features.type.js— definiciones de tiposContactSuggestion/MentionSuggestionapps/backend/graphql/types/user-recommendations.type.js— definiciones de tiposPersonYouMayKnow/UsersYouMayKnowResponse/SimilarUser, además de la mutacióndismissSuggestionapps/backend/managers/user-managers/contact-import.manager.js— lógica de negocio de importación/sincronización/invitación de contactos y coincidencia con usuarios existentesapps/backend/managers/user-managers/search-discovery.manager.js— lógica de clasificación degetUsersYouMayKnow/dismissSuggestion/getSimilarUsersapps/backend/managers/user-managers/mentions-tags.manager.js— lógica degetMentionSuggestionsapps/backend/data-access-services/user/contact.access-service.js— persistencia de contactos importados, respaldada por la tablauser_contact(se movió adata-access-services/user/; ya no existe undata-access-services/contact.access-service.jsde nivel superior)apps/backend/managers/user-managers/validation.manager.js—isUsernameAvailable/isEmailAvailable/validateUserData/validateBatchUserData; los wrappers de cara a GraphQL (validateUsername,validateEmail,validateFieldRealtime,validateProfileContent) viven enmanagers/user-managers/index.jsy delegan en este manager
Frontend
apps/frontend-nextjs/src/page-components/settings/ContactsPage.tsx— Settings → Contacts (/settings/contacts, enrutado desdeapps/frontend-nextjs/src/app/settings/contacts/page.tsx): formulario manual de nombre/email/teléfono +importContacts, un botón "Sync contacts" (syncContacts), "Delete imported contacts" con un paso de confirmación (deleteImportedContacts), la lista decontactSuggestionscon acciones de seguir/descartar, "Invite contacts" por correo (inviteContacts), y la lista de enviadas/aceptadas/expiradas degetInvitedContactsStatusapps/frontend-nextjs/src/components/discovery/RecommendationsSection.tsx— renderiza el feed deusersYouMayKnowen Explorar/Inicio y llama adismissSuggestiondesde el botón X de la tarjeta (también renderizasimilarUsers/getTrendingUsers/getRecommendedUsers)apps/frontend-nextjs/src/components/PostModal.tsx— autocompletado de menciones con@en el cuadro de comentarios, respaldado pormentionSuggestionsapps/frontend-nextjs/src/components/Login.tsx— llamadas con debounce avalidateUsername/validateEmailen el formulario de registro- No se encontró interfaz de frontend para
friendsFromContacts— por ahora es exclusivo del backend. Todo lo demás en este archivo ahora tiene un consumidor de frontend a través de la página de configuración de Contactos anterior; esta documentación antes decía que ninguno deimportContacts/syncContacts/inviteContacts/friendsFromContacts/getInvitedContactsStatus/deleteImportedContacts/contactSuggestionstenía interfaz de frontend — corregido, salvo parafriendsFromContacts.
Checklist de implementación técnica
-
importContacts/syncContacts/deleteImportedContacts— resolvers conectados enuser-contact-import.resolver.js; los contactos persisten en la tablauser_contact(modeloUserContact,contact.access-service.js); ahora conectados a la página de configuración de Contactos (ContactsPage.tsx) — esta documentación antes decía que no existía interfaz de frontend; corregido -
inviteContacts— resolver conectado (noinviteByEmail, como decía un borrador anterior de esta documentación); las invitaciones persisten en la tablauser_invitation(modeloUserInvitation) con un enlace real/invite/:tokeny un período de espera de 30 días por contacto antes de volver a invitar; ahora entrega realmente la invitación medianteservices/sms/sms.service.js(Twilio por defecto) para identificadores con forma de teléfono oservices/email.service.js(AWS SES por defecto) para correos electrónicos, en lugar de solo escribir la fila de invitación — esta documentación antes decía que no existía ninguna integración de envío; corregido. Conectado a la sección "Invite contacts" de la página de configuración de Contactos. -
friendsFromContacts/getInvitedContactsStatus— queries más nuevas que respaldan los flujos anteriores (encontrar contactos ya emparejados, verificar invitaciones enviadas);getInvitedContactsStatusestá conectada a la lista de estado de invitaciones de la página de configuración de Contactos (friendsFromContactssigue sin ningún consumidor de frontend) -
contactSuggestions— resolver conectado; el esquema ahora lo declara como el tipoContactSuggestionsResponse(user-features.type.js) en lugar deJSONsin tipar, y el resolver mapea la forma interna del manager a ese tipo — esta documentación antes decía que devolvíaJSONsin tipar y queContactSuggestion/ContactSuggestionsResponseno los usaba ningún campo; corregido. Conectado a la lista de sugerencias de la página de configuración de Contactos (seguir/descartar). -
usersYouMayKnow/dismissSuggestion— conectados de extremo a extremo:RecommendationsSectionrenderiza el feed de "Personas que quizás conozcas" y llama adismissSuggestiondesde el botón X de la tarjeta; los descartes persisten por usuario endismissed_suggestion(modeloDismissedSuggestion:user_id,dismissed_user_id) y se excluyen de futuros resultados deusersYouMayKnow. Esto es el equivalente actual de lo que borradores anteriores de esta documentación llamabanpeopleYouMayKnow/dismissContactSuggestion— esos nombres exactos todavía no existen, pero la funcionalidad que describían ahora sí, bajo estos otros nombres. -
similarUsers— resolver conectado (search-discovery.manager.js#getSimilarUsers, clasifica según la superposición de etiquetas de contenido compartidas + la similitud en actividad de publicación) y renderizado porRecommendationsSection -
mentionSuggestions— resolver conectado y llamado por el autocompletado de menciones con@dePostModal. El manager (mentions-tags.manager.js#getMentionSuggestions) ejecuta una búsqueda real (userAccessService.searchUsers), excluye a los usuarios bloqueados en cualquier dirección, y devuelve un objeto por candidato con exactamente la forma delMentionSuggestiondel esquema (user,relevanceScore,mutualConnectionsvíauserFollowAccessService.getMutualFollowing,recentInteractions); el resolver devuelve ese arreglo tal cual.relevanceScoreincorpora si el que llama ya sigue al candidato, si está verificado, y el conteo de conexiones mutuas.recentInteractionsestá fijado honestamente afalse— no existe ninguna señal de registro de interacciones en el código para calcularlo. Esta documentación antes decía que el resolver devolvía un envoltorio{ suggestions, query, hasMore }con una forma completamente distinta que lanzaría un error en tiempo de ejecución; ese desajuste está corregido. -
validateUsername/validateEmail/validateUserData/validateBatchUserData/validateProfileContent— resolvers conectados (user-validation.resolver.js+validation.manager.js/index.js);validateUsername/validateEmailestán conectados al formulario de registro web (Login.tsx, con debounce) -
validateFieldRealtime— corregido. El resolver ahora llama auserManager.validateFieldRealtime(input, context)— el único objeto{ field, value }más el contexto, coincidiendo con la firma real del managervalidateFieldRealtime(input, context = {}). Esta documentación antes describía un desajuste entre el resolver y el manager (el resolver pasabainput.field/input.value/contextcomo tres argumentos posicionales, así que la desestructuración{ field, value } = inputdel propio manager recibía una cadena simple y siempre dabaundefined/undefined); ese desajuste está corregido y cada llamada ahora valida el campo/valor real.
Importación de contactos
importContacts recibe un input: ContactImportInput! — un arreglo contacts: [JSON!]! más una etiqueta source: String! ('phone', 'email', 'google', 'apple', 'sync' u 'other') — y almacena un hash SHA-256 unidireccional de cada contacto (teléfono > correo electrónico > nombre, el que sea más estable como identificador) para poder emparejarlo con usuarios registrados sin conservar la información de contacto original. Cada elemento de contacto necesita al menos uno de email, phone o name.
mutation ImportContacts($input: ContactImportInput!) {
importContacts(input: $input) {
success
message
}
}
El esquema también declara importedCount, existingUsers e invitationsSent en ContactImportResult. importContacts de contact-import.manager.js ahora completa los dos primeros (importedCount: results.stored, existingUsers: results.matched; invitationsSent siempre es 0 aquí, ya que importContacts nunca envía nada — eso lo hace la mutación separada inviteContacts, que sí establece invitationsSent) — esta documentación antes decía que ninguno de los tres campos se completaba (lo que habría hecho fallar cada llamada contra los campos Int! no nulos del esquema); corregido.
Sincronizar contactos
syncContacts no recibe argumentos — el esquema lo declara como syncContacts: ContactImportResult! sin nada más. No puede recibir una lista de contactos nueva provista por el cliente; en su lugar, vuelve a revisar los contactos ya importados por este usuario que nunca coincidieron con un usuario registrado (no hay forma de recuperar el correo electrónico/teléfono original a partir de su hash unidireccional para volver a intentar una coincidencia, así que funciona en la dirección contraria: aplica el mismo hash al correo electrónico/teléfono de cada usuario activo recientemente y lo compara con los hashes pendientes de este usuario).
mutation SyncContacts {
syncContacts {
success
message
}
}
Invitar contactos
inviteContacts recibe una lista plana de direcciones de correo electrónico (los identificadores que no parecen un correo se tratan como número de teléfono) y, para cada uno que no haya sido invitado ya en los últimos 30 días, persiste una invitación con un enlace /invite/:token generado y la entrega de verdad — mediante services/email.service.js (AWS SES por defecto) para identificadores con forma de correo, o services/sms/sms.service.js (Twilio por defecto) para los que tienen forma de teléfono. Esta documentación antes decía que solo se escribía el registro de la invitación y que no existía ninguna integración de envío por SMS/correo en el código — corregido: ahora se reutilizan los mismos servicios de SMS/correo que se usan en otras partes de la app (códigos OTP, restablecimiento de contraseña).
mutation InviteContacts($emails: [String!]!) {
inviteContacts(emails: $emails) {
success
message
}
}
Usa getInvitedContactsStatus(limit, offset) para verificar invitaciones enviadas (InvitedContactStatus { contactId email status invitedAt joinedAt reminderCount }); usa deleteImportedContacts para eliminar las filas de contactos importados de este usuario (no elimina las invitaciones enviadas).
Sugerencias de contactos
contactSuggestions(limit: Int, offset: Int): ContactSuggestionsResponse! ahora devuelve el tipo ContactSuggestionsResponse/ContactSuggestion declarado en user-features.type.js, en lugar de JSON sin tipar. El resolver mapea la forma interna de contact-import.manager.js#getContactSuggestions ({ userId, suggestions: [{ contactId, contact, matchedUser, confidence, suggestionReason, contactInfo }], totalSuggestions, pagination }) a la respuesta tipada:
type ContactSuggestion {
id: ID!
user: User!
mutualConnections: Int!
suggestionReason: String!
confidenceScore: Float!
}
type ContactSuggestionsResponse {
suggestions: [ContactSuggestion!]!
total: Int!
hasMore: Boolean!
}
query ContactSuggestions($limit: Int, $offset: Int) {
contactSuggestions(limit: $limit, offset: $offset) {
suggestions {
id
user { id username profilePicture isVerified }
suggestionReason
confidenceScore
}
total
hasMore
}
}
Nota: mutualConnections está fijado a 0 en el mapeo — el conteo real de conexiones mutuas todavía no está implementado para coincidencias de contactos (el mismo estado "TODO: calcular conexiones mutuas" que hay en un par de lugares más del código, p. ej. advanced-social.manager.js#getMutualConnectionsCount). Esta documentación antes decía que contactSuggestions devolvía JSON sin tipar y que ContactSuggestion/ContactSuggestionsResponse estaban declarados pero sin usar por ningún campo; ambas cosas están corregidas. Renderizado por la página de configuración de Contactos (ContactsPage.tsx).
Personas que quizás conozcas
Las sugerencias por superposición de contactos anteriores son independientes del feed más amplio "Personas que quizás conozcas" de la plataforma, usersYouMayKnow, que es lo que RecommendationsSection realmente renderiza. Se clasifica por conexiones mutuas y otras señales, en lugar de por la superposición de contactos importados.
type RecommendationReason {
type: String!
mutualConnectionsCount: Int
confidence: Float
}
type PersonYouMayKnow {
user: User!
matchScore: Float!
reasons: [RecommendationReason!]!
}
type UsersYouMayKnowResponse {
peopleYouMayKnow: [PersonYouMayKnow!]!
limit: Int!
offset: Int!
total: Int!
hasMore: Boolean!
}
query UsersYouMayKnow($limit: Int, $offset: Int) {
usersYouMayKnow(limit: $limit, offset: $offset) {
peopleYouMayKnow {
user { id username profilePicture isVerified }
matchScore
reasons { type mutualConnectionsCount confidence }
}
total
hasMore
}
}
mutation DismissSuggestion($userId: ID!) {
dismissSuggestion(userId: $userId)
}
Una query estrechamente relacionada, pero clasificada por separado, similarUsers(limit, offset): SimilarUsersResponse!, muestra personas con gustos y nivel de actividad similares (superposición de etiquetas de contenido compartidas con un peso de 0.7, similitud en actividad de publicación con un peso de 0.3) en lugar de conexiones mutuas; se renderiza con el mismo componente RecommendationsSection.
Sugerencias de menciones
mentionSuggestions impulsa el autocompletado con @ en descripciones y comentarios, clasificando a los candidatos por relevanceScore. mentions-tags.manager.js#getMentionSuggestions ejecuta una búsqueda real (userAccessService.searchUsers, excluyendo usuarios bloqueados en cualquier dirección) y devuelve un objeto con la forma de MentionSuggestion por cada candidato — user, un relevanceScore calculado (estado de seguimiento + verificación + conteo de conexiones mutuas), mutualConnections (real, vía userFollowAccessService.getMutualFollowing) y recentInteractions (siempre false — todavía no existe ninguna señal de registro de interacciones que lo respalde). El resolver devuelve ese arreglo sin cambios, coincidiendo con el [MentionSuggestion!]! del esquema.
type MentionSuggestion {
user: User!
relevanceScore: Float!
mutualConnections: Int!
recentInteractions: Boolean!
}
query MentionSuggestions($query: String, $limit: Int) {
mentionSuggestions(query: $query, limit: $limit) {
user { id username profilePicture }
relevanceScore mutualConnections recentInteractions
}
}
Validación de campos (en tiempo real)
Un conjunto de queries de validación de solo lectura que se usan principalmente para retroalimentación instantánea durante el registro / la edición de perfil. Todas devuelven el mismo ValidationResult:
type ValidationResult {
valid: Boolean!
message: String # human-readable reason when invalid
suggestions: [String!] # e.g. alternative usernames when one is taken
}
input ValidationInput { field: String! value: String! }
extend type Query {
"Is a username well-formed AND available? Returns suggestions when taken."
validateUsername(username: String!): ValidationResult!
"Is an email well-formed AND available?"
validateEmail(email: String!): ValidationResult!
"Validate a single field by name as the user types (field = 'username' | 'email' | 'password' | …)."
validateFieldRealtime(input: ValidationInput!): ValidationResult!
"Validate an arbitrary bag of user fields in one call (JSON of field → value)."
validateUserData(input: JSON!): ValidationResult!
"Validate many user records at once (e.g. a bulk import); one result per record."
validateBatchUserData(data: [JSON!]!): [ValidationResult!]!
"Moderate free-text profile content (bio, name) against the content rules."
validateProfileContent(content: String!): ValidationResult!
}
Ejemplo:
query ValidateUsername { validateUsername(username: "sergio") { valid message suggestions } }
query ValidateField { validateFieldRealtime(input: { field: "email", value: "[email protected]" }) { valid message } }
validateUsername/validateEmail/validateUserData/validateBatchUserData/validateProfileContent se resuelven en user-validation.resolver.js y están respaldados por validation.manager.js (mediante wrappers delgados en index.js). validateUsername/validateEmail son los dos que hoy están realmente conectados al formulario de registro web (Login.tsx, con debounce); validateFieldRealtime está conectado y funciona correctamente — ver la nota del checklist anterior.
Reglas de validación
| Campo | Regla |
|---|---|
| Edad mínima | 18 años |
| Nombre de usuario | /^[a-z0-9._]{2,20}$/ (minúsculas, dígitos, puntos, guiones bajos) |
| Contraseña | 8+ caracteres, mayúscula, minúscula, dígito, carácter especial |