feat(users): implement user following system and following feed

Add follow/unfollow actions and social stats to user profiles
Introduce Following feed scope in Life view
Add notifications for new followers
This commit is contained in:
2026-05-04 15:49:57 +08:00
parent 016364a8b8
commit 8cb8190554
11 changed files with 472 additions and 18 deletions

View File

@@ -13,10 +13,11 @@ type NotificationType =
| 'life_comment_reply'
| 'discussion_comment_reply'
| 'life_post_reaction'
| 'user_follow'
| 'moderation_result';
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'profile-user';
type ModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
type NotificationCursor = {
@@ -35,6 +36,7 @@ type NotificationRow = {
actor: NotificationActor | null;
type: NotificationType;
lifePostId: number | null;
profileUserId: number | null;
lifeCommentId: number | null;
parentLifeCommentId: number | null;
discussionCommentId: number | null;
@@ -55,6 +57,7 @@ export type NotificationTarget = {
id: number;
path: string;
lifePostId: number | null;
profileUserId: number | null;
lifeCommentId: number | null;
discussionCommentId: number | null;
entityType: DiscussionEntityType | null;
@@ -147,6 +150,7 @@ function notificationProjection(): string {
n.recipient_user_id AS "recipientUserId",
n.type,
n.life_post_id AS "lifePostId",
n.profile_user_id AS "profileUserId",
n.life_comment_id AS "lifeCommentId",
n.parent_life_comment_id AS "parentLifeCommentId",
n.discussion_comment_id AS "discussionCommentId",
@@ -178,6 +182,9 @@ function discussionEntityPath(entityType: DiscussionEntityType | null, entityId:
}
function notificationTargetType(row: NotificationRow): NotificationTargetType {
if (row.profileUserId !== null) {
return 'profile-user';
}
if (row.discussionCommentId !== null) {
return 'discussion-comment';
}
@@ -188,6 +195,9 @@ function notificationTargetType(row: NotificationRow): NotificationTargetType {
}
function notificationPath(row: NotificationRow): string {
if (row.profileUserId !== null) {
return `/profile/${row.profileUserId}`;
}
if (row.lifePostId !== null) {
return `/life/${row.lifePostId}`;
}
@@ -198,7 +208,9 @@ function notificationPath(row: NotificationRow): string {
function toNotificationItem(row: NotificationRow): NotificationItem {
const targetType = notificationTargetType(row);
const targetId =
targetType === 'discussion-comment'
targetType === 'profile-user'
? row.profileUserId
: targetType === 'discussion-comment'
? row.discussionCommentId
: targetType === 'life-comment'
? row.lifeCommentId
@@ -213,6 +225,7 @@ function toNotificationItem(row: NotificationRow): NotificationItem {
id: targetId ?? 0,
path: notificationPath(row),
lifePostId: row.lifePostId,
profileUserId: row.profileUserId,
lifeCommentId: row.lifeCommentId,
discussionCommentId: row.discussionCommentId,
entityType: row.entityType,
@@ -458,6 +471,43 @@ export async function createLifePostReactionNotification(postId: number, actorUs
await publishInsertedNotification(row);
}
export async function createUserFollowNotification(actorUserId: number, followedUserId: number): Promise<void> {
const row = await queryOne<{ id: number; recipientUserId: number }>(
`
INSERT INTO notifications (
recipient_user_id,
actor_user_id,
type,
profile_user_id,
read_at,
created_at,
updated_at
)
SELECT
followed_user.id,
actor_user.id,
'user_follow',
actor_user.id,
NULL,
now(),
now()
FROM users actor_user
JOIN users followed_user ON followed_user.id = $2
WHERE actor_user.id = $1
AND actor_user.id <> followed_user.id
ON CONFLICT (recipient_user_id, actor_user_id, profile_user_id)
WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL
DO UPDATE SET read_at = NULL,
created_at = now(),
updated_at = now()
RETURNING id, recipient_user_id AS "recipientUserId"
`,
[actorUserId, followedUserId]
);
await publishInsertedNotification(row);
}
export async function createApprovedCommentNotification(target: {
type: ModerationTargetType;
id: number;
@@ -613,6 +663,7 @@ export async function createModerationResultNotification(
id: row.lifePostId,
path: `/life/${row.lifePostId}`,
lifePostId: row.lifePostId,
profileUserId: null,
lifeCommentId: null,
discussionCommentId: null,
entityType: null,
@@ -688,6 +739,7 @@ export async function createModerationResultNotification(
id: row.lifeCommentId,
path: `/life/${row.lifePostId}`,
lifePostId: row.lifePostId,
profileUserId: null,
lifeCommentId: row.lifeCommentId,
discussionCommentId: null,
entityType: null,
@@ -764,6 +816,7 @@ export async function createModerationResultNotification(
id: row.discussionCommentId,
path: discussionEntityPath(row.entityType, row.entityId) ?? '/',
lifePostId: null,
profileUserId: null,
lifeCommentId: null,
discussionCommentId: row.discussionCommentId,
entityType: row.entityType,