Developer Docs

Social Graph โ€” Integration Guide

Overview

The Social Graph is Mallnline's consumer infrastructure layer, hosted inside the nodes subgraph. It provides three complementary systems that transform Visitors from passive buyers into an engaged, connected community:

System Purpose
Collections (Wishlists) Persistent entity registries โ€” save Products and Malets into shareable lists
Follow System Unidirectional social graph edges โ€” follow Malets, Users, and Organizations
Murchaser Dashboard Aggregation pipeline โ€” unified consumer snapshot via GraphQL Federation

Together, these systems implement Phase 1โ€“2 of the Social Graph & User Connections roadmap, laying the foundation for shared registries, social feeds, and the Mallnline Algorithm.

Architecture at a Glance

Component Location Database Collection
Collections apps/nodes/src/collections/ MongoDB collections, collectionitems
Follows apps/nodes/src/follows/ MongoDB follows
Murchaser Dashboard apps/nodes/src/murchaser-dashboard/ โ€” (reads Follow data) โ€”

All three systems use the @CurrentActor() decorator for authentication and follow the platform's Errors as Data pattern for mutations.


Collections (Wishlists)

Collections are user- or organization-owned lists that hold references to entities across subgraphs (Products, Malets). Think wishlists, gift registries, "Saved for Later", or curated boards.

Data Model

type Collection {
	id: ID! # Format: COL-{nanoid}
	ownerId: ID! # User ID or Organization ID
	ownerType: String! # "USER" or "ORGANIZATION"
	name: String!
	description: String
	visibility: CollectionVisibility! # PUBLIC, PRIVATE, SHARED
	createdAt: DateTime!
	updatedAt: DateTime!
}

type CollectionItem {
	id: ID! # Format: CIT-{nanoid}
	collectionId: ID!
	entityId: ID! # External entity ID (Product, Malet)
	entityType: String! # "Product" or "Malet"
	entity: CollectionEntity # Union type โ€” Gateway resolves the full object
	addedAt: DateTime!
}

union CollectionEntity = Product | Malet

The entity field is a federated union โ€” when queried, the Gateway resolves the full Product or Malet object from their owning subgraphs transparently.

Ownership Scoping

Collections are scoped to their owner. All mutations enforce strict ownership checks:

  • User collections: Scoped to @CurrentActor().id
  • Organization collections: Scoped to the x-org-id header (for Malet Owner dashboards)
  • Attempting to modify another owner's collection returns a UserError (not a 403)

Visibility Levels

Level Who Can See
PRIVATE Owner only (default)
SHARED Anyone with a direct link
PUBLIC Discoverable in search and profile

GraphQL API

Create a Collection

mutation CreateCollection($input: CreateCollectionInput!) {
	createCollection(input: $input) {
		collection {
			id
			name
			visibility
		}
		userErrors {
			code
			message
		}
	}
}

Input:

{
	"input": {
		"name": "Holiday Gift Ideas",
		"description": "Gift ideas for the family",
		"visibility": "SHARED"
	}
}

Add an Item to a Collection

mutation AddCollectionItem($input: AddCollectionItemInput!) {
	addCollectionItem(input: $input) {
		item {
			id
			entityId
			entityType
			entity {
				... on Product {
					id
					name
				}
				... on Malet {
					id
					name
				}
			}
		}
		userErrors {
			code
			message
		}
	}
}

Input:

{
	"input": {
		"collectionId": "COL-abc123",
		"entityId": "PROD-xyz789",
		"entityType": "Product"
	}
}

List My Collections (Cursor-Paginated)

query {
	collections {
		edges {
			node {
				id
				name
				visibility
				createdAt
			}
		}
		pageInfo {
			hasNextPage
			endCursor
		}
	}
}

List Items in a Collection

