Skip to content

Epic 33 — Social Graph (Follow, Search & Suggestions)

Today every user sees every published post (the feed query is a global recency stream — BE-FEED-003). This epic turns the platform into a real social network: a follow graph, a Following / For You feed split, a universal people + content search, and a "who to follow" suggestion engine that reuses the Qdrant embedding infra from Epic 6, and admin content moderation so admins can browse all user posts and unlist or remove non-compliant ones.

Design decisions (locked):

  • Follow graph lives on User (user ↔ user), not on a single profile type — any role can follow any role. Only talents and producers author posts today (Post.authorId / Post.producerAuthorId), so following a pet-owner currently yields no feed content; the graph is intentionally future-proofed.
  • Feed becomes two tabs: Following (chronological posts from accounts you follow) and For You (ranked discovery — current global behavior + suggestions + system posts). New users are never stranded with an empty feed.
  • Search is a new unified surface (people + posts) for all roles. The existing casting-oriented searchTalents / TalentSearchFilters (Epic 6) stays separate — it is a hiring tool, not social search.
  • External social links are handle-only (Instagram / Facebook / TikTok / YouTube / Website): users store a handle per platform and we deep-link out (BE-SOCIAL-006 / FE-SOCIAL-005). We do not fetch or embed their off-platform content — the official APIs don't cover our mostly-personal-account user base (IG Basic Display shut down 2024-12-04, FB personal-profile posts are closed, TikTok Display API needs an app audit). OAuth content-embedding is parked as a possible Phase 2.

BE-SOCIAL-001 — Follow model

  • [x] Done

Files:

  • Edit: castyou-backend/prisma/schema.prisma — add Follow model + back-relations on User
  • Run: pnpm db:migrate (Postgres schema change — prisma generate alone is not enough)

Schema:

prisma
model Follow {
  id          String   @id @default(cuid())
  followerId  String
  follower    User     @relation("Following", fields: [followerId], references: [id], onDelete: Cascade)
  followingId String
  following   User     @relation("Followers", fields: [followingId], references: [id], onDelete: Cascade)
  createdAt   DateTime @default(now())

  @@unique([followerId, followingId])      // idempotent follow; one edge per pair
  @@index([followingId, createdAt])        // "who follows me", newest first
  @@index([followerId, createdAt])         // "who I follow", newest first
  @@map("follows")
}

Add to model User:

prisma
  following  Follow[]  @relation("Following")   // edges where this user is the follower
  followers  Follow[]  @relation("Followers")   // edges where this user is being followed

Notes: A user cannot follow themselves — enforced in the service layer (BE-SOCIAL-002), not the DB. Counts are derived via _count (no denormalised counters in MVP; revisit if follower lists get large).


BE-SOCIAL-002 — Follow/unfollow mutations + relationship queries

  • [x] Done

Files:

  • Create: castyou-backend/src/services/social/index.tsfollow(actorId, targetId), unfollow(actorId, targetId), getFollowers(userId, page, pageSize), getFollowing(userId, page, pageSize), getRelationship(actorId, targetId)
  • Edit: castyou-backend/src/graphql/schema/index.ts
  • Create: castyou-backend/src/graphql/resolvers/social.ts
  • Edit: castyou-backend/src/graphql/resolvers/index.ts
  • Create: castyou-backend/src/__tests__/resolvers/social.test.ts

GraphQL:

graphql
type FollowEdge {
  user:        PublicUser!   # the other party (resolved per query)
  followedAt:  DateTime!
  followsMe:   Boolean!      # does this user follow the viewer back?
  followedByMe: Boolean!     # does the viewer follow this user?
}

type FollowConnection { items: [FollowEdge!]! page: PageInfo! }

# A lightweight, role-agnostic public identity used across social surfaces.
type PublicUser {
  id:           ID!
  displayName:  String!
  handle:       String
  avatarUrl:    String
  role:         String!      # TALENT | PRODUCER | AGENCY | PET_OWNER
  headline:     String       # primaryCategory (talent) or company (producer)
  followerCount: Int!
  followedByMe: Boolean!
}

# Queries
followers(userId: ID!, page: Int = 1, pageSize: Int = 20): FollowConnection!
following(userId: ID!, page: Int = 1, pageSize: Int = 20): FollowConnection!

# Mutations
followUser(userId: ID!): PublicUser!     # idempotent; returns updated target
unfollowUser(userId: ID!): PublicUser!   # idempotent

