Skip to content

Commit

Permalink
Merge pull request #13 from Together42/10-together-event-refactoring
Browse files Browse the repository at this point in the history
feat: together event refactoring
  • Loading branch information
seo-wo authored Nov 22, 2023
2 parents af2a085 + 178971a commit f0dec6c
Show file tree
Hide file tree
Showing 45 changed files with 2,616 additions and 399 deletions.
19 changes: 14 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,29 @@
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
"@nestjs/cqrs": "^10.2.6",
"@nestjs/jwt": "^10.1.1",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^3.0.0",
"@nestjs/swagger": "^7.1.14",
"@nestjs/typeorm": "^10.0.0",
"@types/cron": "^2.4.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cron": "3.0.0",
"dotenv": "^16.3.1",
"mysql2": "^3.6.3",
"nestjs-slack": "^2.0.0",
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"typeorm-naming-strategies": "^4.1.0"
"@nestjs/swagger": "^7.1.14",
"@nestjs/typeorm": "^10.0.0",
"mysql2": "^3.6.3",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"typeorm": "^0.3.17"
"slack-block-builder": "^2.7.2",
"typeorm": "^0.3.17",
"typeorm-naming-strategies": "^4.1.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
Expand Down
14 changes: 12 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MeetupsModule } from './meetups/meetups.module';
import { BatchModule } from './batch/batch.module';
import { SlackModule } from 'nestjs-slack';
import { CqrsModule } from '@nestjs/cqrs';
import { AuthModule } from './auth/auth.module';
import { UserModule } from './user/user.module';
import { EventsModule } from './events/events.module';
import configuration from './config/configuration';

@Module({
Expand All @@ -16,6 +19,12 @@ import configuration from './config/configuration';
isGlobal: true,
load: [configuration],
}),
CqrsModule.forRoot(),
SlackModule.forRoot({
type: 'api',
token: process.env.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN,
isGlobal: true,
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
Expand All @@ -33,7 +42,8 @@ import configuration from './config/configuration';
}),
AuthModule,
UserModule,
EventsModule,
MeetupsModule,
BatchModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
16 changes: 16 additions & 0 deletions src/batch/batch.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { BatchService } from './batch.service';
import { MeetupsService } from 'src/meetups/meetups.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MeetupEntity } from 'src/meetups/entity/meetup.entity';
import { MeetupAttendeeEntity } from 'src/meetups/entity/meetup-attendee.entity';

@Module({
imports: [
ScheduleModule.forRoot(),
TypeOrmModule.forFeature([MeetupEntity, MeetupAttendeeEntity]),
],
providers: [BatchService, MeetupsService],
})
export class BatchModule {}
70 changes: 70 additions & 0 deletions src/batch/batch.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
import { CreateMeetupDto } from 'src/meetups/dto/create-meetup.dto';
import { MeetupsService } from 'src/meetups/meetups.service';
import { CronJob } from 'cron';
import { MeetupCategory } from 'src/meetups/enum/meetup-category.enum';

@Injectable()
export class BatchService {
constructor(
private schedulerReistry: SchedulerRegistry,
private meetupsService: MeetupsService,
) {}
private readonly logger = new Logger(BatchService.name);

@Cron('0 14 * * 1', { name: 'createWeeklyMeeting', timeZone: 'Asia/Seoul' })
async createWeeklyMeeting() {
const meetingDate = new Date();
meetingDate.setDate(meetingDate.getDate() + 2);
const meetingMonth = meetingDate.getMonth() + 1;
const meetingDay = meetingDate.getDate();

const createMeetupDto: CreateMeetupDto = {
title: `[주간회의] ${meetingMonth}${meetingDay}일`,
description: '매 주 생성되는 정기 회의입니다.',
categoryId: MeetupCategory.WEEKLY_MEETUP,
};
const { meetupId } = await this.meetupsService.create(createMeetupDto);

// 생성한 이벤트 마감 크론잡 등록 및 실행
const cronJob = CronJob.from({
cronTime: '0 17 * * 3',
timeZone: 'Asia/Seoul',
onTick: async () => {
try {
await this.meetupsService.createMatching({ meetupId, teamNum: 1 });
} catch (e) {
this.logger.error('[createWeeklyMeeting]', e);
}
},
});
this.schedulerReistry.addCronJob('matchWeeklyMeeting', cronJob);
cronJob.start();
}

@Cron('0 14 * * 3', { name: 'createWeeklyDinner', timeZone: 'Asia/Seoul' })
async createWeeklyDinner() {
const createMeetupDto: CreateMeetupDto = {
title: '[주간 식사] 회의 끝나고 같이 저녁 드실 분~',
description: '금일 오후 6시에 자동 마감됩니다.',
categoryId: MeetupCategory.WEEKLY_MEETUP,
};
const { meetupId } = await this.meetupsService.create(createMeetupDto);

// 생성한 이벤트 마감 크론잡 등록 및 실행
const cronJob = CronJob.from({
cronTime: '0 18 * * 3',
timeZone: 'Asia/Seoul',
onTick: async () => {
try {
await this.meetupsService.createMatching({ meetupId, teamNum: 1 });
} catch (e) {
this.logger.error('[createWeeklyDinner]', e);
}
},
});
this.schedulerReistry.addCronJob('matchWeeklyDinner', cronJob);
cronJob.start();
}
}
86 changes: 86 additions & 0 deletions src/common/dto/error-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
HttpExceptionBody,
HttpExceptionBodyMessage,
HttpStatus,
} from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { STATUS_CODES } from 'http';