query CollectionItems($collectionId: ID!) {
	collectionItems(collectionId: $collectionId) {
		edges {
			node {
				id
				entityId
				entityType
				entity {
					... on Product {
						id
						name
					}
					... on Malet {
						id
						name
						slug
					}
				}
			}
		}
		pageInfo {
			hasNextPage
		}
	}
}

Follow System

The Follow System implements unidirectional social graph edges โ€” a Visitor can follow Malets, other Users, and Organizations. Follow events are dispatched via TCP to the alerts service, enabling downstream notifications, timeline feeds, and algorithm signals.

Data Model

type Follow {
	id: ID! # Format: FOL-{nanoid}
	followerId: ID! # The user who initiated the follow
	targetId: ID! # The entity being followed
	targetType: FollowTargetType!
	createdAt: DateTime!
}

enum FollowTargetType {
	MALET # A Malet (storefront/brand)
	USER # Another user on the platform
	ORGANIZATION # An organization
}

Indexes:

  • Compound unique index on (followerId, targetId) โ€” one follow per user-target pair
  • Secondary index on targetId โ€” powers "who follows this entity" queries
  • Compound index on (followerId, targetType) โ€” powers filtered "what I follow" queries

Behavioral Rules

Rule Behavior
Idempotent Following the same entity twice returns the existing record (no error, no duplicate)
Self-follow rejected Attempting to follow yourself returns a BAD_REQUEST UserError
Unfollow missing Unfollowing a non-existent relationship returns a NOT_FOUND UserError

Event Dispatch

Every follow/unfollow action emits a TCP event to the alerts service via ClientProxy:

// On follow:
this.alertsClient.emit('follow_created', {
  followerId: 'user-123',
  targetId: 'malet-456',
  targetType: 'MALET',
  timestamp: '2026-04-06T12:00:00Z'
});

// On unfollow:
this.alertsClient.emit('follow_removed', { ... });

Future consumers (Timeline, Algorithm, Social Feed) can subscribe to these same event patterns without modifying the nodes service.

GraphQL API

Follow a Malet

mutation Follow($input: FollowInput!) {
	followEntity(input: $input) {
		follow {
			id
			followerId
			targetId
			targetType
		}
		userErrors {
			code
			message
		}
	}
}

Input:

{ "input": { "targetId": "MALET-abc123", "targetType": "MALET" } }

Unfollow

mutation Unfollow($input: UnfollowInput!) {
	unfollowEntity(input: $input) {
		follow {
			id
			targetId
		}
		userErrors {
			code
			message
		}
	}
}

Check Follow Status (UI Toggle)

query {
	isFollowing(targetId: "MALET-abc123")
}

Returns true or false. Use this to render follow/unfollow button state.

Get Followers

query {
	followers(targetId: "MALET-abc123") {
		edges {
			node {
				followerId
				createdAt
			}
		}
		pageInfo {
			hasNextPage
		}
	}
}

Follower & Following Counts

query {
	followerCount(targetId: "MALET-abc123") # How many followers this Malet has
	followingCount # How many entities the current user follows
}

My Following (Paginated)

query {
	myFollowing(targetType: MALET) {
		edges {
			node {
				id
				targetId
				targetType
				createdAt
			}
		}
		pageInfo {
			hasNextPage
		}
	}
}

The optional targetType filter lets you scope results (e.g., only show followed Malets on a profile page).


Murchaser Dashboard

The Murchaser Dashboard is an aggregation pipeline that adds unified consumer fields to the federated User type. It uses GraphQL Federation's reference resolution to compose data from across the entire platform in a single GraphQL query.

