diff --git a/src/services/user/user.service.ts b/src/services/user/user.service.ts new file mode 100644 index 0000000..fb6bf5c --- /dev/null +++ b/src/services/user/user.service.ts @@ -0,0 +1,131 @@ +import { eq } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import type * as schema from '../../db/schema/index.js'; +import { users } from '../../db/schema/index.js'; +import { notFound, conflict, ERROR_CODES } from '../../utils/errors.js'; +import type { User } from '../../db/schema/users.js'; +import type { SelfLevel } from '../../db/schema/index.js'; + +type Db = NodePgDatabase; + +export type ProfileUpdateInput = { + nickname?: string; + avatarUrl?: string | null; + country?: string | null; + city?: string | null; + selfLevel?: SelfLevel | null; + isPublic?: boolean; +}; + +export type PublicProfile = { + id: string; + nickname: string; + avatarUrl: string | null; + country: string | null; + city: string | null; + selfLevel: string | null; + isPublic: boolean; +}; + +export type PrivateProfile = PublicProfile & { + email: string; + emailVerifiedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +function toPublicProfile(user: User): PublicProfile { + return { + id: user.id, + nickname: user.nickname, + avatarUrl: user.avatarUrl, + country: user.country, + city: user.city, + selfLevel: user.selfLevel, + isPublic: user.isPublic, + }; +} + +function toPrivateProfile(user: User): PrivateProfile { + return { + ...toPublicProfile(user), + email: user.email, + emailVerifiedAt: user.emailVerifiedAt?.toISOString() ?? null, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt.toISOString(), + }; +} + +export class UserService { + constructor(private readonly db: Db) {} + + async getById(userId: string): Promise { + const [user] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1); + return user ?? null; + } + + async getByNickname(nickname: string): Promise { + const [user] = await this.db + .select() + .from(users) + .where(eq(users.nickname, nickname.trim())) + .limit(1); + return user ?? null; + } + + async getPrivateProfile(userId: string): Promise { + const user = await this.getById(userId); + if (!user) { + throw notFound('User not found'); + } + return toPrivateProfile(user); + } + + async getPublicProfile(username: string): Promise { + const user = await this.getByNickname(username); + if (!user) { + throw notFound('User not found'); + } + if (!user.isPublic) { + throw notFound('User not found'); + } + return toPublicProfile(user); + } + + async updateProfile(userId: string, input: ProfileUpdateInput): Promise { + const updateData: Partial = { + updatedAt: new Date(), + }; + + if (input.nickname !== undefined) { + const trimmed = input.nickname.trim(); + const [existing] = await this.db + .select({ id: users.id }) + .from(users) + .where(eq(users.nickname, trimmed)) + .limit(1); + + if (existing && existing.id !== userId) { + throw conflict(ERROR_CODES.NICKNAME_TAKEN, 'Nickname already taken'); + } + updateData.nickname = trimmed; + } + if (input.avatarUrl !== undefined) updateData.avatarUrl = input.avatarUrl; + if (input.country !== undefined) updateData.country = input.country; + if (input.city !== undefined) updateData.city = input.city; + if (input.selfLevel !== undefined) updateData.selfLevel = input.selfLevel; + if (input.isPublic !== undefined) updateData.isPublic = input.isPublic; + + const [updated] = await this.db + .update(users) + .set(updateData) + .where(eq(users.id, userId)) + .returning(); + + if (!updated) { + throw notFound('User not found'); + } + + return toPrivateProfile(updated); + } +}