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

@@ -94,6 +94,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_referral_code_idx
CREATE INDEX IF NOT EXISTS users_referred_by_user_id_idx
ON users(referred_by_user_id);
CREATE TABLE IF NOT EXISTS user_follows (
follower_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
followed_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (follower_user_id, followed_user_id),
CHECK (follower_user_id <> followed_user_id)
);
CREATE INDEX IF NOT EXISTS user_follows_followed_created_idx
ON user_follows(followed_user_id, created_at DESC, follower_user_id);
CREATE TABLE IF NOT EXISTS environments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
@@ -290,6 +301,7 @@ VALUES
('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true)
@@ -381,6 +393,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.delete-any',
'life.reactions.set',
'life.ratings.set',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete',
'discussions.comments.delete-any'
@@ -449,6 +462,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.delete',
'life.reactions.set',
'life.ratings.set',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete'
])
@@ -496,6 +510,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.delete',
'life.reactions.set',
'life.ratings.set',
'users.follow',
'discussions.comments.create',
'discussions.comments.delete'
])
@@ -514,6 +529,13 @@ JOIN permissions p ON p.key = 'life.ratings.set'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'users.follow'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
WITH first_owner_user AS (
SELECT u.id
FROM users u
@@ -1231,10 +1253,12 @@ CREATE TABLE IF NOT EXISTS notifications (
'life_comment_reply',
'discussion_comment_reply',
'life_post_reaction',
'user_follow',
'moderation_result'
)
),
life_post_id integer REFERENCES life_posts(id) ON DELETE CASCADE,
profile_user_id integer REFERENCES users(id) ON DELETE CASCADE,
life_comment_id integer REFERENCES life_post_comments(id) ON DELETE CASCADE,
parent_life_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL,
discussion_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
@@ -1274,6 +1298,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_post_reaction_unique_idx
ON notifications(recipient_user_id, actor_user_id, life_post_id)
WHERE type = 'life_post_reaction' AND actor_user_id IS NOT NULL AND life_post_id IS NOT NULL;
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX IF NOT EXISTS notifications_user_follow_unique_idx
ON notifications(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;
CREATE TABLE IF NOT EXISTS notification_ws_tickets (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -1289,6 +1320,24 @@ CREATE INDEX IF NOT EXISTS notification_ws_tickets_user_idx
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS moderation_reason text;
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE notifications
DROP CONSTRAINT IF EXISTS notifications_type_check;
ALTER TABLE notifications
ADD CONSTRAINT notifications_type_check CHECK (
type IN (
'life_post_comment',
'life_comment_reply',
'discussion_comment_reply',
'life_post_reaction',
'user_follow',
'moderation_result'
)
);
ALTER TABLE life_tags
ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false;

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,

View File

@@ -16,7 +16,7 @@ import {
requestAiModerationReview,
type AiModerationStatus
} from './aiModeration.ts';
import { createLifePostReactionNotification } from './notifications.ts';
import { createLifePostReactionNotification, createUserFollowNotification } from './notifications.ts';
type QueryValue = string | string[] | undefined;
@@ -353,6 +353,7 @@ type LifePostSort = 'latest' | 'oldest' | 'top-rated';
type LifePostFilters = {
authorId?: number;
followedByUserId?: number;
};
type LifePostsPage = {
@@ -395,9 +396,19 @@ type PublicProfileContribution = {
lastContributedAt: Date | null;
};
type PublicProfileViewerRelation = 'none' | 'following' | 'followed-by' | 'friends';
type PublicProfileSocial = {
followerCount: number;
followingCount: number;
friendCount: number;
viewerRelation: PublicProfileViewerRelation;
};
type PublicUserProfile = {
user: PublicProfileUser;
stats: PublicProfileStats;
social: PublicProfileSocial;
contributions: PublicProfileContribution[];
};
@@ -3535,6 +3546,18 @@ async function listLifePostsWithFilters(
conditions.push(`lp.created_by_user_id = $${params.length}`);
}
if (filters.followedByUserId !== undefined) {
params.push(filters.followedByUserId);
conditions.push(`
EXISTS (
SELECT 1
FROM user_follows uf
WHERE uf.follower_user_id = $${params.length}
AND uf.followed_user_id = lp.created_by_user_id
)
`);
}
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, canViewAll);
addModerationLanguageCondition(conditions, params, 'lp', languageCode);
@@ -3616,6 +3639,15 @@ export async function listLifePosts(
return listLifePostsWithFilters(paramsQuery, userId, locale, {}, canViewAll);
}
export async function listFollowingLifePosts(
userId: number,
paramsQuery: QueryParams = {},
locale = defaultLocale,
canViewAll = false
): Promise<LifePostsPage> {
return listLifePostsWithFilters(paramsQuery, userId, locale, { followedByUserId: userId }, canViewAll);
}
async function getPublicProfileUser(userIdValue: number): Promise<PublicProfileUser | null> {
const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid');
return queryOne<PublicProfileUser>(
@@ -3635,7 +3667,68 @@ function publicContributionType(entityType: string): string {
return entityType === 'daily-checklist-items' ? 'daily-checklist' : entityType;
}
export async function getPublicUserProfile(userIdValue: number): Promise<PublicUserProfile | null> {
async function getPublicProfileSocial(userId: number, viewerUserId: number | null): Promise<PublicProfileSocial> {
const social = await queryOne<
Omit<PublicProfileSocial, 'viewerRelation'> & {
viewerFollows: boolean;
targetFollowsViewer: boolean;
}
>(
`
SELECT
COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE followed_user_id = $1), 0) AS "followerCount",
COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE follower_user_id = $1), 0) AS "followingCount",
COALESCE((
SELECT COUNT(*)::integer
FROM user_follows outgoing
WHERE outgoing.follower_user_id = $1
AND EXISTS (
SELECT 1
FROM user_follows incoming
WHERE incoming.follower_user_id = outgoing.followed_user_id
AND incoming.followed_user_id = $1
)
), 0) AS "friendCount",
CASE
WHEN $2::integer IS NULL OR $2::integer = $1 THEN false
ELSE EXISTS (
SELECT 1
FROM user_follows
WHERE follower_user_id = $2::integer
AND followed_user_id = $1
)
END AS "viewerFollows",
CASE
WHEN $2::integer IS NULL OR $2::integer = $1 THEN false
ELSE EXISTS (
SELECT 1
FROM user_follows
WHERE follower_user_id = $1
AND followed_user_id = $2::integer
)
END AS "targetFollowsViewer"
`,
[userId, viewerUserId]
);
const viewerRelation =
social?.viewerFollows && social.targetFollowsViewer
? 'friends'
: social?.viewerFollows
? 'following'
: social?.targetFollowsViewer
? 'followed-by'
: 'none';
return {
followerCount: social?.followerCount ?? 0,
followingCount: social?.followingCount ?? 0,
friendCount: social?.friendCount ?? 0,
viewerRelation
};
}
export async function getPublicUserProfile(userIdValue: number, viewerUserId: number | null = null): Promise<PublicUserProfile | null> {
const user = await getPublicProfileUser(userIdValue);
if (!user) {
return null;
@@ -3702,6 +3795,8 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
[user.id]
);
const social = await getPublicProfileSocial(user.id, viewerUserId);
return {
user,
stats: stats ?? {
@@ -3715,6 +3810,7 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
lifeReactions: 0,
discussionComments: 0
},
social,
contributions: contributions.map((item) => ({
...item,
contentType: publicContributionType(item.contentType)
@@ -3722,6 +3818,57 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
};
}
export async function followUser(followerUserId: number, followedUserIdValue: number): Promise<PublicUserProfile | null> {
const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid');
if (followerUserId === followedUserId) {
throw validationError('server.validation.cannotFollowSelf');
}
const followedUser = await getPublicProfileUser(followedUserId);
if (!followedUser) {
return null;
}
const inserted = await queryOne<{ inserted: boolean }>(
`
INSERT INTO user_follows (follower_user_id, followed_user_id)
VALUES ($1, $2)
ON CONFLICT (follower_user_id, followed_user_id) DO NOTHING
RETURNING true AS inserted
`,
[followerUserId, followedUser.id]
);
if (inserted?.inserted === true) {
await createUserFollowNotification(followerUserId, followedUser.id);
}
return getPublicUserProfile(followedUser.id, followerUserId);
}
export async function unfollowUser(followerUserId: number, followedUserIdValue: number): Promise<PublicUserProfile | null> {
const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid');
if (followerUserId === followedUserId) {
throw validationError('server.validation.cannotFollowSelf');
}
const followedUser = await getPublicProfileUser(followedUserId);
if (!followedUser) {
return null;
}
await query(
`
DELETE FROM user_follows
WHERE follower_user_id = $1
AND followed_user_id = $2
`,
[followerUserId, followedUser.id]
);
return getPublicUserProfile(followedUser.id, followerUserId);
}
export async function listUserLifePosts(
userIdValue: number,
paramsQuery: QueryParams = {},

View File

@@ -64,6 +64,7 @@ import {
exportAdminData,
fetchPokemonData,
fetchPokemonImageOptions,
followUser,
getAdminDataToolsSummary,
getAncientArtifact,
getHabitat,
@@ -81,6 +82,7 @@ import {
listConfig,
listDailyChecklistItems,
listHabitats,
listFollowingLifePosts,
listItems,
listLifeComments,
listLanguages,
@@ -115,6 +117,7 @@ import {
updateLifePost,
updatePokemon,
updateRecipe,
unfollowUser,
wipeAdminData
} from './queries.ts';
import {
@@ -1184,7 +1187,30 @@ app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(reque
app.get('/api/users/:id/profile', async (request, reply) => {
const { id } = request.params as { id: string };
const profile = await getPublicUserProfile(Number(id));
const user = await optionalUser(request);
const profile = await getPublicUserProfile(Number(id), user?.id ?? null);
return profile ? { profile } : notFound(reply, request);
});
app.put('/api/users/:id/follow', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'users.follow', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const profile = await followUser(user.id, Number(id));
return profile ? { profile } : notFound(reply, request);
});
app.delete('/api/users/:id/follow', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'users.follow', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const profile = await unfollowUser(user.id, Number(id));
return profile ? { profile } : notFound(reply, request);
});
@@ -1239,6 +1265,20 @@ app.get('/api/life-posts', async (request) => {
);
});
app.get('/api/life-posts/following', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const canViewAll = userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any');
return listFollowingLifePosts(
user.id,
request.query as Record<string, string | string[] | undefined>,
requestLocale(request),
canViewAll
);
});
app.get('/api/life-posts/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const user = await optionalUser(request);