Developer Docs

Nodes Subgraph

The Nodes subgraph is the canonical owner of user profiles on the Ngwenya platform. It extends the base identity provided by the auth service (which manages login credentials) into fully-featured consumer profiles with handles, social connections, privacy controls, and cross-service metadata.

Port: 3009 ยท Federation: Apollo v2 ยท Database: MongoDB (users, follows, collections, user_attributes, blocked_users collections)


Architecture

apps/nodes/src/
โ”œโ”€โ”€ actors/user/          โ€” User entity, mutations, federation, privacy resolvers
โ”‚   โ”œโ”€โ”€ user.entity.ts        โ€” Federated User type (@key "id", @extends)
โ”‚   โ”œโ”€โ”€ user-mutation.resolver.ts  โ€” Profile updates, handle claims, preferences
โ”‚   โ”œโ”€โ”€ user-federation.resolver.ts โ€” __resolveReference for cross-subgraph resolution
โ”‚   โ”œโ”€โ”€ content-visibility.resolver.ts โ€” contentDisplayName, contentAvatarUrl
โ”‚   โ”œโ”€โ”€ age-verification.resolver.ts  โ€” Age verification status + self-declaration
โ”‚   โ”œโ”€โ”€ support-proxy.resolver.ts     โ€” Pseudo-anonymous aliases, contact sharing
โ”‚   โ”œโ”€โ”€ admin-users.resolver.ts       โ€” Tower-scoped user listing + search
โ”‚   โ”œโ”€โ”€ admin-users.service.ts        โ€” Paginated user queries (admin)
โ”‚   โ”œโ”€โ”€ account-change-hook.controller.ts โ€” TCP listener for account lifecycle events
โ”‚   โ”œโ”€โ”€ cookie-consent.service.ts     โ€” GDPR cookie preferences
โ”‚   โ””โ”€โ”€ data-export.service.ts        โ€” GDPR data export (JSON download)
โ”œโ”€โ”€ attributes/           โ€” Key-value metadata per user (cross-service)
โ”œโ”€โ”€ blocked-users/        โ€” User block/unblock system
โ”œโ”€โ”€ collections/          โ€” Wishlists and curated collections
โ”œโ”€โ”€ follows/              โ€” Social graph (follow/unfollow)
โ”œโ”€โ”€ membership/           โ€” Organization membership federation
โ”œโ”€โ”€ murchaser-dashboard/  โ€” Buyer-side order aggregation
โ””โ”€โ”€ utils/                โ€” Shared utilities

All providers are registered in a single NodesModule. The directory structure is for code organization only.


Core Entity: User

The User entity federates from the auth service using @key(fields: "id") with @extends. The auth service owns the id field; Nodes owns everything else.

type User @key(fields: "id") @extends {
  id: ID! @external

  # Profile (owned by Nodes)
  displayName: String
  handle: String              # v|handle sigil
  bio: String
  avatarUrl: String
  location: String
  website: String
  company: String
  handleChangedAt: DateTime
  handleHistory: [HandleHistoryEntry!]!

  # Privacy-Aware Display
  contentDisplayName: String! # Computed from privacy settings
  contentAvatarUrl: String    # null when anonymous
  contentVisibilityStatus: ContentVisibility!

  # Age Verification
  isAgeVerified: Boolean!
  ageVerificationStatus: AgeVerificationStatus!

  # Support Anonymity
  supportAlias: String       # e.g. "Customer-A7B3C"
  supportContactEmail: String # Only visible if consumer explicitly shared
  supportContactPhone: String

  # Preferences
  preferences: UserPreferences!
  attributes: [UserAttribute!]!
}

Profile Auto-Creation

When auth creates a new user, it emits a user_created TCP event. The AppController in Nodes listens for this event and auto-creates the corresponding User document in MongoDB:

@EventPattern('user_created')
async handleUserCreated(data: { userId: string; email?: string }) {
  await this.userModel.create({ userId: data.userId, email: data.email });
}

If a profile query arrives before the event (race condition), the resolvers use lazy initialization โ€” creating the profile on-demand during the first mutation.


Handle System (`v|` sigil)

Users claim a globally unique handle with the v| sigil prefix (e.g., v|sarah). The handle becomes the user's public URL slug: mallnline.com/u/sarah.

Rules

Rule Details
Format Lowercase alphanumeric + hyphens, 2โ€“30 characters
Uniqueness Unique across the user namespace. Cross-namespace check with checkHandleAvailability (malets) recommended
Reserved 35+ reserved words including platform routes (admin, support, lobby), u-products (ucart, uchat), and brand names (mallnline, ngwenya)
Cooldown Handle changes limited to once every 30 days
History All previous handles are stored in handleHistory[]
Permanence Handles can be changed (with cooldown), but the old handle is not released โ€” it remains in the history

API

# Check availability
query { checkUserHandleAvailability(handle: "sarah") } # โ†’ true/false

# Claim or change handle
mutation { updateProfile(input: { handle: "sarah" }) { id handle handleChangedAt } }

Social Graph

Follows

The follow system implements a unidirectional social graph stored in the follows collection. Users can follow three target types:

Target Type Sigil Example
MALET `m `
USER `v `
ORGANIZATION `o `
# Follow
mutation { followEntity(input: { targetId: "...", targetType: MALET }) { id } }

