diff --git a/messages/en-US.json b/messages/en-US.json index a1888e2..435fab1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -354,6 +354,7 @@ "displayName": "Display Name", "notSet": "Not Set", "memberSince": "Member Since", + "joined": "Joined", "logout": "Logout", "folders": { "title": "Folders", @@ -364,5 +365,14 @@ "actions": "Actions", "view": "View" } + }, + "follow": { + "follow": "Follow", + "following": "Following", + "followers": "Followers", + "followersOf": "{username}'s Followers", + "followingOf": "{username}'s Following", + "noFollowers": "No followers yet", + "noFollowing": "Not following anyone yet" } } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index fed8647..60e6d12 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -354,6 +354,7 @@ "displayName": "显示名称", "notSet": "未设置", "memberSince": "注册时间", + "joined": "加入于", "logout": "登出", "folders": { "title": "文件夹", @@ -364,5 +365,14 @@ "actions": "操作", "view": "查看" } + }, + "follow": { + "follow": "关注", + "following": "已关注", + "followers": "粉丝", + "followersOf": "{username} 的粉丝", + "followingOf": "{username} 的关注", + "noFollowers": "还没有粉丝", + "noFollowing": "还没有关注任何人" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c865f85..d0282aa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,12 +17,15 @@ model User { updatedAt DateTime @updatedAt displayUsername String? username String @unique + bio String? accounts Account[] dictionaryLookUps DictionaryLookUp[] folders Folder[] folderFavorites FolderFavorite[] sessions Session[] translationHistories TranslationHistory[] + followers Follow[] @relation("UserFollowers") + following Follow[] @relation("UserFollowing") @@map("user") } @@ -198,3 +201,18 @@ model TranslationHistory { @@index([translatedText, sourceLanguage, targetLanguage]) @@map("translation_history") } + +model Follow { + id String @id @default(cuid()) + followerId String @map("follower_id") + followingId String @map("following_id") + createdAt DateTime @default(now()) @map("created_at") + + follower User @relation("UserFollowers", fields: [followerId], references: [id], onDelete: Cascade) + following User @relation("UserFollowing", fields: [followingId], references: [id], onDelete: Cascade) + + @@unique([followerId, followingId]) + @@index([followerId]) + @@index([followingId]) + @@map("follows") +} diff --git a/src/app/(auth)/users/[username]/followers/page.tsx b/src/app/(auth)/users/[username]/followers/page.tsx new file mode 100644 index 0000000..4517574 --- /dev/null +++ b/src/app/(auth)/users/[username]/followers/page.tsx @@ -0,0 +1,44 @@ +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { PageLayout } from "@/components/ui/PageLayout"; +import { UserList } from "@/components/follow/UserList"; +import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action"; +import { actionGetFollowers } from "@/modules/follow/follow-action"; + +interface FollowersPageProps { + params: Promise<{ username: string }>; +} + +export default async function FollowersPage({ params }: FollowersPageProps) { + const { username } = await params; + const t = await getTranslations("follow"); + + const userResult = await actionGetUserProfileByUsername({ username }); + + if (!userResult.success || !userResult.data) { + notFound(); + } + + const user = userResult.data; + + const followersResult = await actionGetFollowers({ + userId: user.id, + page: 1, + limit: 50, + }); + + const followers = followersResult.success && followersResult.data + ? followersResult.data.followers.map((f) => f.user) + : []; + + return ( + +
+

+ {t("followersOf", { username: user.displayUsername || user.username || "User" })} +

+ +
+
+ ); +} diff --git a/src/app/(auth)/users/[username]/following/page.tsx b/src/app/(auth)/users/[username]/following/page.tsx new file mode 100644 index 0000000..47da8ac --- /dev/null +++ b/src/app/(auth)/users/[username]/following/page.tsx @@ -0,0 +1,44 @@ +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { PageLayout } from "@/components/ui/PageLayout"; +import { UserList } from "@/components/follow/UserList"; +import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action"; +import { actionGetFollowing } from "@/modules/follow/follow-action"; + +interface FollowingPageProps { + params: Promise<{ username: string }>; +} + +export default async function FollowingPage({ params }: FollowingPageProps) { + const { username } = await params; + const t = await getTranslations("follow"); + + const userResult = await actionGetUserProfileByUsername({ username }); + + if (!userResult.success || !userResult.data) { + notFound(); + } + + const user = userResult.data; + + const followingResult = await actionGetFollowing({ + userId: user.id, + page: 1, + limit: 50, + }); + + const following = followingResult.success && followingResult.data + ? followingResult.data.following.map((f) => f.user) + : []; + + return ( + +
+