Rules:

  • Reject self-follow (actorId === targetId) with a user-facing error.
  • followUser / unfollowUser are idempotent — re-following an already-followed user is a no-op success (upsert on the unique pair).
  • Both lists are paginated (per pagination rule) — page / pageSize args + page object. Never return an unbounded follower list.
  • followUser fires a NEW_FOLLOWER notification (BE-NOTIF-003) — but must be silent under admin impersonation (Epic 24): no notification row, no email/push when the actor is an impersonated session.
  • PublicUser.role/headline/avatarUrl resolve from whichever profile the user owns (talent → displayName/primaryCategory/headlinePhoto; producer → company name/logo).

BE-SOCIAL-003 — Following feed tab

  • [x] Done

Files:

  • Edit: castyou-backend/src/services/feed/index.ts — add followingFeed(viewerId, first, after)
  • Edit: castyou-backend/src/graphql/resolvers/feed.ts
  • Edit: castyou-backend/src/graphql/schema/index.ts
  • Edit: castyou-backend/src/__tests__/resolvers/feed.test.ts

GraphQL: extend the existing feed with an explicit tab rather than overloading feed:

graphql
enum FeedTab { FOLLOWING FOR_YOU }

# Replaces the bare feed(first, after) call site; FOR_YOU preserves today's behavior.
feed(tab: FeedTab! = FOR_YOU, first: Int = 10, after: String): PostConnection!

Following query logic:

  1. Resolve the set of followingIds for the viewer.
  2. Map those user ids → their talentProfile.id and producerProfile.id.
  3. Return Post where status = PUBLISHED AND (authorId IN talentIds OR producerAuthorId IN producerIds), ordered createdAt DESC, cursor-paginated (after = last post id), reusing the existing PostConnection shape.
  4. System posts are NOT included in Following (they belong to For You / the official-posts surface) — Following is strictly accounts you chose to follow.
  5. Empty result → resolver returns an empty connection; the FE shows the bootstrap empty state (FE-SOCIAL-001).

BE-SOCIAL-004 — "For You" feed ranking

  • [x] Done

