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 |
Related
- Handle System & Sigil Taxonomy โ Phase 1 handle infrastructure that this feature builds on
- Social Graph โ Collections, Follows & Murchaser Dashboard โ Follow system powering the profile follow/unfollow buttons
- Community Features โ Reviews, Q&A, and Issues that populate the activity feed
- Community Orchestration โ Assignment workflows for Issues and Discussions referenced in activity items
- Privacy & Security APIs โ
profileVisibilityfield that controls public profile access