diff --git a/prisma/migrations/20250803113800_badge/migration.sql b/prisma/migrations/20250803113800_badge/migration.sql new file mode 100644 index 0000000..28cf04f --- /dev/null +++ b/prisma/migrations/20250803113800_badge/migration.sql @@ -0,0 +1,32 @@ +/* + Warnings: + + - A unique constraint covering the columns `[type,threshold]` on the table `badges` will be added. If there are existing duplicate values, this will fail. + - Added the required column `threshold` to the `badges` table without a default value. This is not possible if the table is not empty. + - Added the required column `type` to the `badges` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE `badges` ADD COLUMN `badge_image` VARCHAR(255) NULL, + ADD COLUMN `threshold` INTEGER NOT NULL, + ADD COLUMN `type` VARCHAR(100) NOT NULL; + +-- CreateTable +CREATE TABLE `user_badges` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `account_id` BIGINT NOT NULL, + `badge_id` BIGINT NOT NULL, + `earned_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `user_badges_account_id_badge_id_key`(`account_id`, `badge_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateIndex +CREATE UNIQUE INDEX `badges_type_threshold_key` ON `badges`(`type`, `threshold`); + +-- AddForeignKey +ALTER TABLE `user_badges` ADD CONSTRAINT `user_badges_account_id_fkey` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `user_badges` ADD CONSTRAINT `user_badges_badge_id_fkey` FOREIGN KEY (`badge_id`) REFERENCES `badges`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 099a519..52e9f77 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,7 @@ model Account { userAgreements UserAgreement[] userCategories UserCategory[] follows Follow[] + userBadges UserBadge[] @@unique([provider, oauthId]) @@map("accounts") @@ -348,12 +349,31 @@ model UserAgreement { } model Badge { - id BigInt @id @default(autoincrement()) - name String @db.VarChar(100) + id BigInt @id @default(autoincrement()) + type String @db.VarChar(100) + threshold Int + name String @db.VarChar(100) + badgeImage String? @map("badge_image") @db.VarChar(255) + userBadges UserBadge[] + + @@unique([type, threshold]) @@map("badges") } +model UserBadge { + id BigInt @id @default(autoincrement()) + accountId BigInt @map("account_id") + badgeId BigInt @map("badge_id") + earnedAt DateTime @default(now()) @map("earned_at") + + account Account @relation(fields: [accountId], references: [id]) + badge Badge @relation(fields:[badgeId], references:[id]) + + @@unique([accountId, badgeId]) + @@map("user_badges") +} + model Session { id String @id sid String @unique diff --git a/prisma/seed.js b/prisma/seed.js index 8371dfa..43e1abf 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -191,6 +191,113 @@ async function main() { }, }); + const badges = await prisma.badge.createMany({ + data:[ + { + name: "첫 커미션 완료!", + type: "comm_finish", + threshold: 1, + badgeImage: "https://example.com/badge_comm1.png", + }, + { + name: "5회 커미션 완료!", + type: "comm_finish", + threshold: 5, + badgeImage: "https://example.com/badge_comm5.png", + }, + { + name: "15회 커미션 완료!", + type: "comm_finish", + threshold: 15, + badgeImage: "https://example.com/badge_comm15.png", + }, + { + name: "50회 커미션 완료!", + type: "comm_finish", + threshold: 50, + badgeImage: "https://example.com/badge_com50.png", + }, + { + name: "첫 팔로우 완료!", + type: "follow", + threshold: 1, + badgeImage: "https://example.com/badge_follow1.png", + }, + { + name: "팔로우 5회!", + type: "follow", + threshold: 5, + badgeImage: "https://example.com/badge_follow5.png", + }, + { + name: "팔로우 15회!", + type: "follow", + threshold: 15, + badgeImage: "https://example.com/badge_follow15.png", + }, + { + name: "팔로우 50회!", + type: "follow", + threshold: 50, + badgeImage: "https://example.com/badge_follow50.png", + }, + { + name: "첫 후기 작성 완료!", + type: "review", + threshold: 1, + badgeImage: "https://example.com/badge_review1.png", + }, + { + name: "5회 후기 작성!", + type: "review", + threshold: 5, + badgeImage: "https://example.com/badge_review5.png", + }, + { + name: "15회 후기 작성!", + type: "review", + threshold: 15, + badgeImage: "https://example.com/badge_review15.png", + }, + { + name: "50회 후기 작성!", + type: "review", + threshold: 50, + badgeImage: "https://example.com/badge_review50.png", + }, + { + name: "첫 커미션 신청 완료!", + type: "comm_request", + threshold: 1, + badgeImage: "https://example.com/badge_request1.png", + }, + { + name: "5회 커미션 신청 완료!", + type: "comm_request", + threshold: 5, + badgeImage: "https://example.com/badge_request5.png", + }, + { + name: "15회 커미션 신청 완료!", + type: "comm_request", + threshold: 15, + badgeImage: "https://example.com/badge_request15.png", + }, + { + name: "50회 커미션 신청 완료!", + type: "comm_request", + threshold: 50, + badgeImage: "https://example.com/badge_request50.png", + }, + { + name: "가입 1주년!", + type: "signup_1year", + threshold: 50, + badgeImage: "https://example.com/badge_signup_1year.png", + }, + ] + }) + console.log("✅ Seed completed successfully."); } diff --git a/src/routes.js b/src/routes.js index 6840840..c3a00b8 100644 --- a/src/routes.js +++ b/src/routes.js @@ -10,6 +10,7 @@ import paymentRouter from "./payment/payment.routes.js" import pointRouter from "./point/point.routes.js" import requestRouter from "./request/request.routes.js" import homeRouter from "./home/home.routes.js" +import artistRouter from "./user/artist.routes.js" import tokenRouter from "./token.routes.js" const router = express.Router(); @@ -19,6 +20,7 @@ router.use("/", bookmarkRouter); router.use("/search", searchRouter) router.use("/commissions", commissionRouter); router.use("/users", userRouter); +router.use("/artists", artistRouter); router.use("/reviews", reviewRouter); router.use("/notifications", notificationRouter); router.use("/payments", paymentRouter); diff --git a/src/user/artist.routes.js b/src/user/artist.routes.js new file mode 100644 index 0000000..d4b894b --- /dev/null +++ b/src/user/artist.routes.js @@ -0,0 +1,12 @@ +import express from "express"; +import { AccessArtistProfile } from "./controller/user.controller.js"; +import { authenticate } from "../middlewares/auth.middleware.js"; + +const router = express.Router(); + + +// 작가 프로필 조회 +router.get("/:artistId", authenticate,AccessArtistProfile ); + + +export default router; \ No newline at end of file diff --git a/src/user/controller/user.controller.js b/src/user/controller/user.controller.js index c68639f..ae14009 100644 --- a/src/user/controller/user.controller.js +++ b/src/user/controller/user.controller.js @@ -158,4 +158,35 @@ export const LookUserFollow = async(req, res, next) => { }catch(err) { next(err); } -} \ No newline at end of file +} + +// 사용자의 뱃지 조회하기 +export const LookUserBadge = async(req, res, next) => { + try{ + console.log("🎖️Decoded JWT from req.user:", req.user); + + const accountId = req.user.accountId.toString(); + console.log("사용자의 뱃지 조회 accountId -> ", accountId); + + const result = await UserService.ViewUserBadge(accountId); + + res.status(StatusCodes.OK).success(result); + }catch(err) { + next(err); + } +} + +// 작가 프로필 조회하기 +export const AccessArtistProfile = async(req, res, next) => { + try{ + const artistId = req.params.artistId; + const accountId = req.user.accountId; + + const result = await UserService.AccessArtistProfile(artistId, accountId); + + res.status(StatusCodes.OK).success(result); + } catch(err) { + next(err); + } +} + diff --git a/src/user/repository/badge.repository.js b/src/user/repository/badge.repository.js new file mode 100644 index 0000000..31bdf1e --- /dev/null +++ b/src/user/repository/badge.repository.js @@ -0,0 +1,63 @@ +import { prisma } from "../../db.config.js" + +export const BadgeRepository = { + // type과 progress를 기준으로 발급 가능한 뱃지를 조회하기 + async findEligibleBadgesByProgress(type, progress) { + return await prisma.badge.findMany({ + where: { + type, + threshold:{ + lte:progress, + }, + }, + orderBy:{ + threshold:"asc" + } + }); + }, + // 여러개의 뱃지를 사용자에게 한 번에 발급하기 + async createManyUserBadges(accountId, badgeIds){ + if(!badgeIds.length) return; + + const data=badgeIds.map((badgeId)=> ({ + accountId, + badgeId, + earnedAt: new Date(), + })); + + return await prisma.userBadge.createMany({ + data, + skipDuplicates:true, // 같은 뱃지 중복 발급 방지 + }) + }, + // 사용자의 뱃지 조회하기 + async ViewUserBadges(accountId){ + return await prisma.userBadge.findMany({ + where:{ + accountId, + }, + include:{ + badge:true, + }, + orderBy:{ + earnedAt:'desc', + } + }); + }, + // 작가 뱃지 조회하기 + async ViewArtistBadges(accountId){ + return await prisma.userBadge.findMany({ + where:{ + accountId, + }, + include:{ + badge:true, + }, + orderBy:{ + earnedAt:'desc', + } + }); + } + +}; + diff --git a/src/user/repository/user.repository.js b/src/user/repository/user.repository.js index e047899..9af6933 100644 --- a/src/user/repository/user.repository.js +++ b/src/user/repository/user.repository.js @@ -1,4 +1,5 @@ import { prisma } from "../../db.config.js" +import { RequestStatus } from "@prisma/client"; export const UserRepository = { /** @@ -118,6 +119,16 @@ export const UserRepository = { select:{ users: { select: { id: true, nickname: true, description: true, profileImage: true } }, artists:{ select: { id: true, nickname: true, description: true, profileImage: true } }, + userBadges:{ + select: { + id:true, earnedAt:true, + badge:{ + select:{ + id:true, type:true, threshold:true, name:true, badgeImage:true + } + } + } + } } }); }, @@ -207,5 +218,92 @@ export const UserRepository = { } } }) - } -}; \ No newline at end of file + }, + + // 사용자의 커미션 신청 횟수 조회 + async countClientCommissionApplication(userId){ + return await prisma.request.count({ + where:{userId, status:RequestStatus.PENDING} + }) + }, + + // 사용자의 리뷰작성 횟수 조회 + async countClientReview(userId) { + return await prisma.review.count({ + where:{userId} + }) + }, + // 작가 프로필 조회하기 + async AccessArtistProfile(artistId) { + return await prisma.artist.findUnique({ + where:{ + id: artistId + }, + select:{ + nickname: true, + description: true, + profileImage: true, + slot:true + } + }); + }, + + // 작가에게 달린 리뷰 조회하기 + async ArtistReviews(artistId) { + return await prisma.review.findMany({ + where:{ + request:{ + commission:{ + artistId:artistId + } + } + }, + orderBy:{createdAt:'desc'}, + take:4, + select:{ + id:true, + rate:true, + content:true, + createdAt:true, + user:{ + select:{ + nickname:true, + } + }, + request:{ + select:{ + inProgressAt:true, + completedAt:true, + commission:{ + select:{ + title:true + } + } + } + } + } + }) + }, + // 작가가 등록한 커미션 목록 불러오기 + async FetchArtistCommissions(artistId) { + return await prisma.commission.findMany({ + where: { artistId: artistId }, + select: { + id: true, + title: true, + summary: true, + minPrice: true, + category: { + select: { name: true } + }, + commissionTags: { + select: { + tag: { select: { name: true } } + } + } + } + }); + }, + +}; + diff --git a/src/user/service/user.service.js b/src/user/service/user.service.js index 3c3ec6f..6e2731d 100644 --- a/src/user/service/user.service.js +++ b/src/user/service/user.service.js @@ -2,6 +2,7 @@ import { UserRepository } from "../repository/user.repository.js"; import { OauthIdAlreadyExistError, MissingCategoryError, MissingRequiredAgreementError, UserRoleError, UserAlreadyFollowArtist, ArtistNotFound, NotFollowingArtist } from "../../common/errors/user.errors.js"; import axios from "axios"; import { signJwt } from "../../jwt.config.js"; +import { BadgeRepository } from "../repository/badge.repository.js"; @@ -114,32 +115,55 @@ export const UserService = { let result; if(role === 'client') { - result = await UserRepository.findUserById(accountId); + result = await UserRepository.getMyProfile(accountId); console.log(result); const user = result.users[0]; + + console.log("userBadges 확인:", result.userBadges); + + + const badges = result.userBadges.map(userBadge => ({ + id: userBadge.id, + earnedAt: userBadge.earnedAt, + badge: userBadge.badge + })); + + return { message:"나의 프로필 조회에 성공하였습니다.", user:{ userId: user.id, nickname: user.nickname, profileImage:user.profileImage, - description: user.description + description: user.description, + badges } } } if(role === 'artist') { - result = await UserRepository.findArtistById(accountId); + result = await UserRepository.getMyProfile(accountId); console.log(result); const artist = result.artists[0]; + + console.log("userBadges 확인:", result.userBadges); + + + const badges = result.userBadges.map(userBadge => ({ + id: userBadge.id, + earnedAt: userBadge.earnedAt, + badge: userBadge.badge + })); + return { message:"나의 프로필 조회에 성공하였습니다.", user:{ artistId: artist.id, nickname: artist.nickname, profileImage:artist.profileImage, - description: artist.description - } + description: artist.description, + badges + }, } } }, @@ -288,5 +312,93 @@ export const UserService = { message:"사용자가 팔로우하는 작가 목록입니다.", artistList }; + }, + + // 사용자가 작성한 리뷰 횟수 조회하기 + async CountUserReview(userId){ + return await UserRepository.CountUserReview(userId); + }, + + // 사용자가 신청한 커미션 횟수 조회하기 + async CountUserCommissionRequest(userId){ + return await UserRepository.countClientCommissionApplication(userId); + }, + + // 특정 progress에 도달했을 때 발급 가능한 뱃지 조회 + async FindBadgesByProgress(type, progress){ + return await BadgeRepository.findEligibleBadgesByProgress(type, progress); + }, + + // 뱃지 발급 처리 로직 통합 + async GrantBadgesByProgress(accountId, type, progress){ + const eligibleBadges = await BadgeRepository.findEligibleBadgesByProgress(type, progress); + const badgeIds = eligibleBadges.map((badge)=> badge.id); + if(!badgeIds.length) return; + + await BadgeRepository.createManyUserBadges(accountId, badgeIds); + }, + + // 사용자의 뱃지 조회하기 + async ViewUserBadge(accountId){ + return await BadgeRepository.ViewUserBadges(accountId); + }, + // 작가 프로필 조회하기 + async AccessArtistProfile(artistId, accountId) { + const profile = await UserRepository.AccessArtistProfile(artistId); + const rawReviews = await UserRepository.ArtistReviews(artistId); + + const reviews = rawReviews.map((r) => { + const start = r.request.inProgressAt ? new Date(r.request.inProgressAt) : null; + const end = r.request.completedAt ? new Date(r.request.completedAt) : null; + + let workingTime = null; + if (start && end) { + const diffMs = end - start; + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + workingTime = hours < 24 ? `${hours}시간` : `${Math.floor(hours / 24)}일`; + } + + return { + id: r.id, + rate: r.rate, + content: r.content, + createdAt: r.createdAt, + commissionTitle: r.request.commission.title, + workingTime: workingTime, + writer: { + nickname: r.user.nickname + } + }}); + + // 작가가 등록한 커미션 목록 + const commissions = await UserRepository.FetchArtistCommissions(artistId); + const commissionList = commissions.map(c=> ({ + id: c.id, + title: c.title, + summary: c.summary, + minPrice: c.minPrice, + category: c.category.name, + tags: c.commissionTags.map(t => t.tag.name), + thumbnail: c.thumbnailImage // 컬럼 존재 시 + })); + + + const result = await UserRepository.getMyProfile(accountId); + + const badges = result.userBadges.map(userBadge => ({ + id: userBadge.id, + earnedAt: userBadge.earnedAt, + badge: userBadge.badge + })); + + + return { + ...profile, + reviews, + commissions:commissionList, + badges + } + } + } \ No newline at end of file diff --git a/src/user/user.routes.js b/src/user/user.routes.js index 1bbacb9..bcd94ca 100644 --- a/src/user/user.routes.js +++ b/src/user/user.routes.js @@ -1,5 +1,5 @@ import express from "express"; -import { addUser, userLogin, getUserProfile, UpdateMyprofile, AccessUserCategories, CheckUserNickname, FollowArtist, CancelArtistFollow, LookUserFollow } from "./controller/user.controller.js"; +import { addUser, userLogin, getUserProfile, UpdateMyprofile, AccessUserCategories, CheckUserNickname, FollowArtist, CancelArtistFollow, LookUserFollow, LookUserBadge } from "./controller/user.controller.js"; import { signJwt } from "../jwt.config.js"; import passport from "passport"; import { authenticate } from "../middlewares/auth.middleware.js"; @@ -153,6 +153,7 @@ router.get( // 사용자 프로필 조회 router.get("/me", authenticate, getUserProfile); + /** * 사용자별 리뷰 목록 조회 API * GET /api/users/:userId/reviews @@ -181,5 +182,8 @@ router.delete("/follows/:artistId", authenticate, CancelArtistFollow); // 사용자가 팔로우하는 작가 조회하기 router.get("/follows", authenticate, LookUserFollow); +// 사용자의 뱃지 조회하기 +router.get("/badges", authenticate, LookUserBadge); + export default router; \ No newline at end of file