generated from nhcarrigan/template
feat: implement comprehensive activity feed feature
Implements issue #56 with a timeline-style activity feed showing recent user activity across the library. Activity Types Displayed: - Suggestions: Shows suggested items with status badges - Likes: Shows liked content with links to items - Comments: Shows comments with preview text - Achievements: Shows earned achievements with icons and points Database & Backend: - Created ActivityService to aggregate from existing data sources - No new database table needed (uses existing suggestions, likes, comments, achievements) - Efficient querying with parallel data fetching - Privacy-aware: Only shows activity from users with profilePublic: true - Filters out banned users automatically API Endpoints: - GET /api/activity - Get general activity feed with pagination - GET /api/activity/:userId - Get specific user's activity - Query parameters: limit (max 100, default 50), offset (default 0) - Returns: activities array, total count, hasMore flag Frontend Activity Feed: - Beautiful timeline layout with user avatars and badges - Relative timestamps ("5m ago", "2h ago", "3d ago") - Activity type icons (💡 💬 ❤️ 🏆) - Colour-coded status badges for suggestions - Comment previews with styled quote blocks - Achievement displays with icons and points - Infinite scroll with "Load More" button - Links to user profiles and content items UI Components: - Responsive card-based design - User avatars with placeholder fallback - Badge display (STAFF, MOD, VIP, primary badges) - Entity links that route to appropriate media pages - Clean typography and spacing - Loading and empty states Privacy & Performance: - Respects profilePublic setting (existing privacy control) - Only displays activity from non-banned users - Pagination to prevent loading too much data - Efficient aggregation with limit multipliers - Clean separation of concerns (service/routes/component) Navigation: - Added /activity route - Added "📰 Activity Feed" link to user dropdown menu - Positioned between Leaderboard and About Implementation Notes: - Built entirely from existing data (no activity table needed) - Retroactive: Shows all historical activity automatically - Type-safe with full TypeScript interfaces - Activity union type for type discrimination - Standalone Angular component - Clean, maintainable code structure
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type {
|
||||
Activity,
|
||||
ActivityFeedResponse,
|
||||
ActivityType,
|
||||
ActivityUser,
|
||||
} from "@library/shared-types";
|
||||
import { ACHIEVEMENTS } from "@library/shared-types";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
export class ActivityService {
|
||||
private prisma = prisma;
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Get activity feed with pagination.
|
||||
*/
|
||||
async getActivityFeed(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
userId?: string
|
||||
): Promise<ActivityFeedResponse> {
|
||||
// Fetch suggestions, likes, comments, and achievements
|
||||
const [suggestions, likes, comments, achievements] = await Promise.all([
|
||||
this.getSuggestionActivities(limit, offset, userId),
|
||||
this.getLikeActivities(limit, offset, userId),
|
||||
this.getCommentActivities(limit, offset, userId),
|
||||
this.getAchievementActivities(limit, offset, userId),
|
||||
]);
|
||||
|
||||
// Combine and sort by createdAt
|
||||
const activities: Activity[] = [
|
||||
...suggestions,
|
||||
...likes,
|
||||
...comments,
|
||||
...achievements,
|
||||
].sort((a, b) => {
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
|
||||
// Apply pagination to combined results
|
||||
const paginatedActivities = activities.slice(offset, offset + limit);
|
||||
const hasMore = activities.length > offset + limit;
|
||||
|
||||
return {
|
||||
activities: paginatedActivities,
|
||||
total: activities.length,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestion activities.
|
||||
*/
|
||||
private async getSuggestionActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? { userId, user: { profilePublic: true, isBanned: false } }
|
||||
: { user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const suggestions = await this.prisma.suggestion.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit * 2, // Get more since we're combining
|
||||
});
|
||||
|
||||
return suggestions.map((suggestion) => ({
|
||||
id: `suggestion-${suggestion.id}`,
|
||||
type: "SUGGESTION" as ActivityType,
|
||||
user: suggestion.user as ActivityUser,
|
||||
entityType: suggestion.entityType,
|
||||
suggestionTitle: suggestion.title,
|
||||
status: suggestion.status,
|
||||
createdAt: suggestion.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get like activities.
|
||||
*/
|
||||
private async getLikeActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? { userId, user: { profilePublic: true, isBanned: false } }
|
||||
: { user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const likes = await this.prisma.like.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit * 2,
|
||||
});
|
||||
|
||||
// For each like, fetch the entity title
|
||||
const likesWithTitles = await Promise.all(
|
||||
likes.map(async (like) => {
|
||||
const entityTitle = await this.getEntityTitle(
|
||||
like.entityType,
|
||||
like.entityId
|
||||
);
|
||||
return {
|
||||
id: `like-${like.id}`,
|
||||
type: "LIKE" as ActivityType,
|
||||
user: like.user as ActivityUser,
|
||||
entityType: like.entityType,
|
||||
entityId: like.entityId,
|
||||
entityTitle,
|
||||
createdAt: like.createdAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return likesWithTitles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comment activities.
|
||||
*/
|
||||
private async getCommentActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? { userId, user: { profilePublic: true, isBanned: false } }
|
||||
: { user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const comments = await this.prisma.comment.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
game: { select: { id: true, title: true } },
|
||||
book: { select: { id: true, title: true } },
|
||||
music: { select: { id: true, title: true } },
|
||||
art: { select: { id: true, title: true } },
|
||||
show: { select: { id: true, title: true } },
|
||||
manga: { select: { id: true, title: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit * 2,
|
||||
});
|
||||
|
||||
return comments.map((comment) => {
|
||||
let entityType = "";
|
||||
let entityId = "";
|
||||
let entityTitle = "";
|
||||
|
||||
if (comment.game) {
|
||||
entityType = "game";
|
||||
entityId = comment.game.id;
|
||||
entityTitle = comment.game.title;
|
||||
} else if (comment.book) {
|
||||
entityType = "book";
|
||||
entityId = comment.book.id;
|
||||
entityTitle = comment.book.title;
|
||||
} else if (comment.music) {
|
||||
entityType = "music";
|
||||
entityId = comment.music.id;
|
||||
entityTitle = comment.music.title;
|
||||
} else if (comment.art) {
|
||||
entityType = "art";
|
||||
entityId = comment.art.id;
|
||||
entityTitle = comment.art.title;
|
||||
} else if (comment.show) {
|
||||
entityType = "show";
|
||||
entityId = comment.show.id;
|
||||
entityTitle = comment.show.title;
|
||||
} else if (comment.manga) {
|
||||
entityType = "manga";
|
||||
entityId = comment.manga.id;
|
||||
entityTitle = comment.manga.title;
|
||||
}
|
||||
|
||||
// Get first 100 characters of comment
|
||||
const commentPreview =
|
||||
comment.content.length > 100
|
||||
? `${comment.content.slice(0, 100)}...`
|
||||
: comment.content;
|
||||
|
||||
return {
|
||||
id: `comment-${comment.id}`,
|
||||
type: "COMMENT" as ActivityType,
|
||||
user: comment.user as ActivityUser,
|
||||
entityType,
|
||||
entityId,
|
||||
entityTitle,
|
||||
commentPreview,
|
||||
createdAt: comment.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get achievement activities.
|
||||
*/
|
||||
private async getAchievementActivities(
|
||||
limit: number,
|
||||
offset: number,
|
||||
userId?: string
|
||||
) {
|
||||
const where = userId
|
||||
? {
|
||||
userId,
|
||||
earned: true,
|
||||
user: { profilePublic: true, isBanned: false },
|
||||
}
|
||||
: { earned: true, user: { profilePublic: true, isBanned: false } };
|
||||
|
||||
const userAchievements = await this.prisma.userAchievement.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
slug: true,
|
||||
avatar: true,
|
||||
primaryBadge: true,
|
||||
isVip: true,
|
||||
isMod: true,
|
||||
isStaff: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { earnedAt: "desc" },
|
||||
take: limit * 2,
|
||||
});
|
||||
|
||||
return userAchievements
|
||||
.filter((ua) => ua.earnedAt) // Only show earned achievements
|
||||
.map((ua) => {
|
||||
const achievement = ACHIEVEMENTS[ua.achievementKey];
|
||||
return {
|
||||
id: `achievement-${ua.id}`,
|
||||
type: "ACHIEVEMENT" as ActivityType,
|
||||
user: ua.user as ActivityUser,
|
||||
achievementKey: ua.achievementKey,
|
||||
achievementName: achievement.name,
|
||||
achievementIcon: achievement.icon,
|
||||
achievementPoints: achievement.points,
|
||||
createdAt: ua.earnedAt!,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get entity title by type and ID.
|
||||
*/
|
||||
private async getEntityTitle(
|
||||
entityType: string,
|
||||
entityId: string
|
||||
): Promise<string> {
|
||||
switch (entityType) {
|
||||
case "game": {
|
||||
const game = await this.prisma.game.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return game?.title || "Unknown Game";
|
||||
}
|
||||
case "book": {
|
||||
const book = await this.prisma.book.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return book?.title || "Unknown Book";
|
||||
}
|
||||
case "music": {
|
||||
const music = await this.prisma.music.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return music?.title || "Unknown Music";
|
||||
}
|
||||
case "art": {
|
||||
const art = await this.prisma.art.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return art?.title || "Unknown Art";
|
||||
}
|
||||
case "show": {
|
||||
const show = await this.prisma.show.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return show?.title || "Unknown Show";
|
||||
}
|
||||
case "manga": {
|
||||
const manga = await this.prisma.manga.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { title: true },
|
||||
});
|
||||
return manga?.title || "Unknown Manga";
|
||||
}
|
||||
default:
|
||||
return "Unknown Item";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user