diff --git a/CLAUDE.md b/CLAUDE.md index 48d99d6..692052d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ pnpm prisma studio # 打开 Prisma Studio 查看数据库 - **PostgreSQL** + **Prisma ORM**(自定义输出目录:`src/generated/prisma`) - **better-auth** 身份验证(邮箱/密码 + OAuth) - **next-intl** 国际化(支持:en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN) -- **edge-tts-universal** 文本转语音 +- **阿里云千问 TTS** (qwen3-tts-flash) 文本转语音 - **pnpm** 包管理器 ## 架构设计 @@ -51,10 +51,36 @@ src/app/ │ └── [locale]/ # 国际化路由 ├── auth/ # 认证页面(sign-in, sign-up) ├── folders/ # 用户学习文件夹管理 -├── api/ # API 路由 -└── profile/ # 用户资料页面 +├── users/[username]/# 用户资料页面(Server Component) +├── profile/ # 重定向到当前用户的资料页面 +└── api/ # API 路由 ``` +### 后端架构模式 + +项目使用 **Action-Service-Repository 三层架构**: + +``` +src/modules/{module}/ +├── {module}-action.ts # Server Actions 层(表单处理、重定向) +├── {module}-action-dto.ts # Action 层 DTO(Zod 验证) +├── {module}-service.ts # Service 层(业务逻辑) +├── {module}-service-dto.ts # Service 层 DTO +├── {module}-repository.ts # Repository 层(数据库操作) +└── {module}-repository-dto.ts # Repository 层 DTO +``` + +各层职责: +- **Action 层**:处理表单数据、验证输入、调用 service 层、处理重定向和错误响应 +- **Service 层**:实现业务逻辑、调用 better-auth API、协调多个 repository 操作 +- **Repository 层**:直接使用 Prisma 进行数据库查询和操作 + +现有模块: +- `auth` - 认证和用户管理(支持用户名/邮箱登录) +- `folder` - 学习文件夹管理 +- `dictionary` - 词典查询 +- `translator` - 翻译服务 + ### 数据库 Schema 核心模型(见 [prisma/schema.prisma](prisma/schema.prisma)): @@ -81,10 +107,13 @@ src/app/ 需要在 `.env.local` 中配置: ```env -# LLM 集成 +# LLM 集成(智谱 AI 用于翻译和 IPA 生成) ZHIPU_API_KEY=your-api-key ZHIPU_MODEL_NAME=your-model-name +# 阿里云千问 TTS(文本转语音) +DASHSCORE_API_KEY=your-dashscore-api-key + # 认证 BETTER_AUTH_SECRET=your-secret BETTER_AUTH_URL=http://localhost:3000 @@ -93,9 +122,6 @@ GITHUB_CLIENT_SECRET=your-client-secret # 数据库 DATABASE_URL=postgresql://username:password@localhost:5432/database_name - -// DashScore -DASHSCORE_API_KEY= ``` ## 重要配置细节 @@ -108,13 +134,15 @@ DASHSCORE_API_KEY= ## 代码组织 -- `src/lib/actions/`: 数据库变更的 Server Actions +- `src/modules/`: 业务模块(auth, folder, dictionary, translator) +- `src/lib/actions/`: 数据库变更的 Server Actions(旧架构,正在迁移到 modules) - `src/lib/server/`: 服务端工具(AI 集成、认证、翻译器) - `src/lib/browser/`: 客户端工具 - `src/hooks/`: 自定义 React hooks(认证 hooks、会话管理) - `src/i18n/`: 国际化配置 - `messages/`: 各支持语言的翻译文件 - `src/components/`: 可复用的 UI 组件(buttons, cards 等) +- `src/shared/`: 共享常量和类型定义 ## 开发注意事项 @@ -122,4 +150,7 @@ DASHSCORE_API_KEY= - schema 变更后,先运行 `pnpm prisma generate` 再运行 `pnpm prisma db push` - 应用使用 TypeScript 严格模式 - 确保类型安全 - 所有面向用户的文本都需要国际化 +- **优先使用 Server Components**,只在需要交互时使用 Client Components +- **新功能应遵循 action-service-repository 架构** - Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作 +- 使用 better-auth username 插件支持用户名登录 diff --git a/README.md b/README.md index b5a14dc..378f327 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,9 @@ - **SRT字幕播放器** - 结合视频字幕学习,支持多种字幕格式 - **字母学习模块** - 针对初学者的字母和发音基础学习 - **记忆强化工具** - 通过科学记忆法巩固学习内容 +- **词典查询** - 查询单词和短语,提供详细释义和例句 - **个人学习空间** - 用户可以创建、管理和组织自己的学习资料 +- **用户资料系统** - 支持用户名登录、个人资料页面展示 ## 🛠 技术栈 @@ -26,7 +28,7 @@ ### 国际化与辅助功能 - **next-intl** - 国际化解决方案 -- **qwen3-tts-flash** - 通义千问语音合成 +- **阿里云千问 TTS** - qwen3-tts-flash 语音合成 ### 开发工具 - **ESLint** - 代码质量检查 @@ -38,8 +40,16 @@ src/ ├── app/ # Next.js App Router 路由 │ ├── (features)/ # 功能模块路由 -│ ├── api/ # API 路由 -│ └── auth/ # 认证相关页面 +│ ├── auth/ # 认证相关页面 +│ ├── profile/ # 用户资料重定向 +│ ├── users/[username]/ # 用户资料页面 +│ ├── folders/ # 文件夹管理 +│ └── api/ # API 路由 +├── modules/ # 业务模块(action-service-repository 架构) +│ ├── auth/ # 认证模块 +│ ├── folder/ # 文件夹模块 +│ ├── dictionary/ # 词典模块 +│ └── translator/ # 翻译模块 ├── components/ # React 组件 │ ├── buttons/ # 按钮组件 │ ├── cards/ # 卡片组件 @@ -50,6 +60,7 @@ src/ │ └── server/ # 服务器端工具 ├── hooks/ # 自定义 React Hooks ├── i18n/ # 国际化配置 +├── shared/ # 共享常量和类型 └── config/ # 应用配置 ``` @@ -57,7 +68,7 @@ src/ ### 环境要求 -- Node.js 24 +- Node.js 23 - PostgreSQL 数据库 - pnpm (推荐) 或 npm @@ -85,17 +96,20 @@ cp .env.example .env.local 然后编辑 `.env.local` 文件,配置所有必要的环境变量: ```env -// LLM +# LLM 集成(智谱 AI 用于翻译和 IPA 生成) ZHIPU_API_KEY=your-zhipu-api-key ZHIPU_MODEL_NAME=your-zhipu-model-name -// Auth +# 阿里云千问 TTS(文本转语音) +DASHSCORE_API_KEY=your-dashscore-api-key + +# 认证 BETTER_AUTH_SECRET=your-better-auth-secret BETTER_AUTH_URL=http://localhost:3000 GITHUB_CLIENT_ID=your-github-client-id GITHUB_CLIENT_SECRET=your-github-client-secret -// Database +# 数据库 DATABASE_URL=postgresql://username:password@localhost:5432/database_name ``` @@ -118,14 +132,27 @@ pnpm run dev ### 认证系统 -应用使用 better-auth 提供安全的用户认证系统,支持邮箱/密码登录和第三方登录。 +应用使用 better-auth 提供安全的用户认证系统,支持: +- 邮箱/密码登录和注册 +- **用户名登录**(可通过用户名或邮箱登录) +- GitHub OAuth 第三方登录 +- 邮箱验证功能 + +### 后端架构 + +项目采用 **Action-Service-Repository 三层架构**: +- **Action 层**:处理 Server Actions、表单验证、重定向 +- **Service 层**:业务逻辑、better-auth 集成 +- **Repository 层**:Prisma 数据库操作 ### 数据模型 核心数据模型包括: -- **User** - 用户信息 +- **User** - 用户信息(支持用户名、邮箱、头像) - **Folder** - 学习资料文件夹 - **Pair** - 语言对(翻译对、词汇对等) +- **Session/Account** - 认证会话追踪 +- **Verification** - 邮箱验证系统 详细模型定义请参考 [prisma/schema.prisma](./prisma/schema.prisma) diff --git a/messages/en-US.json b/messages/en-US.json index dc8a0b7..14c0474 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -231,5 +231,17 @@ "pleaseCreateFolder": "Please create a folder first", "savedToFolder": "Saved to folder: {folderName}", "saveFailed": "Save failed, please try again later" + }, + "user_profile": { + "anonymous": "Anonymous", + "email": "Email", + "verified": "Verified", + "unverified": "Unverified", + "accountInfo": "Account Information", + "userId": "User ID", + "username": "Username", + "displayName": "Display Name", + "notSet": "Not Set", + "memberSince": "Member Since" } } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 96dc73b..7aac43a 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -231,5 +231,17 @@ "pleaseCreateFolder": "请先创建文件夹", "savedToFolder": "已保存到文件夹:{folderName}", "saveFailed": "保存失败,请稍后重试" + }, + "user_profile": { + "anonymous": "匿名", + "email": "邮箱", + "verified": "已验证", + "unverified": "未验证", + "accountInfo": "账户信息", + "userId": "用户ID", + "username": "用户名", + "displayName": "显示名称", + "notSet": "未设置", + "memberSince": "注册时间" } } diff --git a/prisma/migrations/20260203120213_table_user_delete_name/migration.sql b/prisma/migrations/20260203120213_table_user_delete_name/migration.sql new file mode 100644 index 0000000..a4d6ee7 --- /dev/null +++ b/prisma/migrations/20260203120213_table_user_delete_name/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `name` on the `user` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "user" DROP COLUMN "name"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 56ef5db..47bfb7f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,7 +10,6 @@ datasource db { model User { id String @id - name String email String emailVerified Boolean @default(false) image String? diff --git a/src/app/(features)/srt-player/utils/subtitleParser.ts b/src/app/(features)/srt-player/utils/subtitleParser.ts index 6145e89..1144554 100644 --- a/src/app/(features)/srt-player/utils/subtitleParser.ts +++ b/src/app/(features)/srt-player/utils/subtitleParser.ts @@ -1,5 +1,4 @@ import { SubtitleEntry } from "../types/subtitle"; -import { logger } from "@/lib/logger"; export function parseSrt(data: string): SubtitleEntry[] { const lines = data.split(/\r?\n/); @@ -94,7 +93,7 @@ export async function loadSubtitle(url: string): Promise { const data = await response.text(); return parseSrt(data); } catch (error) { - logger.error('加载字幕失败', error); + console.error('加载字幕失败', error); return []; } } \ No newline at end of file diff --git a/src/app/(features)/text-speaker/page.tsx b/src/app/(features)/text-speaker/page.tsx index 0c73e27..182ad1b 100644 --- a/src/app/(features)/text-speaker/page.tsx +++ b/src/app/(features)/text-speaker/page.tsx @@ -15,7 +15,6 @@ import { SaveList } from "./SaveList"; import { useTranslations } from "next-intl"; import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators"; import { genIPA, genLanguage } from "@/modules/translator/translator-action"; -import { logger } from "@/lib/logger"; import { PageLayout } from "@/components/ui/PageLayout"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; @@ -75,7 +74,7 @@ export default function TextSpeakerPage() { setIPA(data.ipa); }) .catch((e) => { - logger.error("生成 IPA 失败", e); + console.error("生成 IPA 失败", e); setIPA(""); }); } @@ -120,7 +119,7 @@ export default function TextSpeakerPage() { load(objurlRef.current); play(); } catch (e) { - logger.error("播放音频失败", e); + console.error("播放音频失败", e); setPause(true); setLanguage(null); setProcessing(false); @@ -212,7 +211,7 @@ export default function TextSpeakerPage() { } setIntoLocalStorage(save); } catch (e) { - logger.error("保存到本地存储失败", e); + console.error("保存到本地存储失败", e); setLanguage(null); } finally { setSaving(false); diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index cabeea7..a672e00 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -1,49 +1,16 @@ -import Image from "next/image"; -import { PageLayout } from "@/components/ui/PageLayout"; -import { PageHeader } from "@/components/ui/PageHeader"; import { auth } from "@/auth"; -import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; import { headers } from "next/headers"; -import { LogoutButton } from "./LogoutButton"; export default async function ProfilePage() { - const t = await getTranslations("profile"); - const session = await auth.api.getSession({ headers: await headers() }); if (!session) { redirect("/auth?redirect=/profile"); } - return ( - - - - {/* 用户信息区域 */} -
- {/* 用户头像 */} - {session.user.image && ( - User Avatar - )} - - {/* 用户名和邮箱 */} -
-

