Skip to content

Commit

Permalink
Merge pull request #42 from DevKor-github/feature/club
Browse files Browse the repository at this point in the history
Feature/club
  • Loading branch information
JeongYeonSeung authored Jul 19, 2024
2 parents 2d3a1e6 + f69d8bb commit e449789
Show file tree
Hide file tree
Showing 14 changed files with 689 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { AuthModule } from './auth/auth.module';
import { CacheModule } from '@nestjs/cache-manager';
import { FriendshipModule } from './friendship/friendship.module';
import { ScheduleModule } from './schedule/schedule.module';
import { ClubModule } from './home/club/club.module';
import { CommonModule } from './common/common.module';
import { CourseReviewModule } from './course-review/course-review.module';
import { BoardModule } from './community/board/board.module';
Expand Down Expand Up @@ -56,6 +57,7 @@ console.log(`.env.${process.env.NODE_ENV}`);
BoardModule,
PostModule,
CommentModule,
ClubModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
46 changes: 46 additions & 0 deletions src/decorators/to-boolean.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Transform } from 'class-transformer';

function ToBoolean() {
const toPlain = Transform(
({ value }) => {
return value;
},
{
toPlainOnly: true,
},
);

const toClass = (target: any, key: string) => {
return Transform(
({ obj }) => {
return valueToBoolean(obj[key]);
},
{
toClassOnly: true,
},
)(target, key);
};

return function (target: any, key: string) {
toPlain(target, key);
toClass(target, key);
};
}

function valueToBoolean(value: any) {
if (value === null || value === undefined) {
return undefined;
}
if (typeof value === 'boolean') {
return value;
}
if (['true', 'yes', '1'].includes(value.toLowerCase())) {
return true;
}
if (['false', 'no', '0'].includes(value.toLowerCase())) {
return false;
}
return value;
}

export { ToBoolean };
29 changes: 29 additions & 0 deletions src/entities/club-like.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { CommonEntity } from './common.entity';
import { ClubEntity } from './club.entity';
import { UserEntity } from './user.entity';

@Entity('club_like')
@Index(['club', 'user'], { unique: true })
export class ClubLikeEntity extends CommonEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
id: number;

@ManyToOne(() => ClubEntity, (club) => club.clubLikes, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'clubId' })
club: ClubEntity;

@ManyToOne(() => UserEntity, (user) => user.clubLikes, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userId' })
user: UserEntity;
}
36 changes: 36 additions & 0 deletions src/entities/club.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { CommonEntity } from './common.entity';
import { ClubLikeEntity } from './club-like.entity';

@Entity('club')
export class ClubEntity extends CommonEntity {
@PrimaryGeneratedColumn({ type: 'bigint' })
id: number;

@Column({ type: 'varchar', nullable: false })
name: string;

@Column({ type: 'varchar', nullable: false })
summary: string;

@Column({ type: 'varchar', nullable: false })
category: string;

@Column({ type: 'varchar', nullable: false })
regularMeeting: string;

@Column({ type: 'varchar', nullable: false })
recruitmentPeriod: string;

@Column({ type: 'varchar', nullable: false })
description: string;

@OneToMany(() => ClubLikeEntity, (clubLike) => clubLike.club)
clubLikes: ClubLikeEntity[];

@Column({ default: 0 })
allLikes: number;

@Column({ type: 'varchar', nullable: false })
imageUrl: string;
}
4 changes: 4 additions & 0 deletions src/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { PostEntity } from './post.entity';
import { CommentEntity } from './comment.entity';
import { CourseReviewEntity } from './course-review.entity';
import { CourseReviewRecommendEntity } from './course-review-recommend.entity';
import { ClubLikeEntity } from './club-like.entity';

@Entity('user')
export class UserEntity extends CommonEntity {
Expand Down Expand Up @@ -95,4 +96,7 @@ export class UserEntity extends CommonEntity {
{ cascade: true },
)
courseReviewRecommends: CourseReviewRecommendEntity[];

@OneToMany(() => ClubLikeEntity, (clubLike) => clubLike.user)
clubLikes: ClubLikeEntity[];
}
43 changes: 43 additions & 0 deletions src/home/club/club-like.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { ClubLikeEntity } from 'src/entities/club-like.entity';
import { DataSource, Repository } from 'typeorm';

@Injectable()
export class ClubLikeRepository extends Repository<ClubLikeEntity> {
constructor(dataSource: DataSource) {
super(ClubLikeEntity, dataSource.createEntityManager());
}

async findTopLikedClubsInfo(): Promise<
{ clubId: number; likeCount: number }[]
> {
// oneWeekAgo를 일주일 전의 Date로 저장
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);

// 일주일 간 좋아요 개수가 가장 많은 동아리 4개 반환, 좋아요 개수가 같은 경우 랜덤 선택
const topClubLikes = await this.createQueryBuilder('club_like')
.select('club_like.clubId')
.addSelect('COUNT(club_like.id)', 'likeCount')
.where('club_like.createdAt >= :oneWeekAgo', { oneWeekAgo })
.groupBy('club_like.clubId')
.orderBy('likeCount', 'DESC')
.addOrderBy('RAND()')
.limit(4)
.getRawMany();

return topClubLikes;
}

