Developer Docs

Public User Profiles & Activity Feed

Public User Profiles implement Phase 2 of the Handle System โ€” giving every Visitor with a claimed @handle a shareable, discoverable profile page at /u/{handle}. The system bridges two subgraphs (nodes for identity resolution, community for activity aggregation) and enforces strict privacy controls.


Architecture Overview

Component Subgraph Location Purpose
Handle Resolution nodes user-query.resolver.ts Case-insensitive userByHandle(handle) query
Activity Feed community social/activity/ Read-time aggregation of Reviews, Issues, Discussions
Profile Route Frontend routes/u/[handle]/ Tabbed profile UI with social integration

Data Flow

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Frontend: /u/meekdenzo                     โ”‚
โ”‚                                             โ”‚
โ”‚  1. userByHandle("meekdenzo") โ†’ nodes       โ”‚
โ”‚  2. isFollowing(userId) โ†’ nodes             โ”‚
โ”‚  3. followerCount(userId) โ†’ nodes           โ”‚
โ”‚  4. userActivity(userId) โ†’ community        โ”‚
โ”‚  5. myFollowing(MALET) โ†’ nodes              โ”‚
โ”‚                                             โ”‚
โ”‚  All queries via Hive Gateway (port 30000)  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Handle Resolution (`nodes` subgraph)

The userByHandle query resolves a Visitor's handle to their public profile. It is the entry point for all profile page loads.

GraphQL API

query UserByHandle($handle: String!) {
  userByHandle(handle: $handle) {
    id
    displayName
    handle
    avatarUrl
    bio
    createdAt
    profileVisibility
  }
}

Behavioral Rules

Rule Behavior
Case-insensitive userByHandle("MeekDenzo") resolves the same as userByHandle("meekdenzo")
PRIVATE profiles Returns null if profileVisibility === PRIVATE โ€” the profile page shows a "User Not Found" card
Owner bypass A Visitor viewing their own profile always sees their data, even when set to PRIVATE
No handle โ†’ null If the user has no handle claimed, or the handle doesn't exist, returns null

Implementation Notes

The resolver uses a case-insensitive regex for MongoDB lookups:

const user = await this.userModel
  .findOne({ handle: { $regex: new RegExp(`^${handle}$`, 'i') } })
  .select('id handle displayName avatarUrl bio createdAt profileVisibility')
  .lean()
  .exec();

WARNING

The privacy guard runs after the database lookup. This means MongoDB still processes the query for PRIVATE profiles โ€” it just returns null at the resolver level. For high-traffic profiles, consider a MongoDB partial index on { handle: 1, profileVisibility: 1 }.


Activity Feed (`community` subgraph)

The Activity Feed provides a chronological list of a Visitor's public contributions โ€” Reviews, Issues, and Discussions. It uses read-time aggregation from existing collections rather than maintaining a separate activity log.

GraphQL API

query UserActivity($userId: ID!, $limit: Int) {
  userActivity(userId: $userId, limit: $limit) {
    id
    type          # REVIEW | ISSUE_OPENED | DISCUSSION
    entityId
    entityTitle
    targetName
    createdAt
  }
}

Activity Types

Type Source Entity Title Field Filter
REVIEW Review title status === APPROVED
ISSUE_OPENED Issue title All (public by default)
DISCUSSION Discussion name All (public by default)

IMPORTANT

The Discussion entity uses name as its display field, not title. This is a common gotcha when working across community entity types โ€” Reviews and Issues use title, Discussions use name.

Architecture Decision: Read-Time Aggregation

The Activity Feed performs parallel MongoDB queries across three collections and merges/sorts in memory:

const [reviews, issues, discussions] = await Promise.all([
  this.reviewModel.find({ createdBy: userId, status: 'APPROVED' })
    .sort({ createdAt: -1 }).limit(limit).lean().exec(),
  this.issueModel.find({ createdBy: userId })
    .sort({ createdAt: -1 }).limit(limit).lean().exec(),
  this.discussionModel.find({ createdBy: userId })
    .sort({ createdAt: -1 }).limit(limit).lean().exec(),
]);
// Merge โ†’ sort by createdAt desc โ†’ slice to limit

Why read-time instead of a materialized activity collection:

  • Data consistency: Activity is always current โ€” no sync lag or orphaned entries
  • Zero write overhead: No event handlers, no dual-writes, no eventual consistency bugs
  • Simple rollback: If a Review is un-approved, it instantly disappears from the feed
  • Acceptable latency: Three parallel .lean() queries with .select() projections are fast at current data volumes