- {session.user.name} -

-

{t("email", { email: session.user.email })}

-
- - {/* 登出按钮 */} - -
-
- ); + // 已登录,跳转到用户资料页面 + // 优先使用 username,如果没有则使用 email + const username = (session.user.username as string) || (session.user.email as string); + redirect(`/users/${username}`); } diff --git a/src/app/users/[username]/page.ts b/src/app/users/[username]/page.ts deleted file mode 100644 index e286ee4..0000000 --- a/src/app/users/[username]/page.ts +++ /dev/null @@ -1,8 +0,0 @@ -interface UserPageProps { - params: Promise<{ username: string}>; -} - -export default async function UserPage({params}: UserPageProps) { - const {username} = await params; - -} diff --git a/src/app/users/[username]/page.tsx b/src/app/users/[username]/page.tsx new file mode 100644 index 0000000..cfcc77a --- /dev/null +++ b/src/app/users/[username]/page.tsx @@ -0,0 +1,126 @@ +import Image from "next/image"; +import { Container } from "@/components/ui/Container"; +import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; + +interface UserPageProps { + params: Promise<{ username: string; }>; +} + +export default async function UserPage({ params }: UserPageProps) { + const { username } = await params; + const t = await getTranslations("user_profile"); + + // Get user profile + const result = await actionGetUserProfileByUsername({ username }); + + if (!result.success || !result.data) { + notFound(); + } + + const user = result.data; + + return ( +
+ + {/* Header */} +
+
+ {/* 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} +

+ )} +
+ + Joined: {new Date(user.createdAt).toLocaleDateString()} + + {user.emailVerified && ( + + + + + Verified + + )} +
+
+
+
+ + {/* Email Section */} +
+