async findLikedClubCategories(userId: number): Promise<string[]> {
// 좋아요 누른 동아리의 카테고리 정보
const likedClubCategories = await this.createQueryBuilder('club_like')
.leftJoinAndSelect('club_like.club', 'club')
.where('club_like.user.id = :userId', { userId })
.select('club.category')
.distinct(true)
.getRawMany();

return likedClubCategories.map((clubLike) => clubLike.club_category);
}
}
119 changes: 119 additions & 0 deletions src/home/club/club.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ClubService } from './club.service';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { User } from 'src/decorators/user.decorator';
import { AuthorizedUserDto } from 'src/auth/dto/authorized-user-dto';
import { GetClubResponseDto } from './dto/get-club-response.dto';
import { ClubSearchQueryDto } from './dto/club-search-query.dto';
import { GetHotClubResponseDto } from './dto/get-hot-club-response.dto';
import { GetRecommendClubResponseDto } from './dto/get-recommend-club-response.dto';

@Controller('club')
@ApiTags('club')
@ApiBearerAuth('accessToken')
@UseGuards(JwtAuthGuard)
export class ClubController {
constructor(private readonly clubService: ClubService) {}

@Get()
@ApiOperation({
summary: '동아리 목록 조회',
description:
'동아리 전체 목록을 조회하거나, 좋아요 여부, 소속/분과, 검색어(동아리명, 동아리 요약)로 필터링 및 좋아요 순으로 정렬하여 조회합니다.',
})
@ApiQuery({
name: 'sortBy',
description: '정렬 방식 (좋아요 순 : like)',
required: false,
})
@ApiQuery({
name: 'wishList',
description: '좋아요 누른 동아리만 필터링 (true / false)',
required: false,
})
@ApiQuery({
name: 'category',
description: '소속/분과별 필터링',
required: false,
})
@ApiQuery({
name: 'keyword',
description: '동아리명/동아리 요약 검색 키워드',
required: false,
})
@ApiOkResponse({
description: '전체 혹은 필터링/정렬 된 동아리 목록 반환',
isArray: true,
type: GetClubResponseDto,
})
async getClubList(
@User() user: AuthorizedUserDto,
@Query() clubSearchQueryDto: ClubSearchQueryDto,
): Promise<GetClubResponseDto[]> {
const userId = user.id;
return await this.clubService.getClubList(userId, clubSearchQueryDto);
}

@Post('/like/:clubId')
@ApiOperation({
summary: '동아리 좋아요 등록/해제',
description:
'이미 동아리 좋아요 눌러져 있으면 해제, 그렇지 않다면 좋아요 등록',
})
@ApiParam({
description: '좋아요 누를 동아리 id',
name: 'clubId',
type: Number,
})
@ApiCreatedResponse({
description: '좋아요 여부 및 좋아요 개수가 업데이트된 동아리 정보 반환',
type: GetClubResponseDto,
})
async toggleLikeClub(
@User() user: AuthorizedUserDto,
@Param('clubId') clubId: number,
): Promise<GetClubResponseDto> {
const userId = user.id;
return await this.clubService.toggleLikeClub(userId, clubId);
}

@Get('hot')
@ApiOperation({
summary: 'Hot Club 목록 조회',
description:
'최근 일주일 동안 좋아요 개수가 가장 많은 동아리 4개를 반환합니다.',
})
@ApiOkResponse({
description: 'Hot Club 목록 4개 반환',
isArray: true,
type: GetHotClubResponseDto,
})
async getHotClubList(): Promise<GetHotClubResponseDto[]> {
return await this.clubService.getHotClubList();
}

@Get('recommend')
@ApiOperation({
summary: 'Recommend Club 목록 조회',
description:
'최초에 무작위로 추천, 이후 좋아요를 누른 동아리가 있다면 그와 같은 카테고리 내에서 추천',
})
@ApiOkResponse({
description: 'Recommend Club 목록 4개 반환',
isArray: true,
type: GetRecommendClubResponseDto,
})
async getRecommendClubList(@User() user: AuthorizedUserDto) {
const userId = user.id;
return await this.clubService.getRecommendClubList(userId);
}
}
14 changes: 14 additions & 0 deletions src/home/club/club.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ClubService } from './club.service';
import { ClubController } from './club.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClubEntity } from 'src/entities/club.entity';
import { ClubRepository } from './club.repository';
import { ClubLikeRepository } from './club-like.repository';

@Module({
imports: [TypeOrmModule.forFeature([ClubEntity])],
providers: [ClubService, ClubRepository, ClubLikeRepository],
controllers: [ClubController],
})
export class ClubModule {}
Loading

0 comments on commit e449789

Please sign in to comment.