+ {t("followingOf", { username: user.displayUsername || user.username || "User" })} +

+ +
+
+ ); +} diff --git a/src/app/(auth)/users/[username]/page.tsx b/src/app/(auth)/users/[username]/page.tsx index 6891cae..2eb932d 100644 --- a/src/app/(auth)/users/[username]/page.tsx +++ b/src/app/(auth)/users/[username]/page.tsx @@ -1,14 +1,15 @@ import Image from "next/image"; import Link from "next/link"; import { PageLayout } from "@/components/ui/PageLayout"; -import { LightButton, LinkButton } from "@/design-system/base/button"; +import { LinkButton } from "@/design-system/base/button"; import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action"; import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository"; +import { actionGetFollowStatus } from "@/modules/follow/follow-action"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { auth } from "@/auth"; import { headers } from "next/headers"; -// import { LogoutButton } from "./LogoutButton"; +import { FollowStats } from "@/components/follow/FollowStats"; interface UserPageProps { params: Promise<{ username: string; }>; @@ -18,10 +19,8 @@ export default async function UserPage({ params }: UserPageProps) { const { username } = await params; const t = await getTranslations("user_profile"); - // Get current session const session = await auth.api.getSession({ headers: await headers() }); - // Get user profile const result = await actionGetUserProfileByUsername({ username }); if (!result.success || !result.data) { @@ -30,150 +29,163 @@ export default async function UserPage({ params }: UserPageProps) { const user = result.data; - // Get user's folders - const folders = await repoGetFoldersWithTotalPairsByUserId(user.id); + const [folders, followStatus] = await Promise.all([ + repoGetFoldersWithTotalPairsByUserId(user.id), + actionGetFollowStatus({ targetUserId: user.id }), + ]); - // Check if viewing own profile const isOwnProfile = session?.user?.username === username || session?.user?.email === username; + const followersCount = followStatus.success && followStatus.data ? followStatus.data.followersCount : 0; + const followingCount = followStatus.success && followStatus.data ? followStatus.data.followingCount : 0; + const isFollowing = followStatus.success && followStatus.data ? followStatus.data.isFollowing : false; + return ( - {/* Header */}
-
-
- {isOwnProfile && {t("logout")}} -
-
- {/* Avatar */} - {user.image ? ( -
- {user.displayUsername -
- ) : ( -
- - {(user.displayUsername || user.username || user.email)[0].toUpperCase()} - -
- )} - - {/* User Info */} -
-

- {user.displayUsername || user.username || t("anonymous")} -

- {user.username && ( -

- @{user.username} -

- )} -

- {user.email} -

-
- - Joined: {new Date(user.createdAt).toLocaleDateString()} - - {user.emailVerified && ( - - - - - Verified - - )} -
-
-
+
+
+ {isOwnProfile && {t("logout")}}
- - {/* Account Info */} -
-

{t("accountInfo")}

-
-
-
{t("userId")}
-
{user.id}
+
+ {user.image ? ( +
+ {user.displayUsername
-
-
{t("username")}
-
- {user.username || {t("notSet")}} -
-
-
-
{t("displayName")}
-
- {user.displayUsername || {t("notSet")}} -
-
-
-
{t("memberSince")}
-
- {new Date(user.createdAt).toLocaleDateString()} -
-
-
-
- - {/* Folders Section */} -
-

{t("folders.title")}

- {folders.length === 0 ? ( -

{t("folders.noFolders")}

) : ( -
- - - - - - - - - - - {folders.map((folder) => ( - - - - - - - ))} - -
- {t("folders.folderName")} - - {t("folders.totalPairs")} - - {t("folders.createdAt")} - - {t("folders.actions")} -
-
{folder.name}
-
ID: {folder.id}
-
-
{folder.total}
-
- {new Date(folder.createdAt).toLocaleDateString()} - - - - {t("folders.view")} - - -
+
+ + {(user.displayUsername || user.username || user.email)[0].toUpperCase()} +
)} + +
+

+ {user.displayUsername || user.username || t("anonymous")} +

+ {user.username && ( +

+ @{user.username} +

+ )} + {user.bio && ( +

+ {user.bio} +

+ )} +
+ + {t("joined")}: {new Date(user.createdAt).toLocaleDateString()} + + {user.emailVerified && ( + + + + + {t("verified")} + + )} +
+
+ +
+
+
+ +
+

{t("accountInfo")}

+
+
+
{t("userId")}
+
{user.id}
+
+
+
{t("username")}
+
+ {user.username || {t("notSet")}} +
+
+
+
{t("displayName")}
+
+ {user.displayUsername || {t("notSet")}} +
+
+
+
{t("memberSince")}
+
+ {new Date(user.createdAt).toLocaleDateString()} +
+
+
+
+ +
+

{t("folders.title")}

+ {folders.length === 0 ? ( +

{t("folders.noFolders")}

+ ) : ( +
+ + + + + + + + + + + {folders.map((folder) => ( + + + + + + + ))} + +
+ {t("folders.folderName")} + + {t("folders.totalPairs")} + + {t("folders.createdAt")} + + {t("folders.actions")} +
+
{folder.name}
+
ID: {folder.id}
+
+
{folder.total}
+
+ {new Date(folder.createdAt).toLocaleDateString()} + + + + {t("folders.view")} + + +
+
+ )} +
); } diff --git a/src/components/follow/FollowButton.tsx b/src/components/follow/FollowButton.tsx new file mode 100644 index 0000000..19b7cb2 --- /dev/null +++ b/src/components/follow/FollowButton.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { PrimaryButton, LightButton } from "@/design-system/base/button"; +import { actionToggleFollow } from "@/modules/follow/follow-action"; +import { toast } from "sonner"; + +interface FollowButtonProps { + targetUserId: string; + initialIsFollowing: boolean; + onFollowChange?: (isFollowing: boolean, followersCount: number) => void; +} + +export function FollowButton({ + targetUserId, + initialIsFollowing, + onFollowChange, +}: FollowButtonProps) { + const [isFollowing, setIsFollowing] = useState(initialIsFollowing); + const [isPending, startTransition] = useTransition(); + + const handleToggleFollow = () => { + startTransition(async () => { + const result = await actionToggleFollow({ targetUserId }); + if (result.success && result.data) { + setIsFollowing(result.data.isFollowing); + onFollowChange?.(result.data.isFollowing, result.data.followersCount); + } else { + toast.error(result.message || "Failed to update follow status"); + } + }); + }; + + if (isFollowing) { + return ( + + {isPending ? "..." : "Following"} + + ); + } + + return ( + + {isPending ? "..." : "Follow"} + + ); +} diff --git a/src/components/follow/FollowStats.tsx b/src/components/follow/FollowStats.tsx new file mode 100644 index 0000000..b882d95 --- /dev/null +++ b/src/components/follow/FollowStats.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; +import { FollowButton } from "./FollowButton"; + +interface FollowStatsProps { + userId: string; + initialFollowersCount: number; + initialFollowingCount: number; + initialIsFollowing: boolean; + currentUserId?: string; + isOwnProfile: boolean; + username: string; +} + +export function FollowStats({ + userId, + initialFollowersCount, + initialFollowingCount, + initialIsFollowing, + currentUserId, + isOwnProfile, + username, +}: FollowStatsProps) { + const [followersCount, setFollowersCount] = useState(initialFollowersCount); + + const handleFollowChange = (isFollowing: boolean, count: number) => { + setFollowersCount(count); + }; + + return ( +
+ + {followersCount} followers + + + {initialFollowingCount} following + + {currentUserId && !isOwnProfile && ( + + )} +
+ ); +} diff --git a/src/components/follow/UserList.tsx b/src/components/follow/UserList.tsx new file mode 100644 index 0000000..2bbf315 --- /dev/null +++ b/src/components/follow/UserList.tsx @@ -0,0 +1,68 @@ +import Image from "next/image"; +import Link from "next/link"; + +interface UserItem { + id: string; + username: string | null; + displayUsername: string | null; + image: string | null; + bio: string | null; +} + +interface UserListProps { + users: UserItem[]; + emptyMessage: string; +} + +export function UserList({ users, emptyMessage }: UserListProps) { + if (users.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + return ( +
+ {users.map((user) => ( + + {user.image ? ( +
+ {user.displayUsername +
+ ) : ( +
+ + {(user.displayUsername || user.username || "U")[0].toUpperCase()} + +
+ )} +
+
+ {user.displayUsername || user.username || "Anonymous"} +
+ {user.username && ( +
@{user.username}
+ )} + {user.bio && ( +
+ {user.bio} +
+ )} +
+ + ))} +
+ ); +} diff --git a/src/modules/auth/auth-action-dto.ts b/src/modules/auth/auth-action-dto.ts index 81b16fd..26dd025 100644 --- a/src/modules/auth/auth-action-dto.ts +++ b/src/modules/auth/auth-action-dto.ts @@ -56,6 +56,7 @@ export type ActionOutputUserProfile = { username: string | null; displayUsername: string | null; image: string | null; + bio: string | null; createdAt: Date; updatedAt: Date; }; diff --git a/src/modules/auth/auth-repository-dto.ts b/src/modules/auth/auth-repository-dto.ts index c413e5e..37bdb99 100644 --- a/src/modules/auth/auth-repository-dto.ts +++ b/src/modules/auth/auth-repository-dto.ts @@ -8,6 +8,7 @@ export type RepoOutputUserProfile = { username: string | null; displayUsername: string | null; image: string | null; + bio: string | null; createdAt: Date; updatedAt: Date; } | null; diff --git a/src/modules/auth/auth-repository.ts b/src/modules/auth/auth-repository.ts index cd644c0..3f9031e 100644 --- a/src/modules/auth/auth-repository.ts +++ b/src/modules/auth/auth-repository.ts @@ -6,9 +6,6 @@ import { RepoOutputUserProfile } from "./auth-repository-dto"; -/** - * Find user by username - */ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise { const user = await prisma.user.findUnique({ where: { username: dto.username }, @@ -19,6 +16,7 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): username: true, displayUsername: true, image: true, + bio: true, createdAt: true, updatedAt: true, } @@ -27,9 +25,6 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): return user; } -/** - * Find user by ID - */ export async function repoFindUserById(dto: RepoInputFindUserById): Promise { const user = await prisma.user.findUnique({ where: { id: dto.id }, @@ -40,6 +35,7 @@ export async function repoFindUserById(dto: RepoInputFindUserById): Promise { const user = await prisma.user.findUnique({ where: { email: dto.email }, @@ -61,6 +54,7 @@ export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promis username: true, displayUsername: true, image: true, + bio: true, createdAt: true, updatedAt: true, } diff --git a/src/modules/auth/auth-service-dto.ts b/src/modules/auth/auth-service-dto.ts index f13f5ba..6159b94 100644 --- a/src/modules/auth/auth-service-dto.ts +++ b/src/modules/auth/auth-service-dto.ts @@ -34,6 +34,7 @@ export type ServiceOutputUserProfile = { username: string | null; displayUsername: string | null; image: string | null; + bio: string | null; createdAt: Date; updatedAt: Date; } | null; diff --git a/src/modules/follow/follow-action-dto.ts b/src/modules/follow/follow-action-dto.ts new file mode 100644 index 0000000..4937620 --- /dev/null +++ b/src/modules/follow/follow-action-dto.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +const schemaActionInputToggleFollow = z.object({ + targetUserId: z.string().min(1), +}); + +const schemaActionInputGetFollowers = z.object({ + userId: z.string().min(1), + page: z.number().int().min(1).optional().default(1), + limit: z.number().int().min(1).max(100).optional().default(20), +}); + +const schemaActionInputGetFollowing = z.object({ + userId: z.string().min(1), + page: z.number().int().min(1).optional().default(1), + limit: z.number().int().min(1).max(100).optional().default(20), +}); + +const schemaActionInputGetFollowStatus = z.object({ + targetUserId: z.string().min(1), +}); + +export type ActionInputToggleFollow = z.infer; +export type ActionInputGetFollowers = z.infer; +export type ActionInputGetFollowing = z.infer; +export type ActionInputGetFollowStatus = z.infer; + +export { + schemaActionInputGetFollowers, + schemaActionInputGetFollowing, + schemaActionInputGetFollowStatus, + schemaActionInputToggleFollow, +}; diff --git a/src/modules/follow/follow-action.ts b/src/modules/follow/follow-action.ts new file mode 100644 index 0000000..f651301 --- /dev/null +++ b/src/modules/follow/follow-action.ts @@ -0,0 +1,84 @@ +"use server"; + +import { auth } from "@/auth"; +import { headers } from "next/headers"; +import { validate } from "@/utils/validate"; +import { serviceGetFollowers, serviceGetFollowing, serviceGetFollowStatus, serviceToggleFollow } from "./follow-service"; +import { + schemaActionInputGetFollowers, + schemaActionInputGetFollowing, + schemaActionInputGetFollowStatus, + schemaActionInputToggleFollow, +} from "./follow-action-dto"; +import { createLogger } from "@/lib/logger"; + +const log = createLogger("follow-action"); + +export async function actionToggleFollow(input: unknown) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return { success: false, message: "Unauthorized" }; + } + + try { + const dto = validate(input, schemaActionInputToggleFollow); + const result = await serviceToggleFollow({ + currentUserId: session.user.id, + targetUserId: dto.targetUserId, + }); + return { success: true, data: result }; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to toggle follow"; + log.error("Failed to toggle follow", { error }); + return { success: false, message }; + } +} + +export async function actionGetFollowStatus(input: unknown) { + const session = await auth.api.getSession({ headers: await headers() }); + + try { + const dto = validate(input, schemaActionInputGetFollowStatus); + const result = await serviceGetFollowStatus({ + currentUserId: session?.user?.id || "", + targetUserId: dto.targetUserId, + }); + return { success: true, data: result }; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to get follow status"; + log.error("Failed to get follow status", { error }); + return { success: false, message }; + } +} + +export async function actionGetFollowers(input: unknown) { + try { + const dto = validate(input, schemaActionInputGetFollowers); + const result = await serviceGetFollowers({ + userId: dto.userId, + page: dto.page, + limit: dto.limit, + }); + return { success: true, data: result }; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to get followers"; + log.error("Failed to get followers", { error }); + return { success: false, message }; + } +} + +export async function actionGetFollowing(input: unknown) { + try { + const dto = validate(input, schemaActionInputGetFollowing); + const result = await serviceGetFollowing({ + userId: dto.userId, + page: dto.page, + limit: dto.limit, + }); + return { success: true, data: result }; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to get following"; + log.error("Failed to get following", { error }); + return { success: false, message }; + } +} diff --git a/src/modules/follow/follow-repository-dto.ts b/src/modules/follow/follow-repository-dto.ts new file mode 100644 index 0000000..aec62b0 --- /dev/null +++ b/src/modules/follow/follow-repository-dto.ts @@ -0,0 +1,51 @@ +export type RepoInputCreateFollow = { + followerId: string; + followingId: string; +}; + +export type RepoInputDeleteFollow = { + followerId: string; + followingId: string; +}; + +export type RepoInputCheckFollow = { + followerId: string; + followingId: string; +}; + +export type RepoInputGetFollowers = { + userId: string; + page?: number; + limit?: number; +}; + +export type RepoInputGetFollowing = { + userId: string; + page?: number; + limit?: number; +}; + +export type RepoOutputFollowUser = { + id: string; + followerId: string; + followingId: string; + createdAt: Date; +}; + +export type RepoOutputFollowWithUser = { + id: string; + followerId: string; + followingId: string; + createdAt: Date; + user: { + id: string; + username: string | null; + displayUsername: string | null; + image: string | null; + bio: string | null; + }; +}; + +export type RepoOutputFollowerCount = number; +export type RepoOutputFollowingCount = number; +export type RepoOutputIsFollowing = boolean; diff --git a/src/modules/follow/follow-repository.ts b/src/modules/follow/follow-repository.ts new file mode 100644 index 0000000..41589b4 --- /dev/null +++ b/src/modules/follow/follow-repository.ts @@ -0,0 +1,142 @@ +import { prisma } from "@/lib/db"; +import { createLogger } from "@/lib/logger"; +import type { + RepoInputCheckFollow, + RepoInputCreateFollow, + RepoInputDeleteFollow, + RepoInputGetFollowers, + RepoInputGetFollowing, + RepoOutputFollowerCount, + RepoOutputFollowUser, + RepoOutputFollowWithUser, + RepoOutputFollowingCount, + RepoOutputIsFollowing, +} from "./follow-repository-dto"; + +const log = createLogger("follow-repository"); + +export async function repoCreateFollow( + dto: RepoInputCreateFollow +): Promise { + log.debug("Creating follow", { followerId: dto.followerId, followingId: dto.followingId }); + + const follow = await prisma.follow.create({ + data: { + followerId: dto.followerId, + followingId: dto.followingId, + }, + }); + + log.info("Follow created", { followId: follow.id }); + return follow; +} + +export async function repoDeleteFollow( + dto: RepoInputDeleteFollow +): Promise { + log.debug("Deleting follow", { followerId: dto.followerId, followingId: dto.followingId }); + + await prisma.follow.delete({ + where: { + followerId_followingId: { + followerId: dto.followerId, + followingId: dto.followingId, + }, + }, + }); + + log.info("Follow deleted"); +} + +export async function repoCheckFollow( + dto: RepoInputCheckFollow +): Promise { + const follow = await prisma.follow.findUnique({ + where: { + followerId_followingId: { + followerId: dto.followerId, + followingId: dto.followingId, + }, + }, + }); + + return !!follow; +} + +export async function repoGetFollowersCount(userId: string): Promise { + return prisma.follow.count({ + where: { followingId: userId }, + }); +} + +export async function repoGetFollowingCount(userId: string): Promise { + return prisma.follow.count({ + where: { followerId: userId }, + }); +} + +export async function repoGetFollowers( + dto: RepoInputGetFollowers +): Promise { + const { userId, page = 1, limit = 20 } = dto; + const skip = (page - 1) * limit; + + const follows = await prisma.follow.findMany({ + where: { followingId: userId }, + include: { + follower: { + select: { + id: true, + username: true, + displayUsername: true, + image: true, + bio: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }); + + return follows.map((f) => ({ + id: f.id, + followerId: f.followerId, + followingId: f.followingId, + createdAt: f.createdAt, + user: f.follower, + })); +} + +export async function repoGetFollowing( + dto: RepoInputGetFollowing +): Promise { + const { userId, page = 1, limit = 20 } = dto; + const skip = (page - 1) * limit; + + const follows = await prisma.follow.findMany({ + where: { followerId: userId }, + include: { + following: { + select: { + id: true, + username: true, + displayUsername: true, + image: true, + bio: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }); + + return follows.map((f) => ({ + id: f.id, + followerId: f.followerId, + followingId: f.followingId, + createdAt: f.createdAt, + user: f.following, + })); +} diff --git a/src/modules/follow/follow-service-dto.ts b/src/modules/follow/follow-service-dto.ts new file mode 100644 index 0000000..cb54976 --- /dev/null +++ b/src/modules/follow/follow-service-dto.ts @@ -0,0 +1,51 @@ +export type ServiceInputToggleFollow = { + currentUserId: string; + targetUserId: string; +}; + +export type ServiceInputGetFollowers = { + userId: string; + page?: number; + limit?: number; +}; + +export type ServiceInputGetFollowing = { + userId: string; + page?: number; + limit?: number; +}; + +export type ServiceInputCheckFollow = { + currentUserId: string; + targetUserId: string; +}; + +export type ServiceOutputFollowUser = { + id: string; + followerId: string; + followingId: string; + createdAt: Date; +}; + +export type ServiceOutputFollowWithUser = { + id: string; + createdAt: Date; + user: { + id: string; + username: string | null; + displayUsername: string | null; + image: string | null; + bio: string | null; + }; +}; + +export type ServiceOutputFollowStatus = { + isFollowing: boolean; + followersCount: number; + followingCount: number; +}; + +export type ServiceOutputToggleFollow = { + isFollowing: boolean; + followersCount: number; +}; diff --git a/src/modules/follow/follow-service.ts b/src/modules/follow/follow-service.ts new file mode 100644 index 0000000..da51d36 --- /dev/null +++ b/src/modules/follow/follow-service.ts @@ -0,0 +1,103 @@ +import { createLogger } from "@/lib/logger"; +import { + repoCheckFollow, + repoCreateFollow, + repoDeleteFollow, + repoGetFollowers, + repoGetFollowersCount, + repoGetFollowing, + repoGetFollowingCount, +} from "./follow-repository"; +import type { + ServiceInputCheckFollow, + ServiceInputGetFollowers, + ServiceInputGetFollowing, + ServiceInputToggleFollow, + ServiceOutputFollowStatus, + ServiceOutputFollowWithUser, + ServiceOutputToggleFollow, +} from "./follow-service-dto"; + +const log = createLogger("follow-service"); + +export async function serviceToggleFollow( + dto: ServiceInputToggleFollow +): Promise { + const { currentUserId, targetUserId } = dto; + + if (currentUserId === targetUserId) { + throw new Error("Cannot follow yourself"); + } + + const isFollowing = await repoCheckFollow({ + followerId: currentUserId, + followingId: targetUserId, + }); + + if (isFollowing) { + await repoDeleteFollow({ + followerId: currentUserId, + followingId: targetUserId, + }); + log.info("Unfollowed user", { currentUserId, targetUserId }); + } else { + await repoCreateFollow({ + followerId: currentUserId, + followingId: targetUserId, + }); + log.info("Followed user", { currentUserId, targetUserId }); + } + + const followersCount = await repoGetFollowersCount(targetUserId); + + return { + isFollowing: !isFollowing, + followersCount, + }; +} + +export async function serviceGetFollowStatus( + dto: ServiceInputCheckFollow +): Promise { + const { currentUserId, targetUserId } = dto; + + const [isFollowing, followersCount, followingCount] = await Promise.all([ + currentUserId + ? repoCheckFollow({ followerId: currentUserId, followingId: targetUserId }) + : false, + repoGetFollowersCount(targetUserId), + repoGetFollowingCount(targetUserId), + ]); + + return { + isFollowing, + followersCount, + followingCount, + }; +} + +export async function serviceGetFollowers( + dto: ServiceInputGetFollowers +): Promise<{ followers: ServiceOutputFollowWithUser[]; total: number }> { + const { userId, page, limit } = dto; + + const [followers, total] = await Promise.all([ + repoGetFollowers({ userId, page, limit }), + repoGetFollowersCount(userId), + ]); + + return { followers, total }; +} + +export async function serviceGetFollowing( + dto: ServiceInputGetFollowing +): Promise<{ following: ServiceOutputFollowWithUser[]; total: number }> { + const { userId, page, limit } = dto; + + const [following, total] = await Promise.all([ + repoGetFollowing({ userId, page, limit }), + repoGetFollowingCount(userId), + ]); + + return { following, total }; +}