How It Works

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Client Query                                          โ”‚
โ”‚                                                        โ”‚
โ”‚  query {                                               โ”‚
โ”‚    user(id: "...") {                                   โ”‚
โ”‚      murchaserDashboard {                              โ”‚
โ”‚        followedMalets { id name slug }                 โ”‚
โ”‚        recentOrders { id status totalAmount { ... } }  โ”‚
โ”‚        upcomingBookings { id startTime service { } }   โ”‚
โ”‚        totalFollowedMalets                             โ”‚
โ”‚      }                                                 โ”‚
โ”‚    }                                                   โ”‚
โ”‚  }                                                     โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                     โ”‚
          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
          โ”‚   Hive Gateway      โ”‚
          โ”‚   (Query Planner)   โ”‚
          โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                     โ”‚  Fans out automatically:
          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
          โ–ผ          โ–ผ                      โ–ผ
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚  nodes   โ”‚ โ”‚  murchases   โ”‚  โ”‚  services    โ”‚
    โ”‚ (Follows,โ”‚ โ”‚ (Orders)     โ”‚  โ”‚ (Bookings)   โ”‚
    โ”‚  User)   โ”‚ โ”‚              โ”‚  โ”‚              โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The nodes subgraph returns lightweight federation stubs ({ __typename: "Malet", id: "..." }). The Gateway sees the @key(fields: "id") directive on the external type and automatically resolves full objects from the owning subgraph.

Fields Added to User

Field Type Description
murchaserDashboard MurchaserDashboard Composite snapshot (orders, bookings, followed Malets)
followedMalets [Malet!]! Malets the user follows, resolved via federation
followedMaletCount Int! Count of followed Malets

MurchaserDashboard Type

type MurchaserDashboard {
	recentOrders: [Order!]! # From murchases subgraph
	upcomingBookings: [Booking!]! # From services subgraph
	followedMalets: [Malet!]! # From malets subgraph
	totalOrders: Int!
	totalBookings: Int!
	totalFollowedMalets: Int!
}

Query Examples

Followed Malets on Profile

query UserProfile($id: ID!) {
	user(id: $id) {
		displayName
		avatarUrl
		followedMaletCount
		followedMalets(limit: 6) {
			id
			# Gateway resolves full Malet fields:
			# name, slug, avatarUrl, etc.
		}
	}
}

Full Murchaser Dashboard

query Dashboard($id: ID!) {
	user(id: $id) {
		murchaserDashboard(ordersLimit: 5, bookingsLimit: 3) {
			recentOrders {
				id
				status
				totalAmount {
					amount
					formatted
				}
				createdAt
			}
			upcomingBookings {
				id
				startTime
				endTime
				status
			}
			followedMalets {
				id
			}
			totalFollowedMalets
			totalOrders
			totalBookings
		}
	}
}

Error Handling

All mutations follow the Errors as Data pattern. Instead of throwing GraphQL errors, mutations return structured userErrors arrays:

type FollowPayload {
	follow: Follow
	userErrors: [FollowUserError!]! # Empty array = success
}

type FollowUserError {
	code: String! # e.g., "BAD_REQUEST", "NOT_FOUND", "UNAUTHORIZED"
	message: String! # Human-readable explanation
}
Error Code When
UNAUTHORIZED No authenticated actor
BAD_REQUEST Self-follow attempt, invalid input
NOT_FOUND Unfollowing a non-existent relationship
NOT_OWNER Attempting to modify another user's collection

Frontend Integration

followStore (`src/lib/stores/followStore.svelte.ts`)

Svelte 5 runes-based reactive store managing Follow state on the client. Independent from the favorites store (which uses the legacy handle-based API).

import { followStore } from '$lib/stores/followStore.svelte';

// Load all followed Malet IDs
await followStore.loadFollowing();

// Follow / unfollow (optimistic UI)
await followStore.followMalet(maletId);
await followStore.unfollowMalet(maletId);

// Toggle
await followStore.toggleFollow(maletId);

// Check state
followStore.isFollowingMalet(maletId);  // boolean
followStore.getFollowedIds();            // string[]
followStore.followCount;                 // number

// Follower count (fetches from backend)
await followStore.fetchFollowerCount(maletId); // number

`` Component

Pill-shaped toggle button with optimistic state, pop animation, and auth gating.