When to reconsider: If activity feed queries become a latency bottleneck (>200ms p95), introduce a denormalized user_activity collection with write-through from entity creation hooks.

Module Registration

TIP

The ActivityModule uses TypegooseModule.forFeature() (from @m8a/nestjs-typegoose), not NestjsQueryTypegooseModule.forFeature(). This is because Review, Issue, and Discussion entities are already registered with assembler serializers by CommentsModule. Double-registration crashes the NestJS DI container with Assembler Serializer already registered for model.

@Module({
  imports: [
    TypegooseModule.forFeature([Review, Issue, Discussion]),
  ],
  providers: [ActivityService, ActivityResolver],
  exports: [ActivityService],
})
export class ActivityModule {}

Frontend Integration

Route Structure

The public profile lives at /u/[handle]:

src/routes/u/[handle]/
โ”œโ”€โ”€ +page.ts          # SvelteKit load โ€” extracts handle param
โ””โ”€โ”€ +page.svelte      # Profile page component

Profile Page Tabs

Tab Content Data Source
Activity Chronological feed of Reviews, Issues, Discussions userActivity query
Malets Malets the user follows myFollowing(targetType: MALET) query

Social Integration

The profile header includes follow/unfollow functionality via the ActorBadge component:

State Button Behavior
Authenticated, not following "Follow" Calls followEntity mutation
Authenticated, following "Following โœ“" Calls unfollowEntity mutation
Own profile Hidden Follow actions are not shown
Unauthenticated "Sign in to follow" Links to /auth

Follow errors display as an inline toast that auto-dismisses after 4 seconds.

Error Handling

The profile page uses a parseGraphQLError() utility that classifies graphql-request ClientError responses into human-friendly messages:

HTTP Status User-Facing Message
401 "Session expired."
403 "Access denied."
404 Routes to "User Not Found" UI
429 "Too many requests."
500+ "Something went wrong on our end."
Network error "Unable to connect to the server."

NOTE

graphql-request's ClientError dumps the entire JSON payload (including raw query text and response headers) into e.message. The parseGraphQLError() function extracts the HTTP status from e.response.status and GraphQL error codes from e.response.errors[].extensions.code, ensuring no raw JSON is ever shown to the user.


Discussion Create DTO

As part of this feature, the DiscussionInputDTO was extended with subjectId and subjectType fields:

export class DiscussionInputDTO {
  @IsString()
  @MaxLength(20)
  @Field()
  name!: string;

  @IsString()
  @Field({ description: 'ID of the subject entity (e.g. Malet, Order)' })
  subjectId!: string;

  @IsOptional()
  @Field(() => SubjectType, {
    nullable: true,
    defaultValue: SubjectType.MALET,
  })
  subjectType?: SubjectType;
}

Previously, subjectId was required on the Mongoose schema but not exposed in the GraphQL input โ€” causing every createOneDiscussion mutation to fail with a Mongoose validation error.


Testing

Backend

# Community E2E (14 tests โ€” includes activity module validation)
make test-e2e PATTERN=community

# TypeScript type check
cd apps/community && npx tsc --noEmit

Frontend

# Unit tests (28 tests)
npx vitest run tests/public-profile.test.ts

# E2E tests (5 tests)
npx playwright test tests/public-profile.spec.ts

File Reference

File Purpose
apps/nodes/src/actors/user/user-query.resolver.ts userByHandle query with privacy guard
apps/community/src/social/activity/activity.module.ts Activity module registration
apps/community/src/social/activity/activity.service.ts Read-time aggregation across Reviews/Issues/Discussions
apps/community/src/social/activity/activity.resolver.ts userActivity GraphQL query
apps/community/src/social/activity/activity.types.ts ActivityItem and ActivityType type definitions
apps/community/src/comments/discussion/dto/create-discussion.input.ts DiscussionInputDTO with subjectId/subjectType
src/routes/u/[handle]/+page.svelte Frontend public profile route
src/routes/u/[handle]/+page.ts SvelteKit load function
src/lib/queries/auth.ts USER_BY_HANDLE query definition
src/lib/queries/community.ts USER_ACTIVITY query definition
src/lib/utils/handleUtils.ts Updated getHandleRoute() for user handles
src/lib/components/ActorBadge.svelte Follow/unfollow mutation wiring
tests/public-profile.test.ts 28 unit tests
tests/public-profile.spec.ts 5 E2E tests