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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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