<FollowButton maletId="MALET-abc" maletName="Luminara" size="sm" />
Prop Type Default Description
maletId string โ€” Entity ID of the Malet to follow
maletName string '' Display name (for aria label)
size 'sm' | 'md' 'sm' Button size tier

States:

  • Not Following: Outline pill, accent border/text โ†’ on hover, fills accent
  • Following: Filled accent pill with checkmark โ†’ on hover, turns red "Unfollow"
  • Unauthenticated: Redirects to /auth on click

FollowingFeed (`/lobby` โ€” "From Malets You Follow")

A horizontal-scrolling card strip rendered on /lobby between the IntentBar and Mall Directory:

  • Visible when: Authenticated + following โ‰ฅ 1 Malet
  • Data source: Meilisearch search queries filtered by followed Malet IDs (approach B)
  • Card types: Products and Blog Posts from followed Malets
  • Sort: indexedAt descending (newest first)
  • Limit: Up to 4 items per Malet, 20 total, max 10 Malets queried in parallel

Malet Header Integration

The <FollowButton> and follower count are injected into Header.svelte on all Malet pages:

  • Hidden when viewing your own Malet (isOwner prop)
  • Follower count fetched on mount via followStore.fetchFollowerCount()
  • Count formatted with k suffix for 1000+

File Reference

File Purpose
apps/nodes/src/collections/collection.entity.ts Collection model (Wishlist container)
apps/nodes/src/collections/collection-item.entity.ts CollectionItem + external entity union
apps/nodes/src/collections/collections-crud.resolver.ts CRUD mutations with ownership checks
apps/nodes/src/collections/collections-federation.resolver.ts Federation reference resolver
apps/nodes/src/follows/follow.entity.ts Follow model + FollowTargetType enum
apps/nodes/src/follows/follows.service.ts Business logic + TCP event dispatch
apps/nodes/src/follows/follows.resolver.ts GraphQL mutations and queries
apps/nodes/src/follows/follows.module.ts Module with ClientsModule for alerts TCP
apps/nodes/src/murchaser-dashboard/murchaser-dashboard.types.ts External stubs (Order, Booking) + dashboard type
apps/nodes/src/murchaser-dashboard/murchaser-dashboard.resolver.ts @ResolveField on User for aggregation
src/lib/queries/follows.ts Frontend GraphQL operations for Follow System
src/lib/stores/followStore.svelte.ts Svelte 5 runes Follow state management
src/lib/components/FollowButton.svelte Follow/unfollow toggle component
src/routes/lobby/feeds/FollowingFeed.svelte "From Malets You Follow" feed strip
tests/follows.test.ts Unit tests for Follow query types (18 tests)

Testing

Unit Tests

# Collections
make test-pattern PATTERN=collection

# Follows
make test-pattern PATTERN=follows

# Murchaser Dashboard
make test-pattern PATTERN=murchaser-dashboard

# All nodes
make test-pattern PATTERN=nodes

E2E Tests

# Full nodes E2E suite (59 tests)
make test-e2e-pattern PATTERN=nodes

Covers:

  • Collection CRUD lifecycle (create, add items, remove, visibility)
  • Follow lifecycle (follow, idempotency, self-reject, unfollow, isFollowing toggle)
  • Murchaser Dashboard queries (followedMaletCount, murchaserDashboard composite)

Roadmap

Phase Status Description
Collections (Wishlists) โœ… Complete Multi-entity collections with federation unions
Follow System โœ… Complete Follower/following with event dispatch
Murchaser Dashboard โœ… Complete Federation-powered aggregation pipeline
Dynamic Lobby Feed โœ… Complete FollowButton + FollowingFeed + Malet Header integration
Shared Registries ๐Ÿ“‹ Planned Multi-user collaboration on collections (gift registries)
Social Activity Feed ๐Ÿšง Phase 1 /u/[handle] Activity tab โ€” Reviews, Issues, Discussions per user
"People You May Know" ๐Ÿ“‹ Planned Graph-based connection suggestions