{t("email")}

+
+
+ {user.email} +
+ {user.emailVerified ? ( + + ✓ {t("verified")} + + ) : ( + + {t("unverified")} + + )} +
+
+ + {/* Account Info */} +
+

{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()} +
+
+
+
+
+
+ ); +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts deleted file mode 100644 index 982f794..0000000 --- a/src/lib/logger.ts +++ /dev/null @@ -1,25 +0,0 @@ -class Logger { - error(message: string, error?: unknown): void { - if (error instanceof Error) { - console.error(`[ERROR] ${message}:`, error.message, error.stack); - } else { - console.error(`[ERROR] ${message}:`, error); - } - } - - warn(message: string, ...args: unknown[]): void { - console.warn(`[WARN] ${message}`, ...args); - } - - info(message: string, ...args: unknown[]): void { - console.info(`[INFO] ${message}`, ...args); - } - - debug(message: string, ...args: unknown[]): void { - if (process.env.NODE_ENV === "development") { - console.debug(`[DEBUG] ${message}`, ...args); - } - } -} - -export const logger = new Logger(); diff --git a/src/modules/auth/auth-action-dto.ts b/src/modules/auth/auth-action-dto.ts index f50d61a..81b16fd 100644 --- a/src/modules/auth/auth-action-dto.ts +++ b/src/modules/auth/auth-action-dto.ts @@ -1,12 +1,12 @@ import z from "zod"; import { generateValidator } from "@/utils/validate"; -import { LENGTH_MAX_PASSWORD, LENGTH_MAX_USERNAME, LENGTH_MIN_PASSWORD, LENGTH_MIN_USERNAME } from "@/shared/constant"; +import { LENGTH_MAX_USERNAME, LENGTH_MIN_USERNAME } from "@/shared/constant"; // Schema for sign up const schemaActionInputSignUp = z.object({ email: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Invalid email address"), username: z.string().min(LENGTH_MIN_USERNAME).max(LENGTH_MAX_USERNAME).regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"), - password: z.string().min(LENGTH_MIN_PASSWORD).max(LENGTH_MAX_PASSWORD), + password: z.string().min(8).max(100), redirectTo: z.string().nullish(), }); @@ -17,7 +17,7 @@ export const validateActionInputSignUp = generateValidator(schemaActionInputSign // Schema for sign in const schemaActionInputSignIn = z.object({ identifier: z.string().min(1), // Can be email or username - password: z.string().min(LENGTH_MIN_PASSWORD).max(LENGTH_MAX_PASSWORD), + password: z.string().min(8).max(100), redirectTo: z.string().nullish(), }); @@ -25,14 +25,14 @@ export type ActionInputSignIn = z.infer; export const validateActionInputSignIn = generateValidator(schemaActionInputSignIn); -// Schema for sign out -const schemaActionInputSignOut = z.object({ - redirectTo: z.string().nullish(), +// Schema for get user profile by username +const schemaActionInputGetUserProfileByUsername = z.object({ + username: z.string().min(LENGTH_MIN_USERNAME).max(LENGTH_MAX_USERNAME), }); -export type ActionInputSignOut = z.infer; +export type ActionInputGetUserProfileByUsername = z.infer; -export const validateActionInputSignOut = generateValidator(schemaActionInputSignOut); +export const validateActionInputGetUserProfileByUsername = generateValidator(schemaActionInputGetUserProfileByUsername); // Output types export type ActionOutputAuth = { @@ -45,3 +45,18 @@ export type ActionOutputAuth = { identifier?: string[]; }; }; + +export type ActionOutputUserProfile = { + success: boolean; + message: string; + data?: { + id: string; + email: string; + emailVerified: boolean; + username: string | null; + displayUsername: string | null; + image: string | null; + createdAt: Date; + updatedAt: Date; + }; +}; diff --git a/src/modules/auth/auth-action.ts b/src/modules/auth/auth-action.ts index dbe72c0..a756fb9 100644 --- a/src/modules/auth/auth-action.ts +++ b/src/modules/auth/auth-action.ts @@ -5,19 +5,23 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { ValidateError } from "@/lib/errors"; import { + ActionInputGetUserProfileByUsername, ActionInputSignIn, ActionInputSignUp, ActionOutputAuth, + ActionOutputUserProfile, + validateActionInputGetUserProfileByUsername, validateActionInputSignIn, validateActionInputSignUp } from "./auth-action-dto"; import { + serviceGetUserProfileByUsername, serviceSignIn, serviceSignUp } from "./auth-service"; // Re-export types for use in components -export type { ActionOutputAuth } from "./auth-action-dto"; +export type { ActionOutputAuth, ActionOutputUserProfile } from "./auth-action-dto"; /** * Sign up action @@ -144,3 +148,32 @@ export async function signOutAction() { redirect("/auth"); } } + +/** + * Get user profile by username + * Returns user profile data for display + */ +export async function actionGetUserProfileByUsername(dto: ActionInputGetUserProfileByUsername): Promise { + try { + const userProfile = await serviceGetUserProfileByUsername(dto); + + if (!userProfile) { + return { + success: false, + message: "User not found", + }; + } + + return { + success: true, + message: "User profile retrieved successfully", + data: userProfile, + }; + } catch (e) { + console.error("Get user profile error:", e); + return { + success: false, + message: "Failed to retrieve user profile", + }; + } +} diff --git a/src/modules/auth/auth-repository-dto.ts b/src/modules/auth/auth-repository-dto.ts new file mode 100644 index 0000000..c413e5e --- /dev/null +++ b/src/modules/auth/auth-repository-dto.ts @@ -0,0 +1,26 @@ +// Repository layer DTOs for auth module - User profile operations + +// User profile data types +export type RepoOutputUserProfile = { + id: string; + email: string; + emailVerified: boolean; + username: string | null; + displayUsername: string | null; + image: string | null; + createdAt: Date; + updatedAt: Date; +} | null; + +// Input types +export type RepoInputFindUserByUsername = { + username: string; +}; + +export type RepoInputFindUserById = { + id: string; +}; + +export type RepoInputFindUserByEmail = { + email: string; +}; diff --git a/src/modules/auth/auth-repository.ts b/src/modules/auth/auth-repository.ts new file mode 100644 index 0000000..cd644c0 --- /dev/null +++ b/src/modules/auth/auth-repository.ts @@ -0,0 +1,70 @@ +import { prisma } from "@/lib/db"; +import { + RepoInputFindUserByEmail, + RepoInputFindUserById, + RepoInputFindUserByUsername, + 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 }, + select: { + id: true, + email: true, + emailVerified: true, + username: true, + displayUsername: true, + image: true, + createdAt: true, + updatedAt: true, + } + }); + + return user; +} + +/** + * Find user by ID + */ +export async function repoFindUserById(dto: RepoInputFindUserById): Promise { + const user = await prisma.user.findUnique({ + where: { id: dto.id }, + select: { + id: true, + email: true, + emailVerified: true, + username: true, + displayUsername: true, + image: true, + createdAt: true, + updatedAt: true, + } + }); + + return user; +} + +/** + * Find user by email + */ +export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promise { + const user = await prisma.user.findUnique({ + where: { email: dto.email }, + select: { + id: true, + email: true, + emailVerified: true, + username: true, + displayUsername: true, + image: true, + createdAt: true, + updatedAt: true, + } + }); + + return user; +} diff --git a/src/modules/auth/auth-service-dto.ts b/src/modules/auth/auth-service-dto.ts index c7caf01..f13f5ba 100644 --- a/src/modules/auth/auth-service-dto.ts +++ b/src/modules/auth/auth-service-dto.ts @@ -1,50 +1,39 @@ -// Service layer DTOs for auth module +// Service layer DTOs for auth module - User profile operations // Sign up input/output export type ServiceInputSignUp = { email: string; username: string; - password: string; // plain text, will be hashed by better-auth + password: string; name: string; }; -export type ServiceOutputSignUp = { +export type ServiceOutputAuth = { success: boolean; - userId?: string; - email?: string; - username?: string; }; // Sign in input/output export type ServiceInputSignIn = { - identifier: string; // email or username + identifier: string; password: string; }; -export type ServiceOutputSignIn = { - success: boolean; - userId?: string; - email?: string; - username?: string; - sessionToken?: string; +// Get user profile input/output +export type ServiceInputGetUserProfileByUsername = { + username: string; }; -// Sign out input/output -export type ServiceInputSignOut = { - sessionId?: string; +export type ServiceInputGetUserProfileById = { + id: string; }; -export type ServiceOutputSignOut = { - success: boolean; -}; - -// User existence check -export type ServiceInputCheckUserExists = { - email?: string; - username?: string; -}; - -export type ServiceOutputCheckUserExists = { - emailExists: boolean; - usernameExists: boolean; -}; +export type ServiceOutputUserProfile = { + id: string; + email: string; + emailVerified: boolean; + username: string | null; + displayUsername: string | null; + image: string | null; + createdAt: Date; + updatedAt: Date; +} | null; diff --git a/src/modules/auth/auth-service.ts b/src/modules/auth/auth-service.ts index dfecbcb..c971e3d 100644 --- a/src/modules/auth/auth-service.ts +++ b/src/modules/auth/auth-service.ts @@ -1,76 +1,94 @@ import { auth } from "@/auth"; import { - ServiceInputSignUp, + repoFindUserByUsername, + repoFindUserById +} from "./auth-repository"; +import { + ServiceInputGetUserProfileByUsername, + ServiceInputGetUserProfileById, ServiceInputSignIn, - ServiceOutputSignUp, - ServiceOutputSignIn + ServiceInputSignUp, + ServiceOutputAuth, + ServiceOutputUserProfile } from "./auth-service-dto"; /** - * Sign up a new user - * Calls better-auth's signUp.email with username support + * Sign up service */ -export async function serviceSignUp(dto: ServiceInputSignUp): Promise { - try { - await auth.api.signUpEmail({ - body: { - email: dto.email, - password: dto.password, - username: dto.username, - name: dto.name, - } - }); - - return { - success: true, +export async function serviceSignUp(dto: ServiceInputSignUp): Promise { + // Better-auth handles user creation internally + const result = await auth.api.signUpEmail({ + body: { email: dto.email, + password: dto.password, + name: dto.name, username: dto.username, - }; - } catch (error) { - // better-auth handles duplicates and validation errors + } + }); + + if (!result.user) { return { success: false, }; } + + return { + success: true, + }; } /** - * Sign in user - * Uses better-auth's signIn.username for username-based authentication + * Sign in service */ -export async function serviceSignIn(dto: ServiceInputSignIn): Promise { - try { - // Determine if identifier is email or username - const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(dto.identifier); +export async function serviceSignIn(dto: ServiceInputSignIn): Promise { + // Try to sign in with username first + const userResult = await repoFindUserByUsername({ username: dto.identifier }); - let session; + if (userResult) { + // User found by username, use email signIn with the user's email + const result = await auth.api.signInEmail({ + body: { + email: userResult.email, + password: dto.password, + } + }); - if (isEmail) { - // Use email sign in - session = await auth.api.signInEmail({ - body: { - email: dto.identifier, - password: dto.password, - } - }); - } else { - // Use username sign in (requires username plugin) - session = await auth.api.signInUsername({ - body: { - username: dto.identifier, - password: dto.password, - } - }); + if (result.user) { + return { + success: true, + }; } + } else { + // Try as email + const result = await auth.api.signInEmail({ + body: { + email: dto.identifier, + password: dto.password, + } + }); - return { - success: true, - sessionToken: session?.token, - }; - } catch (error) { - // better-auth throws on invalid credentials - return { - success: false, - }; + if (result.user) { + return { + success: true, + }; + } } + + return { + success: false, + }; +} + +/** + * Get user profile by username + */ +export async function serviceGetUserProfileByUsername(dto: ServiceInputGetUserProfileByUsername): Promise { + return await repoFindUserByUsername(dto); +} + +/** + * Get user profile by ID + */ +export async function serviceGetUserProfileById(dto: ServiceInputGetUserProfileById): Promise { + return await repoFindUserById(dto); }