Files:

  • Edit: castyou-backend/src/services/feed/index.ts — add forYouFeed(viewerId, first, after) (the FOR_YOU branch of BE-SOCIAL-003's resolver)
  • Edit: castyou-backend/src/__tests__/services/feed.test.ts

Description: FOR_YOU keeps the current global stream as its backbone but ranks for discovery. Candidate set = published posts from the last N days (excluding the viewer's own + posts from blocked/hidden authors) ∪ system posts. Blend score:

SignalWeightSource
RecencyhighcreatedAt decay
EngagementmediumlikeCount + commentCount + shareCount (normalised)
Author affinitymediumQdrant similarity between viewer's engagement profile and the post author's embedding (reuse Epic 6 vectorStore / embeddings)
Already-followed penaltylow (negative)de-prioritise authors already in Following — For You is for discovery

Dev mode: falls back to engagement-weighted recency (no Qdrant), mirroring the Epic 6 matching fallback. Cursor pagination is score-then-id stable. System posts are interleaved at a fixed cadence (e.g. every 8th card) rather than ranked, so official content always surfaces.


BE-SEARCH-002 — Unified search (people + content)

  • [x] Done

Files:

  • Create: castyou-backend/src/services/search/unified.tssearchPeople(q, page, pageSize), searchPosts(q, page, pageSize)
  • Edit: castyou-backend/src/graphql/schema/index.ts
  • Create: castyou-backend/src/graphql/resolvers/search.ts (or extend existing)
  • Create: castyou-backend/src/__tests__/resolvers/unifiedSearch.test.ts

GraphQL:

graphql
type PeopleSearchConnection { items: [PublicUser!]! page: PageInfo! }
type PostSearchConnection   { items: [Post!]!       page: PageInfo! }

# Queries — role-agnostic, available to every signed-in user
searchPeople(q: String!, page: Int = 1, pageSize: Int = 20): PeopleSearchConnection!
searchPosts(q: String!,  page: Int = 1, pageSize: Int = 20): PostSearchConnection!

Implementation — hybrid keyword + semantic (Qdrant) on both surfaces:

  • People (hybrid): two retrieval passes merged into one ranked PublicUser list:
    1. Keyword — case-insensitive ILIKE across displayName / handle / stageName / primaryCategory (talent) and company name (producer), unioned across profile types. Owns exact-name / handle lookup (@johnsmith, "Maria Silva").
    2. Semantic — embed q and query Qdrant over the talent profile embeddings already indexed in Epic 6 (displayName + primaryCategory + skills + experienceEntries + notableWorks + dreamGigs). Owns descriptive / intent queries ("moody cinematographers in Berlin"). Extend embeddings.indexProfile to also cover producers so company profiles are semantically searchable.
    3. Merge & rank — fuse the two passes: a keyword exact-handle / exact-name hit always outranks a semantic-only hit; within the semantic tail, order by similarity score. De-dupe by userId. Exclude status != ACTIVE.
  • Posts (hybrid): ILIKE on caption (keyword) merged with semantic Qdrant match over post/caption embeddings (extend Epic 6's pipeline to index posts on create — shared with BE-SOCIAL-005). Same exact-then-semantic merge.
  • Dev mode (no Qdrant): both surfaces fall back to ILIKE-only, mirroring the Epic 6 matching fallback — search still works, just without the semantic tail.
  • Both paginated (page / pageSize + page object). Pagination is over the merged result set (stable order: keyword block, then semantic-by-score). Empty q (after trim) returns an empty connection, not an error.

BE-SOCIAL-005 — "Who to follow" suggestion engine

  • [x] Done

Files:

  • Create: castyou-backend/src/services/social/suggestions.tsfollowSuggestions(viewerId, limit)
  • Edit: castyou-backend/src/services/ai/embeddings.ts — also index posts (indexPost) so post + author affinity is queryable (shared with BE-SEARCH-002)
  • Edit: castyou-backend/src/graphql/schema/index.ts
  • Edit: castyou-backend/src/graphql/resolvers/social.ts
  • Create: castyou-backend/src/__tests__/services/suggestions.test.ts

GraphQL:

graphql
type FollowSuggestion {
  user:    PublicUser!
  reasons: [String!]!   # human-readable, e.g. "Followed by 3 people you follow", "Posts about Cinematography"
  score:   Float!       # 0–1
}

followSuggestions(limit: Int = 10): [FollowSuggestion!]!

Ranking signals (blended, mirrors the Epic 6 matchReasons pattern):

  1. Social graph — friends-of-friends: accounts followed by people the viewer follows, weighted by overlap count.
  2. Content affinity — Qdrant similarity between the viewer's engagement/profile embedding and candidate author embeddings.
  3. Behavioral — authors whose posts the viewer has liked/commented/viewed (PostLike / PostComment / viewCount signals) but does not yet follow.
  4. Cold-start / popularity — for viewers with an empty graph, fall back to trending accounts (most followers gained + most engagement in the last 7 days).

Always excluded: the viewer, already-followed users, blocked/suspended users. Each suggestion carries 1–3 reasons. Capped at limit (default 10, hard max 50) — log/comment the cap so it's clear suggestions are intentionally bounded, not exhaustive.


BE-NOTIF-003 — NEW_FOLLOWER notification type

  • [x] Done

Files:

  • Edit: castyou-backend/src/services/notifications/index.ts — add NEW_FOLLOWER constant + trigger from socialService.follow
  • Edit: castyou-backend/src/graphql/schema/index.ts — extend NotificationType enum
TypeTriggerTarget
NEW_FOLLOWERA user follows another user (BE-SOCIAL-002)The followed user

Rules: payload { followerId, followerDisplayName, followerAvatarUrl }; tapping navigates to the follower's profile. Suppressed entirely under admin impersonation (Epic 24) — assert in a regression test that an impersonated follow writes no notification row and sends no email/push. Follows are not messages, so the no-notification-for-messages rule does not apply here.


BE-ADMIN-004 — Admin content moderation API (browse / unlist / remove posts)

  • [x] Done

Files:

  • Edit: castyou-backend/prisma/schema.prisma — add UNLISTED to PostStatus; add moderation fields to Post
  • Run: pnpm db:migrate (Postgres schema change — prisma generate alone is not enough)
  • Edit: castyou-backend/src/graphql/schema/index.ts — admin content queries/mutations; add POST to report target types
  • Edit: castyou-backend/src/graphql/resolvers/admin.ts
  • Create: castyou-backend/src/services/admin/contentModeration.ts
  • Create: castyou-backend/src/__tests__/resolvers/adminContent.test.ts

Schema:

prisma
// extend existing enum — UNLISTED = hidden from all public surfaces but the post still exists
enum PostStatus { PUBLISHED DRAFT UNLISTED REMOVED }

// add to model Post:
  moderatedById     String?    // admin User.id who last acted
  moderatedAt       DateTime?
  moderationReason  String?    // e.g. "Nudity", "Spam", "Off-platform solicitation"
  moderationAction  String?    // UNLIST | REMOVE | RESTORE

Status semantics:

StatusIn Following / For You feed?In search?Direct link?Visible to author?Visible to admin?
PUBLISHED
UNLISTED✅ (not 404)✅ (with a "limited by moderation" banner)
REMOVED❌ (404)✅ (in admin only)

Feeds (BE-SOCIAL-003/004) and search (BE-SEARCH-002) already filter to status = PUBLISHED, so UNLISTED/REMOVED drop out automatically — no extra coupling. Add a regression test asserting an unlisted/removed post is absent from both feeds and both search surfaces.

GraphQL:

graphql
input AdminPostFilter {
  status:     PostStatus     # default: all
  authorId:   ID             # filter to one user's posts
  reportedOnly: Boolean      # only posts with an open Report
  q:          String         # caption contains
}

type AdminPost {
  post:         Post!
  author:       PublicUser!
  reportCount:  Int!         # open reports against this post
  moderatedBy:  PublicUser
  moderatedAt:  DateTime
  moderationReason: String
}

type AdminPostPage { items: [AdminPost!]! page: PageInfo! }

# Queries (admin only — adminGuard / isAdmin)
adminPosts(filter: AdminPostFilter, page: Int = 1, pageSize: Int = 20): AdminPostPage!
adminPost(id: ID!): AdminPost!

# Mutations (admin only)
unlistPost(id: ID!, reason: String!): AdminPost!    # PUBLISHED → UNLISTED
removePost(id: ID!, reason: String!): AdminPost!    # any → REMOVED
restorePost(id: ID!): AdminPost!                    # UNLISTED/REMOVED → PUBLISHED

Rules:

  • All queries/mutations gated by the existing admin guard (isAdmin) — same pattern as BE-ADMIN-001.
  • Every moderation action writes the moderated* fields and a UserActivityLog entry (action: "POST_MODERATION", detail: "<UNLIST|REMOVE|RESTORE> post <id>: <reason>") against the post author's userId, so it shows in their admin activity timeline.
  • Extend reportContent (BE-ADMIN-002) targetType to accept POST; resolving a report can trigger unlistPost/removePost. AdminPostFilter.reportedOnly surfaces the queue.
  • adminPosts is paginated (page/pageSize + page object) — never an unbounded list (pagination rule).
  • Moderation is silent to the author by default (no notification in MVP); revisit a "your post was removed" notice later.

FE-ADMIN-003 — Admin content moderation UI (Content tab)

  • [x] Done

Files:

  • Edit: castyou-frontend/apps/app/src/pages/admin/AdminPage.tsx — add a Content tab (alongside Users / Jobs / Reports)
  • Create: castyou-frontend/apps/app/src/pages/admin/AdminContentPage.tsx
  • Create: castyou-frontend/apps/app/src/components/admin/PostModerationModal.tsx
  • Create: castyou-frontend/apps/app/src/hooks/useAdminContent.ts
  • Create: castyou-frontend/apps/app/src/lib/queries/adminContent.tsADMIN_POSTS_QUERY, UNLIST_POST, REMOVE_POST, RESTORE_POST
  • Edit: castyou-frontend/apps/app/src/pages/admin/AdminUserDetailPage.tsx — "Posts" section linking to this user's content (filter authorId)

Description: Admin-only Content tab: a paginated table of posts — thumbnail, author (avatar + handle), caption excerpt, status badge (Published / Unlisted / Removed), like/comment counts, report badge, created date, and a row action menu (Unlist / Remove / Restore / View). Filters: status, reported-only, author, caption search. Clicking a row opens a preview with the full media + PostModerationModal (action picker + required reason field). Optimistic status update on action. Pagination controls below the table (pagination rule). All components from @castyou/design-system (design-system rule). Reported posts also surface in the existing Reports tab via the POST target type.


FE-SOCIAL-001 — Feed two-tab (Following / For You)

  • [x] Done

Files:

  • Edit: castyou-frontend/apps/app/src/pages/feed/FeedPage.tsx — add a tab switcher
  • Edit: castyou-frontend/apps/app/src/hooks/useFeed.ts — pass tab
  • Edit: castyou-frontend/packages/design-system/src/components/ui/Tabs.tsx — DS Tabs (add if missing)
  • Edit: castyou-frontend/packages/design-system/src/index.ts

Description: Top-of-feed Tabs: For You (default) and Following. Each tab drives feed(tab:) with its own infinite-scroll cursor (existing IntersectionObserver pattern). Following empty state (EmptyState from DS): "You're not following anyone yet" + a "Find people to follow" CTA → the suggestions screen (FE-SOCIAL-004). All components from @castyou/design-system (design-system rule).


FE-SOCIAL-002 — Follow button (DS component)

  • [x] Done

Files:

  • Edit: castyou-frontend/packages/design-system/src/components/ui/FollowButton.tsx — new DS component (Follow / Following states, optimistic)
  • Edit: castyou-frontend/packages/design-system/src/index.ts
  • Create: castyou-frontend/apps/app/src/hooks/useFollow.tsfollowUser / unfollowUser mutations with optimistic cache update + rollback
  • Edit: castyou-frontend/apps/app/src/components/feed/PostCard.tsx — add Follow button to post header
  • Edit: talent/producer profile headers — add Follow button

Description: FollowButton shows "Follow" (primary) → "Following" (secondary; hover/long-press reveals "Unfollow"). Optimistic toggle, rolls back on error. Hidden on the viewer's own profile/posts.


FE-SOCIAL-003 — Follower / following counts + lists screen

  • [x] Done

Files:

  • Create: castyou-frontend/apps/app/src/pages/social/FollowListPage.tsx — tabbed Followers / Following list
  • Create: castyou-frontend/apps/app/src/lib/queries/social.tsFOLLOWERS_QUERY, FOLLOWING_QUERY
  • Edit: profile header components — show followerCount / followingCount, tappable → FollowListPage
  • Edit: castyou-frontend/apps/app/src/App.tsx — route /u/:userId/followers, /u/:userId/following

Description: Profile headers show follower/following counts. Tapping opens a paginated list (rows = avatar + name + handle + FollowButton). Paginated with controls below the list (pagination rule). All DS components.


FE-SEARCH-001 — Universal search screen (People / Posts)

  • [x] Done

Files:

  • Create: castyou-frontend/apps/app/src/pages/search/SearchPage.tsx
  • Create: castyou-frontend/apps/app/src/lib/queries/search.tsSEARCH_PEOPLE_QUERY, SEARCH_POSTS_QUERY
  • Create: castyou-frontend/apps/app/src/hooks/useSearch.ts — debounced query
  • Edit: castyou-frontend/apps/app/src/components/AppShell.tsx — search entry (nav bar / icon)
  • Edit: castyou-frontend/apps/app/src/App.tsx/search route

Description: A search bar with People and Posts result tabs. Debounced (~300 ms) input → searchPeople / searchPosts. People rows = PublicUser + FollowButton; Posts rows = compact PostCard. Empty query → recent searches / suggestions placeholder; no results → EmptyState. Paginated results. All DS components.


FE-SOCIAL-004 — "Suggested for you" rail + discovery screen

  • [x] Done

Files:

  • Create: castyou-frontend/apps/app/src/pages/social/SuggestionsPage.tsx — full "Who to follow" screen
  • Create: castyou-frontend/apps/app/src/components/social/SuggestionRail.tsx — horizontal rail
  • Create: castyou-frontend/apps/app/src/lib/queries/social.ts (extend) — FOLLOW_SUGGESTIONS_QUERY
  • Edit: castyou-frontend/packages/design-system/src/components/ui/SuggestionCard.tsx — DS card (avatar, name, reason chip, Follow button)
  • Edit: castyou-frontend/packages/design-system/src/index.ts

Description: SuggestionRail (horizontal SuggestionCards) embedded in the For You feed (e.g. after the first few posts) and on the Following empty state. Each card shows the reasons[0] as a chip ("Followed by 3 people you follow"). Dedicated SuggestionsPage lists the full ranked set. Following from a card removes it from the rail (optimistic). All DS components.


BE-SOCIAL-006 — External social profile handles (Instagram / Facebook / TikTok)

  • [x] Done

Scope (handle-only, locked 2026-06-09): users link their off-platform presence by storing a handle/username per platform and deep-linking out — CasTyou does not fetch or embed their latest posts.

Feasibility note — why handle-only (decision rationale, do not reopen without re-checking the APIs): a full "log in to show your latest content" flow was evaluated and rejected for MVP because the official APIs don't cover our (mostly personal-account) user base:

  • Instagram — the Basic Display API that exposed any personal account's feed was permanently shut down 2024-12-04. The only remaining path is the Instagram Graph API, which requires a Business/Creator account linked to a Facebook Page — personal accounts cannot connect at all.
  • Facebook — Meta's API is Page-centric; fetching a personal profile's posts is effectively closed (locked behind App Review + Business Verification and returns nothing useful for individuals).
  • TikTok — the Display API (/v2/user/info/, /v2/video/list/) can fetch a creator's recent videos via OAuth (scopes user.info.basic,video.list), but requires passing TikTok's app audit (privacy policy + demo video, ~1–2 weeks) and a server-side token-refresh job.

OAuth content-embedding (TikTok video embed + Instagram Business/Creator embed; Facebook always skipped) is parked as a possible Phase 2 enrichment, not part of this ticket.

Files:

  • Reuse: the existing socialLinks Json? field already on TalentProfile ({ website, linkedin, instagram }) and ProducerProfile ({ instagram, tiktok, website }) — normalise both to a shared shape (see below) rather than adding new columns. No migration needed if we keep Json.
  • Create: castyou-backend/src/services/social/handles.tsnormalizeHandle(platform, raw), socialProfileUrl(platform, handle), validateHandles(input)
  • Edit: castyou-backend/src/graphql/schema/index.tsSocialHandles type + SocialHandlesInput, expose on the public profile
  • Edit: the talent/producer profile resolvers + the updateProfile mutation to accept socialHandles
  • Create: castyou-backend/src/__tests__/services/socialHandles.test.ts

GraphQL:

graphql
type SocialHandle {
  platform: SocialPlatform!
  handle:   String!          # normalised, no leading "@", no URL
  url:      String!          # canonical profile URL, resolver-built
}

enum SocialPlatform { INSTAGRAM FACEBOOK TIKTOK YOUTUBE WEBSITE LINKEDIN }

input SocialHandleInput { platform: SocialPlatform!  handle: String! }

# exposed on PublicUser / talent + producer public profiles
socialHandles: [SocialHandle!]!

# folded into the existing profile update
input UpdateProfileInput { ...  socialHandles: [SocialHandleInput!] }

Rules:

  • Store only the handle, never a full URL. normalizeHandle strips a leading @, strips any instagram.com/…/tiktok.com/@…/facebook.com/… URL the user pastes down to the username, trims whitespace, lowercases where the platform is case-insensitive.
  • url is always built server-side from (platform, handle) via socialProfileUrl — never trust a client-supplied URL (avoids open-redirect / spoofed links). TikTok → https://www.tiktok.com/@<handle>, Instagram → https://instagram.com/<handle>, Facebook → https://facebook.com/<handle>, etc.
  • Validate each handle against the platform's allowed charset/length; reject invalid with a per-field user-facing error. Empty/removed handle = delete that platform entry.
  • No external network calls — we do not verify the account exists or fetch any content. (If Phase 2 OAuth embedding ever lands, that is where verification/fetching would live.)
  • WEBSITE allows a full URL (validated https?://, rendered as-is).

  • [x] Done

Files:

  • Edit: castyou-frontend/apps/app/src/pages/profile/… profile-edit form — add a "Social profiles" section
  • Edit: castyou-frontend/packages/design-system/src/components/…SocialHandleInput (platform icon + @ prefix field) and a SocialLinks display row (icon buttons), both in the DS
  • Edit: castyou-frontend/packages/design-system/src/index.ts
  • Edit: castyou-frontend/apps/app/src/lib/queries/social.ts (or the profile query/mutation) — include socialHandles
  • Public profile view ([BE-PROFILE public view, Epic 5]) — render the SocialLinks icon row in the existing "social links" slot

Description: In profile-edit, a "Social profiles" section with one input per platform (Instagram, Facebook, TikTok, YouTube, Website) showing a fixed @/icon prefix so users type only the handle; pasting a full URL is accepted and normalised on save. On the public profile, render handles as a row of tappable platform-icon buttons that open the canonical url (server-built) in a new tab. All components from @castyou/design-system. No embedded feeds — links out only.