Contacts & Suggestions — Technical Reference
← Back to Contacts & Suggestions
Where this lives
Backend
apps/backend/graphql/resolvers/user-contact-import.resolver.js— resolvers for contact import/sync/invite (importContacts,syncContacts,inviteContacts,deleteImportedContacts),friendsFromContacts,getInvitedContactsStatus, andcontactSuggestionsapps/backend/graphql/resolvers/user-recommendations.resolver.js— resolvers forusersYouMayKnow,similarUsers, anddismissSuggestionapps/backend/graphql/resolvers/user-mentions-tags.resolver.js— resolver formentionSuggestionsapps/backend/graphql/resolvers/user-validation.resolver.js— resolvers forvalidateUsername,validateEmail,validateUserData,validateBatchUserData,validateProfileContent,validateFieldRealtimeapps/backend/graphql/types/contacts-validation.type.js— contact import/validation type defs (note: the mutation isinviteContacts, notinviteByEmail)apps/backend/graphql/types/user-features.type.js—ContactSuggestion/MentionSuggestiontype defsapps/backend/graphql/types/user-recommendations.type.js—PersonYouMayKnow/UsersYouMayKnowResponse/SimilarUsertype defs plus thedismissSuggestionmutationapps/backend/managers/user-managers/contact-import.manager.js— contact import/sync/invite business logic and matching against existing usersapps/backend/managers/user-managers/search-discovery.manager.js—getUsersYouMayKnow/dismissSuggestion/getSimilarUsersranking logicapps/backend/managers/user-managers/mentions-tags.manager.js—getMentionSuggestionslogicapps/backend/data-access-services/user/contact.access-service.js— imported-contact persistence, backed by theuser_contacttable (moved underdata-access-services/user/; there is no longer a top-leveldata-access-services/contact.access-service.js)apps/backend/managers/user-managers/validation.manager.js—isUsernameAvailable/isEmailAvailable/validateUserData/validateBatchUserData; the GraphQL-facing wrappers (validateUsername,validateEmail,validateFieldRealtime,validateProfileContent) live inmanagers/user-managers/index.jsand delegate into this manager
Frontend
apps/frontend-nextjs/src/page-components/settings/ContactsPage.tsx— Settings → Contacts (/settings/contacts, routed fromapps/frontend-nextjs/src/app/settings/contacts/page.tsx): manual name/email/phone entry +importContacts, a "Sync contacts" button (syncContacts), "Delete imported contacts" with a confirm step (deleteImportedContacts), thecontactSuggestionslist with follow/dismiss actions, "Invite contacts" by email (inviteContacts), and the sent/joined/expired list fromgetInvitedContactsStatusapps/frontend-nextjs/src/components/discovery/RecommendationsSection.tsx— renders theusersYouMayKnowfeed on Explore/Home and callsdismissSuggestionfrom the card's X button (also renderssimilarUsers/getTrendingUsers/getRecommendedUsers)apps/frontend-nextjs/src/components/PostModal.tsx—@-mention autocomplete in the comment box, backed bymentionSuggestionsapps/frontend-nextjs/src/components/Login.tsx— debouncedvalidateUsername/validateEmailcalls on the register form- No frontend UI found for
friendsFromContacts— backend-only for now. Everything else in this file now has a frontend consumer via the Contacts settings page above; this doc previously said none ofimportContacts/syncContacts/inviteContacts/friendsFromContacts/getInvitedContactsStatus/deleteImportedContacts/contactSuggestionshad frontend UI — corrected for everything exceptfriendsFromContacts.
Technical implementation checklist
-
importContacts/syncContacts/deleteImportedContacts— resolvers wired inuser-contact-import.resolver.js; contacts persist to theuser_contacttable (UserContactmodel,contact.access-service.js); wired to the Contacts settings page (ContactsPage.tsx) — this doc previously said no frontend UI existed; corrected -
inviteContacts— resolver wired (notinviteByEmailas an earlier draft of these docs said); invitations persist to theuser_invitationtable (UserInvitationmodel) with a real/invite/:tokenlink and a 30-day per-contact cooldown before re-inviting; now actually delivers the invite throughservices/sms/sms.service.js(Twilio by default) for phone-shaped identifiers orservices/email.service.js(AWS SES by default) for email addresses, instead of only writing the invitation row — this doc previously said no delivery integration existed; corrected. Wired to the Contacts settings page's "Invite contacts" section. -
friendsFromContacts/getInvitedContactsStatus— newer queries backing the flows above (find already-matched contacts, check on sent invites);getInvitedContactsStatusis wired to the Contacts settings page's invitation-status list (friendsFromContactsstill has no frontend consumer) -
contactSuggestions— resolver wired; the schema now declares it as the typedContactSuggestionsResponse(user-features.type.js) instead of rawJSON, and the resolver maps the manager's internal shape onto it — this doc previously said it returned untypedJSONand thatContactSuggestion/ContactSuggestionsResponsewere unused by any field; corrected. Wired to the Contacts settings page's suggestions list (follow/dismiss). -
usersYouMayKnow/dismissSuggestion— wired end-to-end:RecommendationsSectionrenders the "People you may know" feed and callsdismissSuggestionfrom the card's X button; dismissals persist per-user indismissed_suggestion(DismissedSuggestionmodel:user_id,dismissed_user_id) and are excluded from futureusersYouMayKnowresults. This is the current equivalent of what earlier drafts of these docs calledpeopleYouMayKnow/dismissContactSuggestion— those exact names still don't exist, but the feature they described now does, under these names. -
similarUsers— resolver wired (search-discovery.manager.js#getSimilarUsers, ranks by shared content-tag overlap + posting-activity similarity) and rendered byRecommendationsSection -
mentionSuggestions— resolver wired and called byPostModal's@-mention autocomplete. The manager (mentions-tags.manager.js#getMentionSuggestions) runs a realuserAccessService.searchUsersquery, excludes users the caller has blocked or is blocked by, and returns one object per candidate shaped exactly like the schema'sMentionSuggestion(user,relevanceScore,mutualConnectionsviauserFollowAccessService.getMutualFollowing,recentInteractions); the resolver returns that array as-is.relevanceScorefactors in whether the caller already follows the candidate,isVerified, and mutual-connection count.recentInteractionsis honestly hardcodedfalse— there's no interaction-log signal anywhere in the codebase to compute it from. This doc previously said the resolver returned a{ suggestions, query, hasMore }wrapper with a completely different flat shape that would throw at execution time; that mismatch is fixed. -
validateUsername/validateEmail/validateUserData/validateBatchUserData/validateProfileContent— resolvers wired (user-validation.resolver.js+validation.manager.js/index.js);validateUsername/validateEmailare wired into the web register form (Login.tsx, debounced) -
validateFieldRealtime— fixed. The resolver now callsuserManager.validateFieldRealtime(input, context)— the single{ field, value }object plus context, matching the manager's realvalidateFieldRealtime(input, context = {})signature. This doc previously described a resolver-to-manager call mismatch (the resolver passedinput.field/input.value/contextas three positional args, so the manager's own{ field, value } = inputdestructuring received a bare string and always yieldedundefined/undefined); that mismatch is corrected and every call now validates the real field/value pair.
Contact import
importContacts takes an input: ContactImportInput! — a contacts: [JSON!]! array plus a source: String! label ('phone', 'email', 'google', 'apple', 'sync', or 'other') — and stores a one-way SHA-256 hash of each contact (phone > email > name, whichever is most identity-stable) so it can be matched against registered users without keeping the raw contact info. Each contact item needs at least one of email, phone, or name.
mutation ImportContacts($input: ContactImportInput!) {
importContacts(input: $input) {
success
message
}
}
The schema also declares importedCount, existingUsers, and invitationsSent on ContactImportResult. contact-import.manager.js's importContacts now populates the first two (importedCount: results.stored, existingUsers: results.matched; invitationsSent is always 0 here since importContacts never sends anything — that's the separate inviteContacts mutation, which is what actually sets invitationsSent) — this doc previously said none of the three fields were populated at all (which would have crashed every call against the schema's non-null Int! fields); corrected.
Sync contacts
syncContacts takes no arguments — the schema declares it as syncContacts: ContactImportResult! with nothing else. It can't take a fresh client-supplied contact list; instead it re-checks this user's already-imported contacts that never matched a registered user (there's no way to recover the original email/phone from their one-way hash to re-run a fresh match, so it works in the other direction: hashing every recently-active user's own email/phone the same way and checking it against this user's outstanding hashes).
mutation SyncContacts {
syncContacts {
success
message
}
}
Invite contacts
inviteContacts takes a flat list of email addresses (identifiers that don't look like an email are treated as a phone number instead) and, for each one not already invited within the last 30 days, persists an invitation with a generated /invite/:token link and actually delivers it — via services/email.service.js (AWS SES by default) for email-shaped identifiers, or services/sms/sms.service.js (Twilio by default) for phone-shaped ones. This doc previously said only the invitation record was written and no SMS/email delivery integration existed in the codebase; corrected — the same SMS/email services used elsewhere in the app (OTP codes, password reset) are now reused here.
mutation InviteContacts($emails: [String!]!) {
inviteContacts(emails: $emails) {
success
message
}
}
Use getInvitedContactsStatus(limit, offset) to check on sent invites (InvitedContactStatus { contactId email status invitedAt joinedAt reminderCount }); use deleteImportedContacts to purge this user's imported-contact rows (it does not delete sent invitations).
Contact suggestions
contactSuggestions(limit: Int, offset: Int): ContactSuggestionsResponse! now returns the typed ContactSuggestionsResponse/ContactSuggestion declared in user-features.type.js, instead of raw JSON. The resolver maps contact-import.manager.js#getContactSuggestions's internal shape ({ userId, suggestions: [{ contactId, contact, matchedUser, confidence, suggestionReason, contactInfo }], totalSuggestions, pagination }) onto the typed response:
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
}
}
Note: mutualConnections is hardcoded to 0 in the mapping — real mutual-connections counting isn't implemented for contact matches yet (same "TODO: calculate mutual connections" state as a couple of other spots in the codebase, e.g. advanced-social.manager.js#getMutualConnectionsCount). This doc previously said contactSuggestions returned untyped JSON and that ContactSuggestion/ContactSuggestionsResponse were declared but unused by any field; both are now corrected. Rendered by the Contacts settings page (ContactsPage.tsx).
People you may know
The contact-overlap suggestions above are separate from the platform's broader "People you may know" feed, usersYouMayKnow, which is what RecommendationsSection actually renders. It's ranked by mutual connections and other signals rather than imported-contact overlap.
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)
}
A closely related, separately-ranked query, similarUsers(limit, offset): SimilarUsersResponse!, surfaces people with similar taste and activity level (shared content-tag overlap weighted 0.7, posting-activity similarity weighted 0.3) rather than mutual connections; it's rendered by the same RecommendationsSection component.
Mention suggestions
mentionSuggestions powers the @ autocomplete in captions and comments, ranking candidates by relevanceScore. mentions-tags.manager.js#getMentionSuggestions runs a real search (userAccessService.searchUsers, excluding users blocked in either direction) and returns one MentionSuggestion-shaped object per candidate — user, a computed relevanceScore (follow status + verification + mutual-connection count), mutualConnections (real, via userFollowAccessService.getMutualFollowing), and recentInteractions (always false — no interaction-log signal exists yet to back it). The resolver returns that array unchanged, matching the schema's [MentionSuggestion!]!.
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
}
}
Field validation (real-time)
A set of read-only validation queries used mainly for instant feedback during sign-up / profile editing. All return the same 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!
}
Example:
query ValidateUsername { validateUsername(username: "sergio") { valid message suggestions } }
query ValidateField { validateFieldRealtime(input: { field: "email", value: "[email protected]" }) { valid message } }
validateUsername/validateEmail/validateUserData/validateBatchUserData/validateProfileContent resolve in user-validation.resolver.js and are backed by validation.manager.js (via thin wrappers in index.js). validateUsername/validateEmail are the two that are actually wired into the web sign-up form today (Login.tsx, debounced); validateFieldRealtime is wired and works correctly — see the checklist note above.
Validation rules
| Field | Rule |
|---|---|
| Minimum age | 18 years |
| Username | /^[a-z0-9._]{2,20}$/ (lowercase, digits, dots, underscores) |
| Password | 8+ chars, uppercase, lowercase, digit, special character |