/**
* 5XX번대 서버 에러
*/
export class ServerExceptionBody implements Omit<HttpExceptionBody, 'error'> {
@ApiProperty({
oneOf: [
{ type: 'string', example: '에러 내용' },
{ type: 'string[]', example: ['에러내용1', '에러내용2'] },
],
})
message: HttpExceptionBodyMessage;

@ApiProperty()
statusCode: number;
}

/**
* 4XX번대 클라이언트 에러
*/
export class ClientExceptionBody implements HttpExceptionBody {
@ApiProperty({
oneOf: [
{ type: 'string', example: '에러 내용' },
{ type: 'string[]', example: ['에러내용1', '에러내용2'] },
],
})
message: HttpExceptionBodyMessage;

@ApiProperty()
statusCode: number;

@ApiProperty()
error: string;
}

export class InternalServerExceptionBody extends ServerExceptionBody {
@ApiProperty({
type: 'string',
example: STATUS_CODES[HttpStatus.INTERNAL_SERVER_ERROR],
})
message = STATUS_CODES[HttpStatus.INTERNAL_SERVER_ERROR]!;

@ApiProperty({ type: 'number', example: HttpStatus.INTERNAL_SERVER_ERROR })
statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
}

export class NotFoundExceptionBody extends ClientExceptionBody {
@ApiProperty({
type: 'string',
example: STATUS_CODES[HttpStatus.NOT_FOUND],
})
error = STATUS_CODES[HttpStatus.FORBIDDEN]!;

@ApiProperty({ type: Number, example: HttpStatus.NOT_FOUND })
statusCode = HttpStatus.NOT_FOUND;
}

export class ForbiddenExceptionBody extends ClientExceptionBody {
@ApiProperty({
type: 'string',
example: STATUS_CODES[HttpStatus.FORBIDDEN],
})
error = STATUS_CODES[HttpStatus.FORBIDDEN]!;

@ApiProperty({ type: Number, example: HttpStatus.FORBIDDEN })
statusCode = HttpStatus.FORBIDDEN;
}

export class BadRequestExceptionBody extends ClientExceptionBody {
@ApiProperty({
type: 'string',
example: STATUS_CODES[HttpStatus.BAD_REQUEST],
})
error = STATUS_CODES[HttpStatus.BAD_REQUEST]!;

@ApiProperty({ type: 'number', example: HttpStatus.BAD_REQUEST })
statusCode = HttpStatus.BAD_REQUEST;
}
8 changes: 8 additions & 0 deletions src/common/enum/error-message.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export enum ErrorMessage {
NO_PERMISSION = '권한이 없습니다.',
MEETUP_NOT_FOUND = '존재하지 않는 이벤트입니다.',
MEETUP_NOT_FOUND_OR_CLOSED = '존재하지 않거나 마감된 이벤트입니다.',
MEETUP_REGISTRATION_NOT_FOUND = '해당 이벤트에 신청한 내역이 없습니다.',
MEETUP_REGISTRATION_ALREADY_EXIST = '이미 신청한 이벤트 입니다.',
TOO_MANY_MEETUP_TEAM_NUMBER = '신청 인원보다 팀 개수가 많습니다.',
}
141 changes: 141 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { HttpException } from '@nestjs/common';
import { Message, Blocks, bold, Attachment } from 'slack-block-builder';
import { MeetupDetailDto } from '../meetups/dto/meetup-detail.dto';
import { MeetupDto } from 'src/meetups/dto/meetup.dto';