# Unfollow
mutation { unfollowEntity(targetId: "...") }

# Query followers / following
query { followers(targetId: "...", targetType: MALET) { edges { node { id } } } }
query { following(userId: "...") { edges { node { id targetType } } } }
query { isFollowing(userId: "...", targetId: "...") } # โ†’ Boolean

Indexing: Compound unique index on (followerId, targetId) prevents duplicates. Secondary index on (followerId, targetType) powers "all Malets I follow" queries.

Collections (Wishlists)

Users create named collections to organize saved items (see Social Wishlist Collaboration for real-time synchronization and sharing architecture):

# CRUD
mutation { createCollection(input: { name: "Summer Wishlist" }) { id } }
mutation { addToCollection(collectionId: "...", itemId: "...", itemType: PRODUCT) { id } }
mutation { removeFromCollection(collectionId: "...", itemId: "...") { id } }

# Query
query { myCollections { id name itemCount items { itemId itemType } } }

Content Visibility & Privacy

The ContentVisibilityResolver computes what name and avatar to display on reviews, comments, and community content based on the user's privacy preferences:

Setting contentDisplayName contentAvatarUrl
FULL_NAME User's display name User's avatar
FIRST_NAME_ONLY First name or initial User's avatar
ANONYMOUS "Anonymous" null (default avatar shown)

This resolver is used by the community, reviews, Q&A, and blog comment subgraphs via federation. When those subgraphs resolve a User entity, the contentDisplayName field automatically applies the privacy mask.


Support Anonymity

The SupportProxyResolver provides pseudo-anonymous identities for support interactions:

  • supportAlias โ€” A randomly generated alias (e.g., Customer-A7B3C) scoped to a Malet/Organization. Stable across interactions with the same seller.
  • supportContactEmail / supportContactPhone โ€” Only populated if the consumer explicitly shared contact info on a specific support ticket (via ShareContactWithIssue mutation).

This ensures Malet Owners can respond to support requests without seeing the buyer's real identity unless the buyer opts in.


Age Verification

Self-declaration age verification for age-restricted products and services:

Status Meaning
UNVERIFIED No age declaration submitted
VERIFIED User declared they meet the minimum age threshold
EXPIRED Verification period lapsed (annual re-verification)
DECLINED User declined to verify
mutation { declareAge(dateOfBirth: "1990-01-15") { isAgeVerified ageVerificationStatus } }

The platform never exposes the user's actual date of birth. Only the boolean isAgeVerified and the ageVerificationStatus enum are accessible in the GraphQL schema.


Blocked Users

Users can block other users, hiding them from search results and preventing messaging:

mutation { blockUser(targetUserId: "...") { id } }
mutation { unblockUser(targetUserId: "...") }
query { blockedUsers { targetUserId blockedAt } }
query { isBlocked(targetUserId: "...") } # โ†’ Boolean

Cross-Service Dependencies

Service Integration
auth user_created event โ†’ auto-profile creation
community User.contentDisplayName / contentAvatarUrl via federation
alerts TCP consumer for user_created (welcome notification)
organizations Membership federation (org.members โ†’ User)
malets Malet owner display via User federation
gateway x-user-id header โ†’ identity resolution

GraphQL API Reference

Queries

# Profile
user(id: ID!): User
checkUserHandleAvailability(handle: String!): Boolean!

# Social Graph
followers(targetId: ID!, targetType: FollowTargetType!, limit: Int): FollowConnection!
following(userId: ID!, limit: Int): FollowConnection!
isFollowing(userId: ID!, targetId: ID!): Boolean!
followerCount(targetId: ID!): Int!
followingCount(userId: ID!): Int!

# Collections
myCollections: [Collection!]!
collection(id: ID!): Collection

# Admin (Tower)
adminUsers(page: Int, limit: Int, search: String): UserConnection!

Mutations

# Profile
updateProfile(input: UserUpdateDTO!): User!
updatePreferences(input: UserPreferences!): User!
updatePrivacySettings(input: PrivacyPreferences!): User!
toggleProfileVisibility(visibility: VisibilityPrivacy!): User!

# Push Tokens
registerPushToken(token: String!): User!
unregisterPushToken(token: String!): User!

# Social Graph
followEntity(input: CreateFollowInput!): Follow!
unfollowEntity(targetId: ID!): Boolean!

# Collections
createCollection(input: CreateCollectionInput!): Collection!
addToCollection(collectionId: ID!, itemId: ID!, itemType: CollectionItemType!): Collection!
removeFromCollection(collectionId: ID!, itemId: ID!): Collection!
deleteCollection(id: ID!): Boolean!

# Support
shareContactWithIssue(issueId: ID!): Boolean!
revokeContactFromIssue(issueId: ID!): Boolean!

# Age Verification
declareAge(dateOfBirth: String!): User!

# Blocking
blockUser(targetUserId: ID!): UserBlock!
unblockUser(targetUserId: ID!): Boolean!

Testing

Suite Tests Coverage
User Mutations 8 Handle claims, cooldown, reserved words, profile updates
Follows 6 Follow/unfollow, duplicates, query counts
Collections 7 CRUD, item management, ownership
Content Visibility 4 Privacy mask computation
Support Proxy 4 Alias generation, contact sharing
Admin Users 3 Pagination, search
E2E 5 User creation, events, follows, admin
Total ~37 โ€”