Skip to main content

Contacts & Suggestions — Technical Reference

← Back to Contacts & Suggestions

Where this lives

Backend

Frontend

Technical implementation checklist

  • importContacts / syncContacts / deleteImportedContacts — resolvers wired in user-contact-import.resolver.js; contacts persist to the user_contact table (UserContact model, contact.access-service.js); wired to the Contacts settings page (ContactsPage.tsx) — this doc previously said no frontend UI existed; corrected
  • inviteContacts — resolver wired (not inviteByEmail as an earlier draft of these docs said); invitations persist to the user_invitation table (UserInvitation model) with a real /invite/:token link and a 30-day per-contact cooldown before re-inviting; now actually delivers the invite through services/sms/sms.service.js (Twilio by default) for phone-shaped identifiers or services/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); getInvitedContactsStatus is wired to the Contacts settings page's invitation-status list (friendsFromContacts still has no frontend consumer)
  • contactSuggestions — resolver wired; the schema now declares it as the typed ContactSuggestionsResponse (user-features.type.js) instead of raw JSON, and the resolver maps the manager's internal shape onto it — this doc previously said it returned untyped JSON and that ContactSuggestion/ContactSuggestionsResponse were unused by any field; corrected. Wired to the Contacts settings page's suggestions list (follow/dismiss).
  • usersYouMayKnow / dismissSuggestion — wired end-to-end: RecommendationsSection renders the "People you may know" feed and calls dismissSuggestion from the card's X button; dismissals persist per-user in dismissed_suggestion (DismissedSuggestion model: user_id, dismissed_user_id) and are excluded from future usersYouMayKnow results. This is the current equivalent of what earlier drafts of these docs called peopleYouMayKnow/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 by RecommendationsSection
  • mentionSuggestions — resolver wired and called by PostModal's @-mention autocomplete. The manager (mentions-tags.manager.js#getMentionSuggestions) runs a real userAccessService.searchUsers query, excludes users the caller has blocked or is blocked by, and returns one object per candidate shaped exactly like the schema's MentionSuggestion (user, relevanceScore, mutualConnections via userFollowAccessService.getMutualFollowing, recentInteractions); the resolver returns that array as-is. relevanceScore factors in whether the caller already follows the candidate, isVerified, and mutual-connection count. recentInteractions is honestly hardcoded false — 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/validateEmail are wired into the web register form (Login.tsx, debounced)
  • validateFieldRealtime — fixed. The resolver now calls userManager.validateFieldRealtime(input, context) — the single { field, value } object plus context, matching the manager's real validateFieldRealtime(input, context = {}) signature. This doc previously described a resolver-to-manager call mismatch (the resolver passed input.field/input.value/context as three positional args, so the manager's own { field, value } = input destructuring received a bare string and always yielded undefined/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

FieldRule
Minimum age18 years
Username/^[a-z0-9._]{2,20}$/ (lowercase, digits, dots, underscores)
Password8+ chars, uppercase, lowercase, digit, special character