diff --git a/pages/api/v2/clubs/[uuid]/index.ts b/pages/api/v2/clubs/[uuid]/index.ts index e1c69cd..db98dad 100644 --- a/pages/api/v2/clubs/[uuid]/index.ts +++ b/pages/api/v2/clubs/[uuid]/index.ts @@ -1,10 +1,12 @@ import { NextApiRequest, NextApiResponse } from 'next' import { Provider } from 'server/provider' import { ClubService } from 'server/service/club.service' +import { UserService } from 'server/service/user.service' import { Club } from 'server/domain/model/Club' import { z, ZodIssue } from 'zod' import { ClubUuidParamsSchema } from 'src/lib/schemas/clubs' -import { NotFoundError } from 'server/domain/error' +import { BadRequestError, NotFoundError, UnauthorizedError } from 'server/domain/error' +import { resolveOptionalAuth } from 'server/util/optional-auth' export default async function handler( req: NextApiRequest, @@ -12,15 +14,30 @@ export default async function handler( ) { try { const clubService = Provider.getService(ClubService) + const userService = Provider.getService(UserService) if (req.method == 'GET') { const { uuid: ClubUuid } = ClubUuidParamsSchema.parse(req.query) - const club = await clubService.findPublicByUuid(ClubUuid) + const auth = await resolveOptionalAuth(req) + const serviceUserId = + auth.type === 'member' + ? await userService + .getUserByAccountId(auth.accountId) + .then((u) => u.serviceUserId) + .catch(() => null) + : null + const club = await clubService.findPublicByUuid(ClubUuid, serviceUserId) return res.status(200).json(club) } } catch (err) { if (err instanceof NotFoundError) { return res.status(404).send('club not found') } + if (err instanceof UnauthorizedError) { + return res.status(401).send('unauthorized') + } + if (err instanceof BadRequestError) { + return res.status(400).send(err.message) + } if (err instanceof z.ZodError) { return res.status(400).json(err.errors) } diff --git a/pages/api/v2/clubs/index.ts b/pages/api/v2/clubs/index.ts index a860d2c..384bfc0 100644 --- a/pages/api/v2/clubs/index.ts +++ b/pages/api/v2/clubs/index.ts @@ -1,7 +1,10 @@ import { NextApiRequest, NextApiResponse } from 'next' import { Provider } from 'server/provider' import { ClubService } from 'server/service/club.service' +import { UserService } from 'server/service/user.service' import { Club } from 'server/domain/model/Club' +import { resolveOptionalAuth } from 'server/util/optional-auth' +import { BadRequestError, UnauthorizedError } from 'server/domain/error' type ResponseData = { clubs: Club[] @@ -14,19 +17,34 @@ export default async function handler( ) { try { const clubService = Provider.getService(ClubService) + const userService = Provider.getService(UserService) if (req.method == 'GET') { const category = req.query.category as string if (!category) { return res.status(400).send('category is required') } - const clubs = await clubService.findByCategory(category) + const auth = await resolveOptionalAuth(req) + const serviceUserId = + auth.type === 'member' + ? await userService + .getUserByAccountId(auth.accountId) + .then((u) => u.serviceUserId) + .catch(() => null) + : null + const clubs = await clubService.findByCategory(category, serviceUserId) return res.status(200).json({ clubs: clubs, totalSize: clubs.length, }) } } catch (err) { + if (err instanceof UnauthorizedError) { + return res.status(401).send('unauthorized') + } + if (err instanceof BadRequestError) { + return res.status(400).send(err.message) + } console.error('listClubs error: ', err) return res.status(500).send('Internal Server Error') } diff --git a/pages/api/v2/clubs/latest/index.ts b/pages/api/v2/clubs/latest/index.ts index 6a9a4a6..5bbccee 100644 --- a/pages/api/v2/clubs/latest/index.ts +++ b/pages/api/v2/clubs/latest/index.ts @@ -1,7 +1,10 @@ import { NextApiRequest, NextApiResponse } from 'next' import { Provider } from 'server/provider' import { ClubService } from 'server/service/club.service' +import { UserService } from 'server/service/user.service' import { Club } from 'server/domain/model/Club' +import { resolveOptionalAuth } from 'server/util/optional-auth' +import { BadRequestError, UnauthorizedError } from 'server/domain/error' type ResponseData = { clubs: Club[] @@ -14,15 +17,30 @@ export default async function handler( ) { try { const clubService = Provider.getService(ClubService) + const userService = Provider.getService(UserService) if (req.method == 'GET') { - const clubs = await clubService.findLatestUploaded() + const auth = await resolveOptionalAuth(req) + const serviceUserId = + auth.type === 'member' + ? await userService + .getUserByAccountId(auth.accountId) + .then((u) => u.serviceUserId) + .catch(() => null) + : null + const clubs = await clubService.findLatestUploaded(undefined, serviceUserId) return res.status(200).json({ clubs: clubs, totalSize: clubs.length, }) } } catch (err) { + if (err instanceof UnauthorizedError) { + return res.status(401).send('unauthorized') + } + if (err instanceof BadRequestError) { + return res.status(400).send(err.message) + } console.error('listLatestClubs error: ', err) return res.status(500).send('Internal Server Error') } diff --git a/pages/api/v2/clubs/popular/index.ts b/pages/api/v2/clubs/popular/index.ts index 2056356..725ccb7 100644 --- a/pages/api/v2/clubs/popular/index.ts +++ b/pages/api/v2/clubs/popular/index.ts @@ -1,7 +1,10 @@ import { NextApiRequest, NextApiResponse } from 'next' import { Provider } from 'server/provider' import { ClubService } from 'server/service/club.service' +import { UserService } from 'server/service/user.service' import { Club } from 'server/domain/model/Club' +import { resolveOptionalAuth } from 'server/util/optional-auth' +import { BadRequestError, UnauthorizedError } from 'server/domain/error' type ResponseData = { clubs: Club[] @@ -14,15 +17,30 @@ export default async function handler( ) { try { const clubService = Provider.getService(ClubService) + const userService = Provider.getService(UserService) if (req.method == 'GET') { - const clubs = await clubService.findPopular() + const auth = await resolveOptionalAuth(req) + const serviceUserId = + auth.type === 'member' + ? await userService + .getUserByAccountId(auth.accountId) + .then((u) => u.serviceUserId) + .catch(() => null) + : null + const clubs = await clubService.findPopular(serviceUserId) return res.status(200).json({ clubs: clubs, totalSize: clubs.length, }) } } catch (err) { + if (err instanceof UnauthorizedError) { + return res.status(401).send('unauthorized') + } + if (err instanceof BadRequestError) { + return res.status(400).send(err.message) + } console.error('listPopularClubs error: ', err) return res.status(500).send('Internal Server Error') } diff --git a/pages/api/v2/clubs/recommendations/random/index.ts b/pages/api/v2/clubs/recommendations/random/index.ts index 4149a71..73c253d 100644 --- a/pages/api/v2/clubs/recommendations/random/index.ts +++ b/pages/api/v2/clubs/recommendations/random/index.ts @@ -1,7 +1,10 @@ import { NextApiRequest, NextApiResponse } from 'next' import { Provider } from 'server/provider' import { ClubService } from 'server/service/club.service' +import { UserService } from 'server/service/user.service' import { Club } from 'server/domain/model/Club' +import { resolveOptionalAuth } from 'server/util/optional-auth' +import { BadRequestError, UnauthorizedError } from 'server/domain/error' type ResponseData = { clubs: Club[] @@ -14,15 +17,30 @@ export default async function handler( ) { try { const clubService = Provider.getService(ClubService) + const userService = Provider.getService(UserService) if (req.method == 'GET') { - const clubs = await clubService.findRandomRecommendations(5) + const auth = await resolveOptionalAuth(req) + const serviceUserId = + auth.type === 'member' + ? await userService + .getUserByAccountId(auth.accountId) + .then((u) => u.serviceUserId) + .catch(() => null) + : null + const clubs = await clubService.findRandomRecommendations(5, serviceUserId) return res.status(200).json({ clubs: clubs, totalSize: clubs.length, }) } } catch (err) { + if (err instanceof UnauthorizedError) { + return res.status(401).send('unauthorized') + } + if (err instanceof BadRequestError) { + return res.status(400).send(err.message) + } console.error('listRandomRecommendedClubs error: ', err) return res.status(500).send('Internal Server Error') } diff --git a/pages/api/v2/clubs/search/index.ts b/pages/api/v2/clubs/search/index.ts index 3067ba3..9076cb7 100644 --- a/pages/api/v2/clubs/search/index.ts +++ b/pages/api/v2/clubs/search/index.ts @@ -1,6 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { Provider } from 'server/provider' import { SearchService } from 'server/service/search.service' +import { UserService } from 'server/service/user.service' import { Club } from 'server/domain/model/Club' import { MinActivityPeriodFilter, SearchFilters } from 'server/service/search/search.types' import { BadRequestError, UnauthorizedError } from 'server/domain/error' @@ -30,6 +31,14 @@ export default async function handler( if (req.method == 'GET') { const auth = await resolveOptionalAuth(req) + const userService = Provider.getService(UserService) + const serviceUserId = + auth.type === 'member' + ? await userService + .getUserByAccountId(auth.accountId) + .then((u) => u.serviceUserId) + .catch(() => null) + : null const query = req.query.query as string if (!query) { return res.status(400).send('query is required') @@ -46,7 +55,7 @@ export default async function handler( } const { clubs, correctedQuery, isTypoCorrected } = - await searchService.searchWithTypoCorrection(query, { filters }) + await searchService.searchWithTypoCorrection(query, { filters }, serviceUserId) await saveRecentSearchBestEffort(auth, query) return res.status(200).json({ clubs: clubs, diff --git a/server/domain/model/Club.ts b/server/domain/model/Club.ts index f417154..51db8c2 100644 --- a/server/domain/model/Club.ts +++ b/server/domain/model/Club.ts @@ -60,6 +60,7 @@ export type Club = { totalReviews: number reviewKeywords: ReviewKeyword[] latestComment: string + isSaved: boolean } export const toClubDomain = ( @@ -70,6 +71,7 @@ export const toClubDomain = ( reviewKeywords: ReviewKeyword[] latestComment: string }, + isSaved?: boolean, ): Club => ({ id: it.uuid, uuid: it.uuid, @@ -112,6 +114,7 @@ export const toClubDomain = ( totalReviews: review?.totalReviews ?? 0, reviewKeywords: review?.reviewKeywords ?? [], latestComment: review?.latestComment ?? '', + isSaved: isSaved ?? false, }) function encode(imageUri: string | undefined): string { diff --git a/server/service/club.service.ts b/server/service/club.service.ts index aa60cbf..bd454db 100644 --- a/server/service/club.service.ts +++ b/server/service/club.service.ts @@ -71,7 +71,7 @@ export class ClubService { @Inject(ClubAccessService) private readonly clubAccessService: ClubAccessService - async findByUuid(uuid: string): Promise { + async findByUuid(uuid: string, serviceUserId: string | null = null): Promise { this.userActivityLogRepository .insert({ type: UserActivityLogType.CALL_GET_CLUB_API, @@ -79,11 +79,14 @@ export class ClubService { }) .catch(console.error) const club = await this.clubAccessService.getExistingClub(uuid) - const clubReview = await this.getClubReviews([club.uuid]) - return toClubDomain(club, clubReview.get(club.uuid)) + const [clubReview, savedSet] = await Promise.all([ + this.getClubReviews([club.uuid]), + this.getSavedClubIdSet(serviceUserId, [club.uuid]), + ]) + return toClubDomain(club, clubReview.get(club.uuid), savedSet.has(club.uuid)) } - async findPublicByUuid(uuid: string): Promise { + async findPublicByUuid(uuid: string, serviceUserId: string | null = null): Promise { this.userActivityLogRepository .insert({ type: UserActivityLogType.CALL_GET_CLUB_API, @@ -91,8 +94,11 @@ export class ClubService { }) .catch(console.error) const club = await this.clubAccessService.getPublicClub(uuid) - const clubReview = await this.getClubReviews([club.uuid]) - return toClubDomain(club, clubReview.get(club.uuid)) + const [clubReview, savedSet] = await Promise.all([ + this.getClubReviews([club.uuid]), + this.getSavedClubIdSet(serviceUserId, [club.uuid]), + ]) + return toClubDomain(club, clubReview.get(club.uuid), savedSet.has(club.uuid)) } async findByAuthKey(authkey: string): Promise { @@ -120,7 +126,7 @@ export class ClubService { } } - async findByCategory(category: string): Promise { + async findByCategory(category: string, serviceUserId: string | null = null): Promise { this.userActivityLogRepository .insert({ type: UserActivityLogType.CALL_LIST_CLUBS_OF_CATEGORY_API, @@ -132,8 +138,14 @@ export class ClubService { status: PUBLIC_CLUB_STATUS, deletedAt: IsNull(), }) - const clubReview = await this.getClubReviews(entities.map((it) => it.uuid)) - const clubs = entities.map((it) => toClubDomain(it, clubReview.get(it.uuid))) + const uuids = entities.map((it) => it.uuid) + const [clubReview, savedSet] = await Promise.all([ + this.getClubReviews(uuids), + this.getSavedClubIdSet(serviceUserId, uuids), + ]) + const clubs = entities.map((it) => + toClubDomain(it, clubReview.get(it.uuid), savedSet.has(it.uuid)), + ) return sortByPopularAndEachRandom(clubs) } @@ -159,6 +171,15 @@ export class ClubService { return club.map((it) => toClubDomain(it)) } + async getSavedClubIdSet(serviceUserId: string | null, clubUuids: string[]): Promise> { + if (!serviceUserId || clubUuids.length === 0) return new Set() + const saved = await this.userSavedClubRepository.find({ + where: { serviceUserId, clubId: In(clubUuids) }, + select: ['clubId'], + }) + return new Set(saved.map((it) => it.clubId)) + } + async findMySavedClubs(serviceUserId: string): Promise { const savedClubs = await this.userSavedClubRepository.findBy({ serviceUserId }) const clubIds = Array.from(new Set(savedClubs.map((it) => it.clubId))) @@ -167,7 +188,7 @@ export class ClubService { status: PUBLIC_CLUB_STATUS, deletedAt: IsNull(), }) - return club.map((it) => toClubDomain(it)) + return club.map((it) => toClubDomain(it, undefined, true)) } async saveClubToMyCollection(serviceUserId: string, clubId: string) { @@ -179,7 +200,7 @@ export class ClubService { await this.userSavedClubRepository.delete({ serviceUserId, clubId }) } - async findPopular(): Promise { + async findPopular(serviceUserId: string | null = null): Promise { this.userActivityLogRepository .insert({ type: UserActivityLogType.CALL_LIST_POPULAR_CLUBS_API, @@ -191,12 +212,18 @@ export class ClubService { status: PUBLIC_CLUB_STATUS, deletedAt: IsNull(), }) - const clubReview = await this.getClubReviews(entities.map((it) => it.uuid)) - const clubs = entities.map((it) => toClubDomain(it, clubReview.get(it.uuid))) + const uuids = entities.map((it) => it.uuid) + const [clubReview, savedSet] = await Promise.all([ + this.getClubReviews(uuids), + this.getSavedClubIdSet(serviceUserId, uuids), + ]) + const clubs = entities.map((it) => + toClubDomain(it, clubReview.get(it.uuid), savedSet.has(it.uuid)), + ) return sortByPopularAndEachRandom(clubs) } - async findLatestUploaded(topN = 20): Promise { + async findLatestUploaded(topN = 20, serviceUserId: string | null = null): Promise { const entities = await this.clubRepository.find({ where: { status: PUBLIC_CLUB_STATUS, @@ -210,11 +237,15 @@ export class ClubService { }, take: topN, }) - const clubReview = await this.getClubReviews(entities.map((it) => it.uuid)) - return entities.map((it) => toClubDomain(it, clubReview.get(it.uuid))) + const uuids = entities.map((it) => it.uuid) + const [clubReview, savedSet] = await Promise.all([ + this.getClubReviews(uuids), + this.getSavedClubIdSet(serviceUserId, uuids), + ]) + return entities.map((it) => toClubDomain(it, clubReview.get(it.uuid), savedSet.has(it.uuid))) } - async findRandomRecommendations(limit = 5): Promise { + async findRandomRecommendations(limit = 5, serviceUserId: string | null = null): Promise { const entities = await this.clubRepository .createQueryBuilder('club') .where('club.status = :status', { status: PUBLIC_CLUB_STATUS }) @@ -223,8 +254,12 @@ export class ClubService { .take(limit) .getMany() - const clubReview = await this.getClubReviews(entities.map((it) => it.uuid)) - return entities.map((it) => toClubDomain(it, clubReview.get(it.uuid))) + const uuids = entities.map((it) => it.uuid) + const [clubReview, savedSet] = await Promise.all([ + this.getClubReviews(uuids), + this.getSavedClubIdSet(serviceUserId, uuids), + ]) + return entities.map((it) => toClubDomain(it, clubReview.get(it.uuid), savedSet.has(it.uuid))) } async registerClub(serviceUserId: string, body: ClubRegisterRequest): Promise { diff --git a/server/service/search.service.ts b/server/service/search.service.ts index ca83d5e..62efa4c 100644 --- a/server/service/search.service.ts +++ b/server/service/search.service.ts @@ -32,10 +32,11 @@ export class SearchService { async searchWithTypoCorrection( query: string, options: SearchOptions = { filters: {} }, + serviceUserId: string | null = null, ): Promise { this.searchLogService.logSearch(query) - const clubs = await this.runSearch(query, options) + const clubs = await this.runSearch(query, options, serviceUserId) if (clubs.length > 0) { return { clubs, @@ -46,7 +47,7 @@ export class SearchService { const correctedQuery = await this.typoCorrectionService.findCorrectedQuery(query) if (correctedQuery && correctedQuery !== query) { - const correctedClubs = await this.runSearch(correctedQuery, options) + const correctedClubs = await this.runSearch(correctedQuery, options, serviceUserId) if (correctedClubs.length > 0) { return { clubs: correctedClubs, @@ -63,9 +64,13 @@ export class SearchService { } } - private async runSearch(query: string, options: SearchOptions): Promise { + private async runSearch( + query: string, + options: SearchOptions, + serviceUserId: string | null = null, + ): Promise { const entities: ClubEntity[] = await this.searchQueryService.search(query, options.filters) - const clubs = await this.hydratorService.toClubs(entities) + const clubs = await this.hydratorService.toClubs(entities, serviceUserId) return this.sortService.sort(clubs, query, options.sort ?? DEFAULT_SEARCH_SORT) } } diff --git a/server/service/search/search-result-hydrator.service.ts b/server/service/search/search-result-hydrator.service.ts index f35dfc1..afa2ac6 100644 --- a/server/service/search/search-result-hydrator.service.ts +++ b/server/service/search/search-result-hydrator.service.ts @@ -8,13 +8,17 @@ export class SearchResultHydratorService { @Inject(ClubService) private readonly clubService: ClubService - async toClubs(entities: ClubEntity[]): Promise { + async toClubs(entities: ClubEntity[], serviceUserId: string | null = null): Promise { const uniqueEntities = this.dedupe(entities) if (uniqueEntities.length === 0) { return [] } - const reviews = await this.clubService.getClubReviews(uniqueEntities.map((it) => it.uuid)) - return uniqueEntities.map((it) => toClubDomain(it, reviews.get(it.uuid))) + const uuids = uniqueEntities.map((it) => it.uuid) + const [reviews, savedSet] = await Promise.all([ + this.clubService.getClubReviews(uuids), + this.clubService.getSavedClubIdSet(serviceUserId, uuids), + ]) + return uniqueEntities.map((it) => toClubDomain(it, reviews.get(it.uuid), savedSet.has(it.uuid))) } private dedupe(entities: ClubEntity[]): ClubEntity[] { diff --git a/src/lib/openapi/register-paths.ts b/src/lib/openapi/register-paths.ts index a9c1135..dfc3e06 100644 --- a/src/lib/openapi/register-paths.ts +++ b/src/lib/openapi/register-paths.ts @@ -754,8 +754,10 @@ registry.registerPath({ path: '/api/v2/clubs', tags: ['Clubs'], summary: '카테고리별 동아리 목록', + security: [{ bearerAuth: [] }, { guestIdAuth: [] }], request: { query: ClubListByCategoryQuerySchema, + headers: GuestIdHeaderSchema, }, responses: { 200: { @@ -767,7 +769,7 @@ registry.registerPath({ }, }, 400: { - description: 'category 쿼리가 필요합니다.', + description: 'category 쿼리가 필요하거나 비회원 x-guest-id header가 잘못되었습니다.', content: { 'text/plain': { schema: ErrorMessageSchema, @@ -783,6 +785,10 @@ registry.registerPath({ path: '/api/v2/clubs/latest', tags: ['Clubs'], summary: '최신 등록 동아리 목록', + security: [{ bearerAuth: [] }, { guestIdAuth: [] }], + request: { + headers: GuestIdHeaderSchema, + }, responses: { 200: { description: '조회 성공', @@ -792,6 +798,10 @@ registry.registerPath({ }, }, }, + 400: { + description: '비회원 x-guest-id header가 잘못되었습니다.', + content: { 'text/plain': { schema: ErrorMessageSchema } }, + }, 500: internalServerErrorResponse, }, }) @@ -801,6 +811,10 @@ registry.registerPath({ path: '/api/v2/clubs/popular', tags: ['Clubs'], summary: '인기 동아리 목록', + security: [{ bearerAuth: [] }, { guestIdAuth: [] }], + request: { + headers: GuestIdHeaderSchema, + }, responses: { 200: { description: '조회 성공', @@ -810,6 +824,10 @@ registry.registerPath({ }, }, }, + 400: { + description: '비회원 x-guest-id header가 잘못되었습니다.', + content: { 'text/plain': { schema: ErrorMessageSchema } }, + }, 500: internalServerErrorResponse, }, }) @@ -820,6 +838,10 @@ registry.registerPath({ tags: ['Clubs'], summary: '랜덤 추천 동아리 목록', description: '검색 결과가 없을 때 노출할 공개 상태 동아리를 랜덤으로 최대 5개 추천합니다.', + security: [{ bearerAuth: [] }, { guestIdAuth: [] }], + request: { + headers: GuestIdHeaderSchema, + }, responses: { 200: { description: '조회 성공', @@ -829,6 +851,10 @@ registry.registerPath({ }, }, }, + 400: { + description: '비회원 x-guest-id header가 잘못되었습니다.', + content: { 'text/plain': { schema: ErrorMessageSchema } }, + }, 500: internalServerErrorResponse, }, }) @@ -929,8 +955,10 @@ registry.registerPath({ path: '/api/v2/clubs/{uuid}', tags: ['Clubs'], summary: '동아리 상세 조회', + security: [{ bearerAuth: [] }, { guestIdAuth: [] }], request: { params: ClubUuidParamsSchema, + headers: GuestIdHeaderSchema, }, responses: { 200: { diff --git a/src/lib/schemas/common.ts b/src/lib/schemas/common.ts index 9e85c05..28572e5 100644 --- a/src/lib/schemas/common.ts +++ b/src/lib/schemas/common.ts @@ -143,6 +143,7 @@ export const ClubSchema = z totalReviews: z.number().int(), reviewKeywords: z.array(ReviewKeywordSchema), latestComment: z.string(), + isSaved: z.boolean(), }) .openapi('Club')