/**
* Fisher-Yates shuffle 방식으로 배열의 요소들을 랜덤으로 섞어줍니다.
* @param array 모든 타입의 배열을 받습니다.
*/
export const shuffleArray = (array: any[]) => {
for (let idx = 0; idx < array.length; idx++) {
const randomIdx = Math.floor(Math.random() * (idx + 1));
[array[idx], array[randomIdx]] = [array[randomIdx], array[idx]];
}
};

export const isHttpException = (error: any): error is HttpException => {
if ('response' in error && 'status' in error) {
return true;
}
return false;
};

/**
* 이벤트 생성시 보내지는 슬랙봇 메시지
*/
export const meetupCreatedMessage = ({
channel,
meetup,
}: {
channel: string;
meetup: Pick<MeetupDto, 'title' | 'description'>;
}) => {
const { title, description } = meetup;
return Message({ text: `[친해지길 바라] ${title}`, channel })
.blocks(
Blocks.Header({
text: ':fire: 친해지길 바라 :fire:',
}),
Blocks.Divider(),
Blocks.Section({
text: `${bold(
title,
)}\n${description}\n이벤트가 생성되었습니다. 서둘러 참석해주세요!`,
}),
Blocks.Section({
text: process.env.TOGETHER_HOME_URL,
}),
)
.buildToObject();
};

/**
* 이벤트 매칭시 보내지는 슬랙봇 메시지
*/
export const meetupMatchedMessage = ({
channel,
meetupDetail,
}: {
channel: string;
meetupDetail: MeetupDetailDto;
}) => {
const { event, teamList } = meetupDetail;
const teams = Object.entries(teamList).map(([teamNum, teamMembers]) => {
const teamMembersNickname = teamMembers.map((member) => member.intraId);
return `${teamNum}팀 : ${teamMembersNickname.join(', ')}`;
});
return Message({ text: `[친해지길 바라] ${event.title}`, channel })
.blocks(
Blocks.Header({
text: ':fire: 친해지길 바라 :fire:',
}),
Blocks.Divider(),
Blocks.Section({
text: `${bold(event.title)}`,
}),
Blocks.Section({
text: `${teams.join('\n')}`,
}),
Blocks.Section({
text: `팀 매칭이 완료되었습니다. 자신의 팀원들과 연락해보세요! :raised_hands:`,
}),
)
.buildToObject();
};

/**
* 이벤트 신청시 보내지는 슬랙봇 메시지
*/
export const meetupRegisteredMessage = ({
channel,
meetup,
}: {
channel: string;
meetup: Pick<MeetupDto, 'title' | 'description'>;
}) => {
const { title } = meetup;
return Message({ channel, text: '[친해지길 바라] 이벤트 신청 완료' })
.blocks(Blocks.Header({ text: '친해지길 바라' }))
.attachments(
Attachment()
.color('#36a64f')
.blocks(
Blocks.Section({
text: `${bold(title)}`,
}),
Blocks.Section({
text: `이벤트 신청이 완료 되었습니다.\n참여 감사합니다 :laughing:`,
}),
),
)
.buildToObject();
};

/**
* 이벤트 신청 취소시 보내지는 슬랙봇 메시지
*/
export const meetupUnregisteredMessage = ({
channel,
meetup,
}: {
channel: string;
meetup: Pick<MeetupDto, 'title' | 'description'>;
}) => {
const { title } = meetup;
return Message({ channel, text: '[친해지길 바라] 이벤트 신청 취소' })
.blocks(Blocks.Header({ text: '친해지길 바라' }))
.attachments(
Attachment()
.color('#f2c744')
.blocks(
Blocks.Section({
text: `${bold(title)}`,
}),
Blocks.Section({
text: `이벤트 신청이 취소 되었습니다.\n아쉽네요.. 다음에 다시 만나요! :face_holding_back_tears:`,
}),
),
)
.buildToObject();
};
Loading

0 comments on commit f0dec6c

Please sign in to comment.