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:
@@ -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,
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user