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-idheader (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
/authon 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:
indexedAtdescending (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 (
isOwnerprop) - Follower count fetched on mount via
followStore.fetchFollowerCount() - Count formatted with
ksuffix 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 |
Related
- Community Features โ Reviews, Q&A, and ratings that integrate with the social graph
- Public User Profiles โ
/u/[handle]profile page powered by handle resolution and community activity aggregation - Analytics Aggregation API โ Murchaser dashboard federation stubs that resolve via the social graph
- Privacy & Security APIs โ Content visibility settings that control how user profiles appear in follows
- Handle System & Sigil Taxonomy โ MallHandle component used in FollowingFeed cards; HandleClaimBanner appears on Lobby alongside FollowingFeed
- Universal Search Index โ Meilisearch queries powering the Dynamic Lobby Feed
- Mallnline Algorithm โ How the Follow system drives personalized lobby recommendations