Saltar al contenido principal

Contactos y Sugerencias — Referencia Técnica

← Volver a Contactos y Sugerencias

Dónde vive esto

Backend

Frontend

  • apps/frontend-nextjs/src/page-components/settings/ContactsPage.tsxSettings → Contacts (/settings/contacts, enrutado desde apps/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 de contactSuggestions con acciones de seguir/descartar, "Invite contacts" por correo (inviteContacts), y la lista de enviadas/aceptadas/expiradas de getInvitedContactsStatus
  • apps/frontend-nextjs/src/components/discovery/RecommendationsSection.tsx — renderiza el feed de usersYouMayKnow en Explorar/Inicio y llama a dismissSuggestion desde el botón X de la tarjeta (también renderiza similarUsers/getTrendingUsers/getRecommendedUsers)
  • apps/frontend-nextjs/src/components/PostModal.tsx — autocompletado de menciones con @ en el cuadro de comentarios, respaldado por mentionSuggestions
  • apps/frontend-nextjs/src/components/Login.tsx — llamadas con debounce a validateUsername/validateEmail en 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 de importContacts/syncContacts/inviteContacts/friendsFromContacts/getInvitedContactsStatus/deleteImportedContacts/contactSuggestions tenía interfaz de frontend — corregido, salvo para friendsFromContacts.

Checklist de implementación técnica

  • importContacts / syncContacts / deleteImportedContacts — resolvers conectados en user-contact-import.resolver.js; los contactos persisten en la tabla user_contact (modelo UserContact, 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 (no inviteByEmail, como decía un borrador anterior de esta documentación); las invitaciones persisten en la tabla user_invitation (modelo UserInvitation) con un enlace real /invite/:token y un período de espera de 30 días por contacto antes de volver a invitar; ahora entrega realmente la invitación mediante services/sms/sms.service.js (Twilio por defecto) para identificadores con forma de teléfono o services/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); getInvitedContactsStatus está conectada a la lista de estado de invitaciones de la página de configuración de Contactos (friendsFromContacts sigue sin ningún consumidor de frontend)
  • contactSuggestions — resolver conectado; el esquema ahora lo declara como el tipo ContactSuggestionsResponse (user-features.type.js) en lugar de JSON sin tipar, y el resolver mapea la forma interna del manager a ese tipo — esta documentación antes decía que devolvía JSON sin tipar y que ContactSuggestion/ContactSuggestionsResponse no 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: RecommendationsSection renderiza el feed de "Personas que quizás conozcas" y llama a dismissSuggestion desde el botón X de la tarjeta; los descartes persisten por usuario en dismissed_suggestion (modelo DismissedSuggestion: user_id, dismissed_user_id) y se excluyen de futuros resultados de usersYouMayKnow. Esto es el equivalente actual de lo que borradores anteriores de esta documentación llamaban peopleYouMayKnow/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 por RecommendationsSection
  • mentionSuggestions — resolver conectado y llamado por el autocompletado de menciones con @ de PostModal. 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 del MentionSuggestion del esquema (user, relevanceScore, mutualConnections vía userFollowAccessService.getMutualFollowing, recentInteractions); el resolver devuelve ese arreglo tal cual. relevanceScore incorpora si el que llama ya sigue al candidato, si está verificado, y el conteo de conexiones mutuas. recentInteractions está fijado honestamente a false — 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/validateEmail están conectados al formulario de registro web (Login.tsx, con debounce)
  • validateFieldRealtime — corregido. El resolver ahora llama a userManager.validateFieldRealtime(input, context) — el único objeto { field, value } más el contexto, coincidiendo con la firma real del manager validateFieldRealtime(input, context = {}). Esta documentación antes describía un desajuste entre el resolver y el manager (el resolver pasaba input.field/input.value/context como tres argumentos posicionales, así que la desestructuración { field, value } = input del propio manager recibía una cadena simple y siempre daba undefined/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

CampoRegla
Edad mínima18 años
Nombre de usuario/^[a-z0-9._]{2,20}$/ (minúsculas, dígitos, puntos, guiones bajos)
Contraseña8+ caracteres, mayúscula, minúscula, dígito, carácter especial