From d786e46c3d4fd6633d52b5c45ddca49fa0d84531 Mon Sep 17 00:00:00 2001 From: six-standard Date: Wed, 25 Jun 2025 14:42:58 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feature:=20=EC=84=BC=ED=8A=B8=EB=A6=AC=20?= =?UTF-8?q?Slack=20=EC=95=8C=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/slack.controller.ts | 165 +++++++ src/controllers/webhook.controller.ts | 109 +++++ src/routes/index.ts | 6 + src/routes/slack.router.ts | 90 ++++ src/routes/webhook.router.ts | 58 +++ src/services/sentry.service.ts | 90 ++++ src/services/slack.service.ts | 236 ++++++++++ src/types/dto/requests/slackRequest.type.ts | 3 + src/types/dto/responses/slackResponse.type.ts | 6 + src/types/index.ts | 38 ++ src/types/models/Sentry.type.ts | 71 +++ src/types/models/Slack.type.ts | 87 ++++ src/utils/sentry.util.ts | 63 +++ src/utils/slack.util.ts | 431 ++++++++++++++++++ 14 files changed, 1453 insertions(+) create mode 100644 src/controllers/slack.controller.ts create mode 100644 src/controllers/webhook.controller.ts create mode 100644 src/routes/slack.router.ts create mode 100644 src/routes/webhook.router.ts create mode 100644 src/services/sentry.service.ts create mode 100644 src/services/slack.service.ts create mode 100644 src/types/dto/requests/slackRequest.type.ts create mode 100644 src/types/dto/responses/slackResponse.type.ts create mode 100644 src/types/models/Sentry.type.ts create mode 100644 src/types/models/Slack.type.ts create mode 100644 src/utils/sentry.util.ts create mode 100644 src/utils/slack.util.ts diff --git a/src/controllers/slack.controller.ts b/src/controllers/slack.controller.ts new file mode 100644 index 0000000..50da8a5 --- /dev/null +++ b/src/controllers/slack.controller.ts @@ -0,0 +1,165 @@ +import { NextFunction, Request, RequestHandler, Response } from 'express'; +import { SlackService } from '@/services/slack.service'; +import { SentryService } from '@/services/sentry.service'; +import logger from '@/configs/logger.config'; +import { PermissionCheckResponseDto, SlackSuccessResponseDto } from '@/types'; +import { SentryActionData, SentryApiAction } from '@/types/models/Sentry.type'; +import { getNewStatusFromAction } from '@/utils/sentry.util'; + +export class SlackController { + constructor( + private slackService: SlackService, + private sentryService: SentryService, + ) {} + + checkPermissions: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const permissions = await this.slackService.checkPermissions(); + const response = new PermissionCheckResponseDto(true, 'Slack 권한 확인 완료', permissions, null); + res.status(200).json(response); + } catch (error) { + logger.error('Slack 권한 확인 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); + next(error); + } + }; + + testBot: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + if (!this.slackService.hasBotToken() && !this.slackService.hasWebhookUrl()) { + const response = new SlackSuccessResponseDto( + false, + 'SLACK_BOT_TOKEN 또는 SLACK_WEBHOOK_URL 환경 변수가 설정되지 않았습니다.', + {}, + 'MISSING_SLACK_CONFIG' + ); + res.status(400).json(response); + return; + } + + const testMessage = { + text: '🤖 봇 테스트 메시지입니다!', + attachments: [ + { + color: 'good', + fields: [ + { + title: '테스트 결과', + value: '✅ Slack 연동이 정상적으로 작동합니다.', + short: false, + }, + ], + footer: `테스트 시간: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`, + }, + ], + }; + + await this.slackService.sendMessage(testMessage); + const response = new SlackSuccessResponseDto(true, '봇 테스트 메시지 전송 완료!', {}, null); + res.status(200).json(response); + } catch (error) { + logger.error('봇 테스트 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); + next(error); + } + }; + + handleInteractive: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const payload = JSON.parse(req.body.payload); + + if (payload.type === 'interactive_message' && payload.actions && payload.actions[0]) { + const action = payload.actions[0]; + + if (action.name === 'sentry_action') { + const [actionType, issueId, organizationSlug, projectSlug] = action.value.split(':'); + + const actionData: SentryActionData = { + action: actionType as SentryApiAction, + issueId, + organizationSlug, + projectSlug, + }; + + if (actionData.issueId && actionData.organizationSlug && actionData.projectSlug) { + logger.info('Processing Sentry action:', actionData); + + const result = await this.sentryService.handleIssueAction(actionData); + + if (result.success) { + const updatedMessage = this.createSuccessMessage(actionData, payload.original_message || {}); + res.json(updatedMessage); + } else { + const errorMessage = this.createErrorMessage(result.error || 'Unknown error', payload.original_message || {}); + res.json(errorMessage); + } + return; + } + } + } + + res.json({ text: '❌ 잘못된 요청입니다.' }); + } catch (error) { + logger.error('Interactive 처리 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); + next(error); + } + }; + + private createSuccessMessage(actionData: SentryActionData, originalMessage: unknown): unknown { + const { action } = actionData; + + const updatedMessage = JSON.parse(JSON.stringify(originalMessage)); + + if (updatedMessage.attachments && updatedMessage.attachments[0]) { + const newStatus = getNewStatusFromAction(action); + const statusColors = { + 'resolved': 'good', + 'ignored': 'warning', + 'archived': '#808080', + 'unresolved': 'danger', + }; + + updatedMessage.attachments[0].color = statusColors[newStatus as keyof typeof statusColors] || 'good'; + + const statusMapping = { + 'resolved': 'RESOLVED', + 'ignored': 'IGNORED', + 'archived': 'ARCHIVED', + 'unresolved': 'UNRESOLVED', + }; + + const statusText = statusMapping[newStatus as keyof typeof statusMapping] || newStatus.toUpperCase(); + updatedMessage.attachments[0].footer = `✅ ${statusText} | 처리 완료: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`; + + delete updatedMessage.attachments[0].actions; + } + + return updatedMessage; + } + + private createErrorMessage(error: string, originalMessage: unknown): unknown { + const updatedMessage = JSON.parse(JSON.stringify(originalMessage)); + + if (updatedMessage.attachments && updatedMessage.attachments[0]) { + updatedMessage.attachments[0].fields.push({ + title: '❌ 오류 발생', + value: error, + short: false, + }); + + updatedMessage.attachments[0].color = 'danger'; + } + + return updatedMessage; + } +} \ No newline at end of file diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts new file mode 100644 index 0000000..864c1e6 --- /dev/null +++ b/src/controllers/webhook.controller.ts @@ -0,0 +1,109 @@ +import { NextFunction, Request, RequestHandler, Response } from 'express'; +import { SlackService } from '@/services/slack.service'; +import { SentryService } from '@/services/sentry.service'; +import { SentryWebhookData, SlackMessage } from '@/types'; +import logger from '@/configs/logger.config'; +import { formatSentryIssueForSlack, createStatusUpdateMessage } from '@/utils/slack.util'; + +export class WebhookController { + constructor( + private slackService: SlackService, + private sentryService: SentryService, + ) {} + + handleSentryWebhook: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const sentryData = req.body; + + const slackMessage = await this.formatSentryDataForSlack(sentryData); + + if (slackMessage === null) { + logger.info('기존 메시지 업데이트 완료, 새 메시지 전송 생략'); + res.status(200).json({ message: 'Webhook processed successfully' }); + return; + } + + const issueId = sentryData.data?.issue?.id; + await this.slackService.sendMessage(slackMessage, issueId); + + res.status(200).json({ message: 'Webhook processed successfully' }); + } catch (error) { + logger.error('Sentry webhook 처리 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); + next(error); + } + }; + + private async formatSentryDataForSlack(sentryData: SentryWebhookData): Promise { + const { action, data } = sentryData; + + if (action === 'resolved' || action === 'unresolved' || action === 'ignored') { + return await this.handleIssueStatusChange(sentryData); + } + + if (action === 'created' && data.issue) { + return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken()); + } + + return { + text: `🔔 Sentry 이벤트: ${action || 'Unknown action'}`, + attachments: [ + { + color: 'warning', + fields: [ + { + title: '이벤트 타입', + value: action || 'Unknown', + short: true, + }, + ], + }, + ], + }; + } + + private async handleIssueStatusChange(sentryData: SentryWebhookData): Promise { + const { data } = sentryData; + const issue = data.issue; + + if (!issue) { + logger.warn('이슈 정보가 없습니다:', sentryData); + return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken()); + } + + logger.info(`이슈 상태 변경 감지: ${issue.id} → ${sentryData.action}`); + + const messageInfo = this.slackService.getMessageInfo(issue.id); + + if (messageInfo) { + logger.info('기존 메시지 발견, 업데이트 시도'); + + try { + const updatedMessage = createStatusUpdateMessage( + sentryData, + this.sentryService.hasSentryToken() + ); + + await this.slackService.updateMessage( + messageInfo.channel, + messageInfo.ts, + updatedMessage + ); + + logger.info('기존 메시지 업데이트 완료'); + return null; + + } catch (error) { + logger.error('메시지 업데이트 실패, 새 메시지로 전송:', error instanceof Error ? error.message : '알 수 없는 오류'); + + } + } else { + logger.info('기존 메시지 없음, 새 메시지 생성'); + } + + return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken()); + } +} \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index 0406af2..f815c64 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -4,6 +4,9 @@ import PostRouter from './post.router'; import NotiRouter from './noti.router'; import LeaderboardRouter from './leaderboard.router'; import TotalStatsRouter from './totalStats.router'; +import WebhookRouter from './webhook.router'; +import SlackRouter from './slack.router'; + const router: Router = express.Router(); @@ -16,5 +19,8 @@ router.use('/', PostRouter); router.use('/', NotiRouter); router.use('/', LeaderboardRouter); router.use('/', TotalStatsRouter); +router.use('/', WebhookRouter); +router.use('/', SlackRouter); + export default router; diff --git a/src/routes/slack.router.ts b/src/routes/slack.router.ts new file mode 100644 index 0000000..91fbbc9 --- /dev/null +++ b/src/routes/slack.router.ts @@ -0,0 +1,90 @@ +import express, { Router } from 'express'; +import { SlackController } from '@/controllers/slack.controller'; +import { SentryService } from '@/services/sentry.service'; +import { SlackService } from '@/services/slack.service'; + +const router: Router = express.Router(); + +const slackService = new SlackService(); +const sentryService = new SentryService(); +const slackController = new SlackController(slackService, sentryService); + +/** + * @swagger + * /slack/check-permissions: + * get: + * summary: Slack 권한 확인 + * description: Slack Bot의 권한 상태를 확인합니다. + * tags: [Slack] + * responses: + * 200: + * description: 권한 확인 성공 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/PermissionCheckResponseDto' + * 400: + * description: Bot Token 미설정 + * 500: + * description: 서버 오류 + */ +router.get('/slack/check-permissions', slackController.checkPermissions); + +/** + * @swagger + * /slack/test-bot: + * post: + * summary: 봇 테스트 + * description: Slack Bot 테스트 메시지를 전송합니다. + * tags: [Slack] + * responses: + * 200: + * description: 테스트 메시지 전송 성공 + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SlackSuccessResponseDto' + * 400: + * description: Slack 연동 미설정 + * 500: + * description: 서버 오류 + */ +router.post('/slack/test-bot', slackController.testBot); + +/** + * @swagger + * /slack/interactive: + * post: + * summary: Slack Interactive Components 처리 + * description: Slack에서 전송되는 버튼 클릭 등의 상호작용을 처리합니다. + * tags: [Slack] + * requestBody: + * required: true + * content: + * application/x-www-form-urlencoded: + * schema: + * type: object + * properties: + * payload: + * type: string + * description: JSON 형태의 Slack payload (URL encoded) + * responses: + * 200: + * description: 상호작용 처리 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * text: + * type: string + * example: "버튼 클릭 처리 완료" + * response_type: + * type: string + * enum: [in_channel, ephemeral] + * 400: + * description: 잘못된 요청 + */ +router.post('/slack/interactive', slackController.handleInteractive); + +export default router; \ No newline at end of file diff --git a/src/routes/webhook.router.ts b/src/routes/webhook.router.ts new file mode 100644 index 0000000..993a4f1 --- /dev/null +++ b/src/routes/webhook.router.ts @@ -0,0 +1,58 @@ +import express, { Router } from 'express'; +import { WebhookController } from '@/controllers/webhook.controller'; +import { SentryService } from '@/services/sentry.service'; +import { SlackService } from '@/services/slack.service'; + +const router: Router = express.Router(); + +// 서비스 인스턴스 생성 +const sentryService = new SentryService(); +const slackService = new SlackService(); + +// 컨트롤러 인스턴스 생성 +const webhookController = new WebhookController(slackService, sentryService); + +/** + * @swagger + * /webhook/sentry: + * post: + * summary: Sentry webhook 처리 + * description: Sentry에서 전송되는 webhook 이벤트를 처리합니다. + * tags: [Webhook] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * action: + * type: string + * description: Sentry 액션 타입 + * enum: [created, resolved, unresolved, ignored] + * data: + * type: object + * properties: + * issue: + * type: object + * description: Sentry 이슈 정보 + * actor: + * type: object + * description: 액션을 수행한 사용자 정보 + * responses: + * 200: + * description: Webhook 처리 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "Webhook 처리 완료" + * 500: + * description: 서버 오류 + */ +router.post('/webhook/sentry', webhookController.handleSentryWebhook); + +export default router; \ No newline at end of file diff --git a/src/services/sentry.service.ts b/src/services/sentry.service.ts new file mode 100644 index 0000000..ba581df --- /dev/null +++ b/src/services/sentry.service.ts @@ -0,0 +1,90 @@ +import axios from 'axios'; +import { SentryActionData, SentryActionResult } from '@/types'; +import logger from '@/configs/logger.config'; + +export class SentryService { + private readonly sentryToken: string; + + constructor() { + this.sentryToken = process.env.SENTRY_AUTH_TOKEN || ''; + } + + + hasSentryToken(): boolean { + return !!this.sentryToken; + } + + async handleIssueAction(actionData: SentryActionData): Promise { + if (!this.sentryToken) { + return { success: false, error: 'Sentry 토큰이 설정되지 않았습니다.' }; + } + + try { + const { action, issueId, organizationSlug, projectSlug } = actionData; + const url = `https://sentry.io/api/0/projects/${organizationSlug}/${projectSlug}/issues/`; + + let data: Record; + + switch (action) { + case 'resolve': + data = { status: 'resolved' }; + break; + case 'ignore': + data = { status: 'ignored' }; + break; + case 'archive': + data = { status: 'ignored', statusDetails: { ignoreUntilEscalating: true } }; + break; + case 'delete': + return await this.deleteIssue(organizationSlug, projectSlug, issueId); + default: + return { success: false, error: `지원하지 않는 액션: ${action}` }; + } + + const response = await axios.put(`${url}`, { + issues: [issueId], + ...data, + }, { + headers: { + 'Authorization': `Bearer ${this.sentryToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.status === 200) { + logger.info(`Sentry 이슈 ${action} 성공:`, { issueId, action }); + return { success: true }; + } else { + throw new Error(`Unexpected response status: ${response.status}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`Sentry 이슈 ${actionData.action} 실패:`, errorMessage); + return { success: false, error: errorMessage }; + } + } + + private async deleteIssue(organizationSlug: string, projectSlug: string, issueId: string): Promise { + try { + const url = `https://sentry.io/api/0/projects/${organizationSlug}/${projectSlug}/issues/${issueId}/`; + + const response = await axios.delete(url, { + headers: { + 'Authorization': `Bearer ${this.sentryToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.status === 202 || response.status === 204) { + logger.info('Sentry 이슈 삭제 성공:', { issueId }); + return { success: true }; + } else { + throw new Error(`Unexpected response status: ${response.status}`); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Sentry 이슈 삭제 실패:', errorMessage); + return { success: false, error: errorMessage }; + } + } +} \ No newline at end of file diff --git a/src/services/slack.service.ts b/src/services/slack.service.ts new file mode 100644 index 0000000..6b8ebca --- /dev/null +++ b/src/services/slack.service.ts @@ -0,0 +1,236 @@ +import axios from 'axios'; +import { SlackMessage, SlackApiResponse, StoredMessageInfo, SlackPermissionsData } from '@/types'; +import { normalizeChannelId } from '@/utils/slack.util'; +import logger from '@/configs/logger.config'; + +const issueMessageMap = new Map(); + +export class SlackService { + private readonly webhookUrl: string; + private readonly botToken: string; + private readonly channelId: string; + + constructor() { + this.webhookUrl = process.env.SLACK_WEBHOOK_URL || ''; + this.botToken = process.env.SLACK_BOT_TOKEN || ''; + this.channelId = process.env.SLACK_CHANNEL_ID || '#general'; + + // 24시간마다 오래된 메시지 정보 정리 + setInterval(() => this.cleanupOldMessages(), 24 * 60 * 60 * 1000); + } + + hasBotToken(): boolean { + return !!this.botToken; + } + + hasWebhookUrl(): boolean { + return !!this.webhookUrl; + } + + storeMessageInfo(issueId: string, messageInfo: Omit): void { + issueMessageMap.set(issueId, { + ...messageInfo, + timestamp: Date.now(), + }); + } + + getMessageInfo(issueId: string): StoredMessageInfo | undefined { + return issueMessageMap.get(issueId); + } + + async checkPermissions(): Promise { + if (!this.botToken) { + return { + hasToken: false, + isValid: false, + permissions: [], + botInfo: null, + channelAccess: false, + recommendations: [ + 'SLACK_BOT_TOKEN 환경 변수를 설정해주세요.', + 'Slack 앱에서 Bot Token을 생성하고 적절한 권한을 부여해주세요.', + ], + }; + } + + try { + const authResponse = await axios.get('https://slack.com/api/auth.test', { + headers: { + 'Authorization': `Bearer ${this.botToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!authResponse.data.ok) { + throw new Error(authResponse.data.error || 'Token validation failed'); + } + + const channelId = normalizeChannelId(this.channelId); + + const channelResponse = await axios.get('https://slack.com/api/conversations.info', { + params: { channel: channelId }, + headers: { + 'Authorization': `Bearer ${this.botToken}`, + 'Content-Type': 'application/json', + }, + }); + + let historyAccess = false; + + try { + const historyResponse = await axios.get('https://slack.com/api/conversations.history', { + params: { channel: channelId, limit: 1 }, + headers: { + 'Authorization': `Bearer ${this.botToken}`, + 'Content-Type': 'application/json', + }, + }); + historyAccess = historyResponse.data.ok; + } catch (error) { + logger.error('History access check failed:', error); + } + + const permissions = [ + 'chat:write', + 'channels:read', + ...(historyAccess ? ['channels:history'] : []), + ]; + + const recommendations = []; + if (!channelResponse.data.ok) { + recommendations.push(`채널 ${this.channelId}에 대한 접근 권한이 없습니다. 봇을 채널에 초대해주세요.`); + } + if (!historyAccess) { + recommendations.push('메시지 업데이트 기능을 위해 channels:history 권한이 필요합니다.'); + } + + return { + hasToken: true, + isValid: authResponse.data.ok, + permissions, + botInfo: { + userId: authResponse.data.user_id, + username: authResponse.data.user, + teamId: authResponse.data.team_id, + teamName: authResponse.data.team, + }, + channelAccess: channelResponse.data.ok, + recommendations: recommendations.length > 0 ? recommendations : ['모든 권한이 정상적으로 설정되었습니다.'], + }; + } catch (error) { + logger.error('Slack 권한 확인 중 오류:', error); + return { + hasToken: true, + isValid: false, + permissions: [], + botInfo: null, + channelAccess: false, + recommendations: [ + 'Bot Token이 유효하지 않습니다.', + 'Slack 앱 설정을 확인하고 올바른 토큰을 사용해주세요.', + ], + }; + } + } + + async sendMessage(message: SlackMessage, issueId?: string): Promise { + // Interactive 기능이 있다면 Bot Token 사용, 없다면 Webhook 사용 + if (this.botToken) { + return await this.sendMessageWithBot(message, issueId); + } else if (this.webhookUrl) { + return await this.sendMessageWithWebhook(message); + } else { + throw new Error('Slack 설정이 없습니다. SLACK_BOT_TOKEN 또는 SLACK_WEBHOOK_URL을 설정해주세요.'); + } + } + + private async sendMessageWithWebhook(message: SlackMessage): Promise { + try { + const response = await axios.post(this.webhookUrl, message, { + headers: { 'Content-Type': 'application/json' }, + }); + + logger.info('Slack 메시지 전송 성공 (Webhook)'); + return { success: true, data: response.data }; + } catch (error) { + logger.error('Slack 메시지 전송 실패 (Webhook):', error instanceof Error ? error.message : '알 수 없는 오류'); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + private async sendMessageWithBot(message: SlackMessage, issueId?: string): Promise { + try { + const channelId = normalizeChannelId(this.channelId); + + const response = await axios.post('https://slack.com/api/chat.postMessage', { + channel: channelId, + ...message, + }, { + headers: { + 'Authorization': `Bearer ${this.botToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.data.ok) { + logger.info('Slack 메시지 전송 성공 (Bot)', { channel: channelId }); + + if (issueId && response.data.ts) { + this.storeMessageInfo(issueId, { + channel: channelId, + ts: response.data.ts, + }); + } + + return { success: true, data: response.data }; + } else { + throw new Error(response.data.error || 'Message send failed'); + } + } catch (error) { + logger.error('Slack 메시지 전송 실패 (Bot):', error instanceof Error ? error.message : '알 수 없는 오류'); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + async updateMessage(channel: string, ts: string, updatedMessage: SlackMessage): Promise { + if (!this.botToken) { + throw new Error('메시지 업데이트에는 Bot Token이 필요합니다.'); + } + + try { + const response = await axios.post('https://slack.com/api/chat.update', { + channel, + ts, + ...updatedMessage, + }, { + headers: { + 'Authorization': `Bearer ${this.botToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.data.ok) { + logger.info('Slack 메시지 업데이트 성공', { channel, ts }); + return { success: true, data: response.data }; + } else { + throw new Error(response.data.error || 'Message update failed'); + } + } catch (error) { + logger.error('Slack 메시지 업데이트 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + private cleanupOldMessages(): void { + const now = Date.now(); + const twentyFourHours = 24 * 60 * 60 * 1000; + + for (const [issueId, messageInfo] of issueMessageMap.entries()) { + if (now - messageInfo.timestamp > twentyFourHours) { + issueMessageMap.delete(issueId); + } + } + + logger.info(`오래된 메시지 정보 정리 완료. 현재 저장된 메시지: ${issueMessageMap.size}개`); + } +} \ No newline at end of file diff --git a/src/types/dto/requests/slackRequest.type.ts b/src/types/dto/requests/slackRequest.type.ts new file mode 100644 index 0000000..9638882 --- /dev/null +++ b/src/types/dto/requests/slackRequest.type.ts @@ -0,0 +1,3 @@ +export interface SlackInteractiveRequestBody { + payload: string; +} \ No newline at end of file diff --git a/src/types/dto/responses/slackResponse.type.ts b/src/types/dto/responses/slackResponse.type.ts new file mode 100644 index 0000000..cd413d1 --- /dev/null +++ b/src/types/dto/responses/slackResponse.type.ts @@ -0,0 +1,6 @@ +import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; +import { SlackPermissionsData } from '@/types/models/Slack.type'; + +export class PermissionCheckResponseDto extends BaseResponseDto {} + +export class SlackSuccessResponseDto extends BaseResponseDto> {} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 7a247d8..13c0285 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,5 +38,43 @@ export type { TotalStatsItem } from '@/types/dto/responses/totalStatsResponse.ty export { GetTotalStatsQueryDto } from '@/types/dto/requests/getTotalStatsQuery.type'; export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse.type'; +// Sentry 관련 +export type { + SentryIssuePriority, + SentryIssueStatus, + SentryAction, + SentryApiAction, +} from '@/types/models/Sentry.type'; +export type { + SentryProject, + SentryIssueMetadata, + SentryIssue, + SentryActor, + SentryWebhookData, + SentryActionData, + SentryActionResult, +} from '@/types/models/Sentry.type'; + +// Slack 관련 +export type { + SlackAttachmentField, + SlackAction, + SlackAttachment, + SlackMessage, + SlackInteractiveAction, + SlackInteractivePayload, + StoredMessageInfo, + SlackApiResponse, + SlackPermissionsData, +} from '@/types/models/Slack.type'; + +export type { + SlackInteractiveRequestBody, +} from '@/types/dto/requests/slackRequest.type'; +export { + PermissionCheckResponseDto, + SlackSuccessResponseDto, +} from '@/types/dto/responses/slackResponse.type'; + // Common export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type'; diff --git a/src/types/models/Sentry.type.ts b/src/types/models/Sentry.type.ts new file mode 100644 index 0000000..6520579 --- /dev/null +++ b/src/types/models/Sentry.type.ts @@ -0,0 +1,71 @@ + +export type SentryIssuePriority = 'high' | 'medium' | 'low'; + +export type SentryIssueStatus = 'resolved' | 'unresolved' | 'ignored'; + +export type SentryAction = 'created' | 'resolved' | 'unresolved' | 'ignored'; + +export interface SentryOrganization { + id: string; + slug: string; + name: string; +} + +export interface SentryProject { + id: string; + name: string; + slug: string; + platform?: string; + organization?: SentryOrganization; +} + +export interface SentryIssueMetadata { + value?: string; + type?: string; +} + +export interface SentryIssue { + id: string; + shortId?: string; + title: string; + culprit?: string; + metadata?: SentryIssueMetadata; + status?: SentryIssueStatus; + priority?: SentryIssuePriority; + count: number; + userCount: number; + firstSeen: string; + lastSeen?: string; + project?: SentryProject; + platform?: string; + permalink?: string; +} + +export interface SentryActor { + id: string; + name: string; + email?: string; +} + +export interface SentryWebhookData { + action: SentryAction; + data: { + issue: SentryIssue; + project?: SentryProject; + }; + actor?: SentryActor; +} + +export type SentryApiAction = 'resolve' | 'unresolve' | 'ignore' | 'archive' | 'unarchive' | 'delete'; + +export interface SentryActionData { + issueId: string; + organizationSlug: string; + projectSlug: string; + action: SentryApiAction; +} + +export interface SentryActionResult { + success: boolean; + error?: string; +} \ No newline at end of file diff --git a/src/types/models/Slack.type.ts b/src/types/models/Slack.type.ts new file mode 100644 index 0000000..eeded83 --- /dev/null +++ b/src/types/models/Slack.type.ts @@ -0,0 +1,87 @@ +export interface SlackAttachmentField { + title: string; + value: string; + short: boolean; +} + +export interface SlackAction { + type: 'button'; + text: string; + name?: string; + value?: string; + url?: string; + style?: 'default' | 'primary' | 'danger' | 'good'; + confirm?: { + title: string; + text: string; + ok_text: string; + dismiss_text: string; + }; +} + +export interface SlackAttachment { + callback_id?: string; + color?: string; + fields?: SlackAttachmentField[]; + actions?: SlackAction[]; + footer?: string; + footer_icon?: string; + ts?: number; + text?: string; + title?: string; + title_link?: string; + mrkdwn_in?: string[]; +} + +export interface SlackMessage { + text: string; + attachments?: SlackAttachment[]; + response_type?: 'in_channel' | 'ephemeral'; + [key: string]: unknown; +} + +export interface SlackInteractiveAction { + name: string; + value: string; + type: string; +} + +export interface SlackInteractivePayload { + type: string; + callback_id?: string; + actions?: SlackInteractiveAction[]; + original_message?: SlackMessage; + response_url?: string; + user?: { + id: string; + name: string; + }; +} + +export interface StoredMessageInfo { + channel: string; + ts: string; + timestamp: number; +} + +export interface SlackApiResponse { + success: boolean; + error?: string; + data?: unknown; +} + +export interface SlackBotInfo { + userId: string; + username: string; + teamId: string; + teamName: string; +} + +export interface SlackPermissionsData { + hasToken: boolean; + isValid: boolean; + permissions: string[]; + botInfo: SlackBotInfo | null; + channelAccess: boolean; + recommendations: string[]; +} \ No newline at end of file diff --git a/src/utils/sentry.util.ts b/src/utils/sentry.util.ts new file mode 100644 index 0000000..5d746d2 --- /dev/null +++ b/src/utils/sentry.util.ts @@ -0,0 +1,63 @@ +import { SentryActionData } from '@/types'; + +/** + * Sentry API 액션에 따른 새로운 상태를 반환하는 함수 + * @param action - Sentry API 액션 + * @returns 새로운 이슈 상태 + */ +export function getNewStatusFromAction(action: string): string { + const statusMap: Record = { + 'resolve': 'resolved', + 'ignore': 'ignored', + 'archive': 'archived', + 'delete': 'deleted', + }; + + return statusMap[action] || 'unresolved'; +} + +/** + * Sentry API 요청 데이터를 생성하는 함수 + * @param actionData - 액션 데이터 + * @returns API 요청을 위한 데이터와 메소드 + */ +export function prepareSentryApiRequest(actionData: SentryActionData): { + method: 'PUT' | 'DELETE'; + data?: { status: string }; +} { + const { action } = actionData; + + switch (action) { + case 'resolve': + return { method: 'PUT', data: { status: 'resolved' } }; + case 'unresolve': + return { method: 'PUT', data: { status: 'unresolved' } }; + case 'archive': + return { method: 'PUT', data: { status: 'ignored' } }; + case 'unarchive': + return { method: 'PUT', data: { status: 'unresolved' } }; + case 'delete': + return { method: 'DELETE' }; + default: + throw new Error('지원되지 않는 액션입니다.'); + } +} + +/** + * Sentry API URL을 생성하는 함수 + * @param issueId - 이슈 ID + * @returns Sentry API URL + */ +export function getSentryApiUrl(issueId: string): string { + return `https://sentry.io/api/0/issues/${issueId}/`; +} + +/** + * Sentry 이슈 URL을 생성하는 함수 + * @param issueId - 이슈 ID + * @param orgSlug - 조직 슬러그 (기본: velog-dashboardv2) + * @returns Sentry 이슈 상세 페이지 URL + */ +export function getSentryIssueUrl(issueId: string, orgSlug: string = 'velog-dashboardv2'): string { + return `https://sentry.io/organizations/${orgSlug}/issues/${issueId}/`; +} \ No newline at end of file diff --git a/src/utils/slack.util.ts b/src/utils/slack.util.ts new file mode 100644 index 0000000..fbb6874 --- /dev/null +++ b/src/utils/slack.util.ts @@ -0,0 +1,431 @@ +import { SlackMessage, SlackAction, SentryIssue, SentryWebhookData } from '@/types'; +import { SlackAttachment } from '@/types/models/Slack.type'; + +/** + * 날짜/시간을 상대적 또는 절대적 형식으로 포맷팅하는 함수 + * @param dateString - 포맷팅할 날짜 문자열 + * @returns 포맷팅된 날짜 문자열 + */ +export function formatDateTime(dateString?: string): string { + if (!dateString) return 'Unknown'; + + try { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + // 상대 시간 표시 + if (diffMins < 1) return '방금 전'; + if (diffMins < 60) return `${diffMins}분 전`; + if (diffHours < 24) return `${diffHours}시간 전`; + if (diffDays < 7) return `${diffDays}일 전`; + + // 절대 시간 표시 (한국 시간) + return date.toLocaleString('ko-KR', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return dateString; + } +} + +/** + * 이슈 상태별 액션 버튼을 생성하는 함수 + * @param issue - Sentry 이슈 정보 + * @param issueUrl - 이슈 상세 페이지 URL + * @param hasSentryToken - Sentry API 토큰 존재 여부 + * @returns Slack 액션 버튼 배열 + */ +export function generateIssueActions(issue: SentryIssue, issueUrl: string, hasSentryToken: boolean = false): SlackAction[] { + const actions: SlackAction[] = [ + { + type: 'button', + text: '🔍 Sentry에서 자세히 보기', + url: issueUrl, + style: 'primary' + } + ]; + + // Interactive 기능이 활성화된 경우에만 액션 버튼 추가 + if (!hasSentryToken) { + return actions; + } + + const issueStatus = issue.status || 'unresolved'; + const baseActionData = { + issueId: issue.id, + projectSlug: issue.project?.slug || 'unknown' + }; + + switch (issueStatus) { + case 'unresolved': + // 미해결 상태: 해결, 보관, 삭제 버튼 + actions.push( + { + type: 'button', + text: '✅ 문제 해결', + name: 'resolve_issue', + value: JSON.stringify({ ...baseActionData, action: 'resolve' }), + style: 'good', + confirm: { + title: '이슈 해결 확인', + text: '이 이슈를 해결됨으로 표시하시겠습니까?', + ok_text: '해결', + dismiss_text: '취소' + } + }, + { + type: 'button', + text: '📦 보관', + name: 'archive_issue', + value: JSON.stringify({ ...baseActionData, action: 'archive' }), + style: 'default', + confirm: { + title: '이슈 보관 확인', + text: '이 이슈를 보관하시겠습니까?', + ok_text: '보관', + dismiss_text: '취소' + } + }, + { + type: 'button', + text: '🗑️ 삭제', + name: 'delete_issue', + value: JSON.stringify({ ...baseActionData, action: 'delete' }), + style: 'danger', + confirm: { + title: '이슈 삭제 확인', + text: '이 이슈를 완전히 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', + ok_text: '삭제', + dismiss_text: '취소' + } + } + ); + break; + + case 'resolved': + // 해결됨 상태: 해결 취소, 보관, 삭제 버튼 + actions.push( + { + type: 'button', + text: '↩️ 해결 취소', + name: 'unresolve_issue', + value: JSON.stringify({ ...baseActionData, action: 'unresolve' }), + style: 'default', + confirm: { + title: '해결 취소 확인', + text: '이 이슈의 해결 상태를 취소하시겠습니까?', + ok_text: '취소', + dismiss_text: '아니오' + } + }, + { + type: 'button', + text: '📦 보관', + name: 'archive_issue', + value: JSON.stringify({ ...baseActionData, action: 'archive' }), + style: 'default' + } + ); + break; + + case 'ignored': + // 보관 상태: 보관 취소, 해결, 삭제 버튼 + actions.push( + { + type: 'button', + text: '📤 보관 취소', + name: 'unarchive_issue', + value: JSON.stringify({ ...baseActionData, action: 'unarchive' }), + style: 'default', + confirm: { + title: '보관 취소 확인', + text: '이 이슈의 보관 상태를 취소하시겠습니까?', + ok_text: '취소', + dismiss_text: '아니오' + } + }, + { + type: 'button', + text: '✅ 문제 해결', + name: 'resolve_issue', + value: JSON.stringify({ ...baseActionData, action: 'resolve' }), + style: 'good' + } + ); + break; + } + + return actions; +} + +/** + * Sentry 이슈 생성 이벤트를 Slack 메시지로 변환하는 함수 + * @param sentryData - Sentry 웹훅 데이터 + * @param hasSentryToken - Sentry API 토큰 존재 여부 + * @returns Slack 메시지 객체 + */ +export function formatSentryIssueForSlack(sentryData: SentryWebhookData, hasSentryToken: boolean): SlackMessage { + const { action, data } = sentryData; + const issue = data.issue; + + if (!issue) { + return { + text: `🔔 Sentry 이벤트: ${action}`, + attachments: [ + { + color: 'warning', + fields: [ + { + title: '오류', + value: '이슈 정보를 찾을 수 없습니다.', + short: false, + }, + ], + }, + ], + }; + } + + const statusEmoji = getStatusEmoji(issue.status); + const statusColor = getStatusColor(issue.status); + + const fields = [ + { + title: '프로젝트', + value: issue.project?.name || 'Unknown', + short: true, + }, + { + title: '상태', + value: `${statusEmoji} ${issue.status?.toUpperCase() || 'UNKNOWN'}`, + short: true, + }, + { + title: '발생 횟수', + value: issue.count?.toString() || '0', + short: true, + }, + { + title: '사용자 수', + value: issue.userCount?.toString() || '0', + short: true, + }, + ]; + + if (issue.culprit) { + fields.push({ + title: '위치', + value: issue.culprit, + short: false, + }); + } + + const attachment: SlackAttachment = { + color: statusColor, + title: issue.title || 'Unknown Error', + title_link: issue.permalink, + fields, + footer: `Sentry | ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`, + mrkdwn_in: ['text', 'pretext'], + }; + + // Sentry 토큰이 있을 때만 Interactive 버튼 추가 + if (hasSentryToken && issue.status !== 'resolved') { + attachment.actions = createActionButtons( + issue.id, + data.project?.organization?.slug || issue.project?.organization?.slug, + data.project?.slug || issue.project?.slug + ); + } + + return { + text: `🚨 *${getActionText(action)}*`, + attachments: [attachment], + }; +} + +/** + * Sentry 이슈 상태 변경을 위한 Slack 메시지 업데이트 함수 + * @param sentryData - Sentry 웹훅 데이터 + * @param originalMessage - 원본 Slack 메시지 + * @param hasSentryToken - Sentry API 토큰 존재 여부 + * @returns 업데이트된 Slack 메시지 + */ +export function createStatusUpdateMessage(sentryData: SentryWebhookData, hasSentryToken: boolean = false): SlackMessage { + const { data } = sentryData; + const issue = data.issue; + + if (!issue) { + return { + text: '❌ 이슈 정보를 찾을 수 없습니다.', + }; + } + + const statusEmoji = getStatusEmoji(issue.status); + const statusColor = getStatusColor(issue.status); + + const fields = [ + { + title: '프로젝트', + value: issue.project?.name || 'Unknown', + short: true, + }, + { + title: '상태', + value: `${statusEmoji} ${issue.status?.toUpperCase() || 'UNKNOWN'}`, + short: true, + }, + { + title: '발생 횟수', + value: issue.count?.toString() || '0', + short: true, + }, + { + title: '사용자 수', + value: issue.userCount?.toString() || '0', + short: true, + }, + ]; + + if (issue.culprit) { + fields.push({ + title: '위치', + value: issue.culprit, + short: false, + }); + } + + const attachment: SlackAttachment = { + color: statusColor, + title: issue.title || 'Unknown Error', + title_link: issue.permalink, + fields, + footer: `Sentry | 상태 변경: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`, + mrkdwn_in: ['text', 'pretext'], + }; + + // 해결되지 않은 상태이고 Sentry 토큰이 있을 때만 액션 버튼 추가 + if (hasSentryToken && issue.status !== 'resolved') { + attachment.actions = createActionButtons( + issue.id, + data.project?.organization?.slug || issue.project?.organization?.slug, + data.project?.slug || issue.project?.slug + ); + } + + return { + text: `🔄 *이슈 상태가 변경되었습니다*`, + attachments: [attachment], + }; +} + +/** + * 채널 ID 형식을 정규화하는 함수 + * @param channelId - 채널 ID 또는 이름 + * @returns 정규화된 채널 ID + */ +export function normalizeChannelId(channelId: string): string { + if (!channelId.startsWith('C') && !channelId.startsWith('#')) { + return '#' + channelId; + } + return channelId; +} + +function createActionButtons(issueId: string, organizationSlug?: string, projectSlug?: string): SlackAction[] { + if (!organizationSlug || !projectSlug) { + return []; + } + + return [ + { + name: 'sentry_action', + text: '✅ 해결', + type: 'button' as const, + value: `resolve:${issueId}:${organizationSlug}:${projectSlug}`, + style: 'primary', + confirm: { + title: '이슈 해결', + text: '이 이슈를 해결된 상태로 변경하시겠습니까?', + ok_text: '해결', + dismiss_text: '취소', + }, + }, + { + name: 'sentry_action', + text: '🔇 무시', + type: 'button' as const, + value: `ignore:${issueId}:${organizationSlug}:${projectSlug}`, + confirm: { + title: '이슈 무시', + text: '이 이슈를 무시하시겠습니까?', + ok_text: '무시', + dismiss_text: '취소', + }, + }, + { + name: 'sentry_action', + text: '📦 보관', + type: 'button' as const, + value: `archive:${issueId}:${organizationSlug}:${projectSlug}`, + confirm: { + title: '이슈 보관', + text: '이 이슈를 보관하시겠습니까?', + ok_text: '보관', + dismiss_text: '취소', + }, + }, + { + name: 'sentry_action', + text: '🗑️ 삭제', + type: 'button' as const, + value: `delete:${issueId}:${organizationSlug}:${projectSlug}`, + style: 'danger', + confirm: { + title: '이슈 삭제', + text: '⚠️ 이 작업은 되돌릴 수 없습니다. 정말로 이 이슈를 삭제하시겠습니까?', + ok_text: '삭제', + dismiss_text: '취소', + }, + }, + ]; +} + +function getStatusEmoji(status?: string): string { + const emojiMap: Record = { + 'unresolved': '🔴', + 'resolved': '✅', + 'ignored': '🔇', + 'archived': '📦', + }; + return emojiMap[status || 'unresolved'] || '❓'; +} + +function getStatusColor(status?: string): string { + const colorMap: Record = { + 'unresolved': 'danger', + 'resolved': 'good', + 'ignored': 'warning', + 'archived': '#808080', + }; + return colorMap[status || 'unresolved'] || 'warning'; +} + +function getActionText(action?: string): string { + const actionMap: Record = { + 'created': '새로운 오류가 발생했습니다', + 'resolved': '오류가 해결되었습니다', + 'unresolved': '오류가 다시 발생했습니다', + 'ignored': '오류가 무시되었습니다', + 'assigned': '오류가 할당되었습니다', + }; + return actionMap[action || 'created'] || `오류 이벤트: ${action}`; +} \ No newline at end of file From 9d11a69bad1c3ce5a507c76e6c69fa5d1ab74f0e Mon Sep 17 00:00:00 2001 From: six-standard Date: Thu, 26 Jun 2025 17:11:00 +0900 Subject: [PATCH 02/15] =?UTF-8?q?refactor:=20=EC=93=B8=EB=AA=A8=EC=97=86?= =?UTF-8?q?=EB=8A=94=20Slack=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/configs/db.config.ts | 14 +- src/controllers/slack.controller.ts | 165 ------------------ src/controllers/webhook.controller.ts | 95 ++++++++++ src/routes/index.ts | 3 - src/routes/slack.router.ts | 90 ---------- src/routes/webhook.router.ts | 36 ++++ src/types/dto/responses/slackResponse.type.ts | 6 - src/types/index.ts | 4 - 8 files changed, 140 insertions(+), 273 deletions(-) delete mode 100644 src/controllers/slack.controller.ts delete mode 100644 src/routes/slack.router.ts delete mode 100644 src/types/dto/responses/slackResponse.type.ts diff --git a/src/configs/db.config.ts b/src/configs/db.config.ts index b8b0773..45a7c8a 100644 --- a/src/configs/db.config.ts +++ b/src/configs/db.config.ts @@ -16,13 +16,17 @@ const poolConfig: pg.PoolConfig = { max: 10, // 최대 연결 수 idleTimeoutMillis: 30000, // 연결 유휴 시간 (30초) connectionTimeoutMillis: 5000, // 연결 시간 초과 (5초) + ssl: false, + // ssl: { + // rejectUnauthorized: false, + // }, }; -if (process.env.NODE_ENV === 'production') { - poolConfig.ssl = { - rejectUnauthorized: false, - }; -} +// if (process.env.NODE_ENV === 'production') { +// poolConfig.ssl = { +// rejectUnauthorized: false, +// }; +// } const pool = new Pool(poolConfig); diff --git a/src/controllers/slack.controller.ts b/src/controllers/slack.controller.ts deleted file mode 100644 index 50da8a5..0000000 --- a/src/controllers/slack.controller.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { NextFunction, Request, RequestHandler, Response } from 'express'; -import { SlackService } from '@/services/slack.service'; -import { SentryService } from '@/services/sentry.service'; -import logger from '@/configs/logger.config'; -import { PermissionCheckResponseDto, SlackSuccessResponseDto } from '@/types'; -import { SentryActionData, SentryApiAction } from '@/types/models/Sentry.type'; -import { getNewStatusFromAction } from '@/utils/sentry.util'; - -export class SlackController { - constructor( - private slackService: SlackService, - private sentryService: SentryService, - ) {} - - checkPermissions: RequestHandler = async ( - req: Request, - res: Response, - next: NextFunction, - ): Promise => { - try { - const permissions = await this.slackService.checkPermissions(); - const response = new PermissionCheckResponseDto(true, 'Slack 권한 확인 완료', permissions, null); - res.status(200).json(response); - } catch (error) { - logger.error('Slack 권한 확인 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); - next(error); - } - }; - - testBot: RequestHandler = async ( - req: Request, - res: Response, - next: NextFunction, - ): Promise => { - try { - if (!this.slackService.hasBotToken() && !this.slackService.hasWebhookUrl()) { - const response = new SlackSuccessResponseDto( - false, - 'SLACK_BOT_TOKEN 또는 SLACK_WEBHOOK_URL 환경 변수가 설정되지 않았습니다.', - {}, - 'MISSING_SLACK_CONFIG' - ); - res.status(400).json(response); - return; - } - - const testMessage = { - text: '🤖 봇 테스트 메시지입니다!', - attachments: [ - { - color: 'good', - fields: [ - { - title: '테스트 결과', - value: '✅ Slack 연동이 정상적으로 작동합니다.', - short: false, - }, - ], - footer: `테스트 시간: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`, - }, - ], - }; - - await this.slackService.sendMessage(testMessage); - const response = new SlackSuccessResponseDto(true, '봇 테스트 메시지 전송 완료!', {}, null); - res.status(200).json(response); - } catch (error) { - logger.error('봇 테스트 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); - next(error); - } - }; - - handleInteractive: RequestHandler = async ( - req: Request, - res: Response, - next: NextFunction, - ): Promise => { - try { - const payload = JSON.parse(req.body.payload); - - if (payload.type === 'interactive_message' && payload.actions && payload.actions[0]) { - const action = payload.actions[0]; - - if (action.name === 'sentry_action') { - const [actionType, issueId, organizationSlug, projectSlug] = action.value.split(':'); - - const actionData: SentryActionData = { - action: actionType as SentryApiAction, - issueId, - organizationSlug, - projectSlug, - }; - - if (actionData.issueId && actionData.organizationSlug && actionData.projectSlug) { - logger.info('Processing Sentry action:', actionData); - - const result = await this.sentryService.handleIssueAction(actionData); - - if (result.success) { - const updatedMessage = this.createSuccessMessage(actionData, payload.original_message || {}); - res.json(updatedMessage); - } else { - const errorMessage = this.createErrorMessage(result.error || 'Unknown error', payload.original_message || {}); - res.json(errorMessage); - } - return; - } - } - } - - res.json({ text: '❌ 잘못된 요청입니다.' }); - } catch (error) { - logger.error('Interactive 처리 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); - next(error); - } - }; - - private createSuccessMessage(actionData: SentryActionData, originalMessage: unknown): unknown { - const { action } = actionData; - - const updatedMessage = JSON.parse(JSON.stringify(originalMessage)); - - if (updatedMessage.attachments && updatedMessage.attachments[0]) { - const newStatus = getNewStatusFromAction(action); - const statusColors = { - 'resolved': 'good', - 'ignored': 'warning', - 'archived': '#808080', - 'unresolved': 'danger', - }; - - updatedMessage.attachments[0].color = statusColors[newStatus as keyof typeof statusColors] || 'good'; - - const statusMapping = { - 'resolved': 'RESOLVED', - 'ignored': 'IGNORED', - 'archived': 'ARCHIVED', - 'unresolved': 'UNRESOLVED', - }; - - const statusText = statusMapping[newStatus as keyof typeof statusMapping] || newStatus.toUpperCase(); - updatedMessage.attachments[0].footer = `✅ ${statusText} | 처리 완료: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`; - - delete updatedMessage.attachments[0].actions; - } - - return updatedMessage; - } - - private createErrorMessage(error: string, originalMessage: unknown): unknown { - const updatedMessage = JSON.parse(JSON.stringify(originalMessage)); - - if (updatedMessage.attachments && updatedMessage.attachments[0]) { - updatedMessage.attachments[0].fields.push({ - title: '❌ 오류 발생', - value: error, - short: false, - }); - - updatedMessage.attachments[0].color = 'danger'; - } - - return updatedMessage; - } -} \ No newline at end of file diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index 864c1e6..5a631dc 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -2,8 +2,10 @@ import { NextFunction, Request, RequestHandler, Response } from 'express'; import { SlackService } from '@/services/slack.service'; import { SentryService } from '@/services/sentry.service'; import { SentryWebhookData, SlackMessage } from '@/types'; +import { SentryActionData, SentryApiAction } from '@/types/models/Sentry.type'; import logger from '@/configs/logger.config'; import { formatSentryIssueForSlack, createStatusUpdateMessage } from '@/utils/slack.util'; +import { getNewStatusFromAction } from '@/utils/sentry.util'; export class WebhookController { constructor( @@ -37,6 +39,51 @@ export class WebhookController { } }; + handleSlackInteractive: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const payload = JSON.parse(req.body.payload); + + if (payload.type === 'interactive_message' && payload.actions && payload.actions[0]) { + const action = payload.actions[0]; + + if (action.name === 'sentry_action') { + const [actionType, issueId, organizationSlug, projectSlug] = action.value.split(':'); + + const actionData: SentryActionData = { + action: actionType as SentryApiAction, + issueId, + organizationSlug, + projectSlug, + }; + + if (actionData.issueId && actionData.organizationSlug && actionData.projectSlug) { + logger.info('Processing Sentry action:', actionData); + + const result = await this.sentryService.handleIssueAction(actionData); + + if (result.success) { + const updatedMessage = this.createSuccessMessage(actionData, payload.original_message || {}); + res.json(updatedMessage); + } else { + const errorMessage = this.createErrorMessage(result.error || 'Unknown error', payload.original_message || {}); + res.json(errorMessage); + } + return; + } + } + } + + res.json({ text: '❌ 잘못된 요청입니다.' }); + } catch (error) { + logger.error('Slack Interactive 처리 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); + next(error); + } + }; + private async formatSentryDataForSlack(sentryData: SentryWebhookData): Promise { const { action, data } = sentryData; @@ -106,4 +153,52 @@ export class WebhookController { return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken()); } + + private createSuccessMessage(actionData: SentryActionData, originalMessage: unknown): unknown { + const { action } = actionData; + + const updatedMessage = JSON.parse(JSON.stringify(originalMessage)); + + if (updatedMessage.attachments && updatedMessage.attachments[0]) { + const newStatus = getNewStatusFromAction(action); + const statusColors = { + 'resolved': 'good', + 'ignored': 'warning', + 'archived': '#808080', + 'unresolved': 'danger', + }; + + updatedMessage.attachments[0].color = statusColors[newStatus as keyof typeof statusColors] || 'good'; + + const statusMapping = { + 'resolved': 'RESOLVED', + 'ignored': 'IGNORED', + 'archived': 'ARCHIVED', + 'unresolved': 'UNRESOLVED', + }; + + const statusText = statusMapping[newStatus as keyof typeof statusMapping] || newStatus.toUpperCase(); + updatedMessage.attachments[0].footer = `✅ ${statusText} | 처리 완료: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`; + + delete updatedMessage.attachments[0].actions; + } + + return updatedMessage; + } + + private createErrorMessage(error: string, originalMessage: unknown): unknown { + const updatedMessage = JSON.parse(JSON.stringify(originalMessage)); + + if (updatedMessage.attachments && updatedMessage.attachments[0]) { + updatedMessage.attachments[0].fields.push({ + title: '❌ 오류 발생', + value: error, + short: false, + }); + + updatedMessage.attachments[0].color = 'danger'; + } + + return updatedMessage; + } } \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index f815c64..49ca492 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -5,7 +5,6 @@ import NotiRouter from './noti.router'; import LeaderboardRouter from './leaderboard.router'; import TotalStatsRouter from './totalStats.router'; import WebhookRouter from './webhook.router'; -import SlackRouter from './slack.router'; const router: Router = express.Router(); @@ -20,7 +19,5 @@ router.use('/', NotiRouter); router.use('/', LeaderboardRouter); router.use('/', TotalStatsRouter); router.use('/', WebhookRouter); -router.use('/', SlackRouter); - export default router; diff --git a/src/routes/slack.router.ts b/src/routes/slack.router.ts deleted file mode 100644 index 91fbbc9..0000000 --- a/src/routes/slack.router.ts +++ /dev/null @@ -1,90 +0,0 @@ -import express, { Router } from 'express'; -import { SlackController } from '@/controllers/slack.controller'; -import { SentryService } from '@/services/sentry.service'; -import { SlackService } from '@/services/slack.service'; - -const router: Router = express.Router(); - -const slackService = new SlackService(); -const sentryService = new SentryService(); -const slackController = new SlackController(slackService, sentryService); - -/** - * @swagger - * /slack/check-permissions: - * get: - * summary: Slack 권한 확인 - * description: Slack Bot의 권한 상태를 확인합니다. - * tags: [Slack] - * responses: - * 200: - * description: 권한 확인 성공 - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/PermissionCheckResponseDto' - * 400: - * description: Bot Token 미설정 - * 500: - * description: 서버 오류 - */ -router.get('/slack/check-permissions', slackController.checkPermissions); - -/** - * @swagger - * /slack/test-bot: - * post: - * summary: 봇 테스트 - * description: Slack Bot 테스트 메시지를 전송합니다. - * tags: [Slack] - * responses: - * 200: - * description: 테스트 메시지 전송 성공 - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/SlackSuccessResponseDto' - * 400: - * description: Slack 연동 미설정 - * 500: - * description: 서버 오류 - */ -router.post('/slack/test-bot', slackController.testBot); - -/** - * @swagger - * /slack/interactive: - * post: - * summary: Slack Interactive Components 처리 - * description: Slack에서 전송되는 버튼 클릭 등의 상호작용을 처리합니다. - * tags: [Slack] - * requestBody: - * required: true - * content: - * application/x-www-form-urlencoded: - * schema: - * type: object - * properties: - * payload: - * type: string - * description: JSON 형태의 Slack payload (URL encoded) - * responses: - * 200: - * description: 상호작용 처리 성공 - * content: - * application/json: - * schema: - * type: object - * properties: - * text: - * type: string - * example: "버튼 클릭 처리 완료" - * response_type: - * type: string - * enum: [in_channel, ephemeral] - * 400: - * description: 잘못된 요청 - */ -router.post('/slack/interactive', slackController.handleInteractive); - -export default router; \ No newline at end of file diff --git a/src/routes/webhook.router.ts b/src/routes/webhook.router.ts index 993a4f1..6d7740f 100644 --- a/src/routes/webhook.router.ts +++ b/src/routes/webhook.router.ts @@ -55,4 +55,40 @@ const webhookController = new WebhookController(slackService, sentryService); */ router.post('/webhook/sentry', webhookController.handleSentryWebhook); +/** + * @swagger + * /webhook/slack/interactive: + * post: + * summary: Slack Interactive Components 처리 + * description: Slack에서 전송되는 버튼 클릭 등의 상호작용을 처리합니다. + * tags: [Webhook] + * requestBody: + * required: true + * content: + * application/x-www-form-urlencoded: + * schema: + * type: object + * properties: + * payload: + * type: string + * description: JSON 형태의 Slack payload (URL encoded) + * responses: + * 200: + * description: 상호작용 처리 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * text: + * type: string + * example: "버튼 클릭 처리 완료" + * response_type: + * type: string + * enum: [in_channel, ephemeral] + * 400: + * description: 잘못된 요청 + */ +router.post('/webhook/slack/interactive', webhookController.handleSlackInteractive); + export default router; \ No newline at end of file diff --git a/src/types/dto/responses/slackResponse.type.ts b/src/types/dto/responses/slackResponse.type.ts deleted file mode 100644 index cd413d1..0000000 --- a/src/types/dto/responses/slackResponse.type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; -import { SlackPermissionsData } from '@/types/models/Slack.type'; - -export class PermissionCheckResponseDto extends BaseResponseDto {} - -export class SlackSuccessResponseDto extends BaseResponseDto> {} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 13c0285..5194b72 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -71,10 +71,6 @@ export type { export type { SlackInteractiveRequestBody, } from '@/types/dto/requests/slackRequest.type'; -export { - PermissionCheckResponseDto, - SlackSuccessResponseDto, -} from '@/types/dto/responses/slackResponse.type'; // Common export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type'; From 635b18fdd8ebaf42f9de400d5f0f90d33eea4faa Mon Sep 17 00:00:00 2001 From: six-standard Date: Fri, 27 Jun 2025 19:57:00 +0900 Subject: [PATCH 03/15] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B8=B0=EB=8A=A5=20=EB=8B=A8=EC=88=9C=ED=99=94=20?= =?UTF-8?q?=EB=8B=B9=EC=9E=A5=20=EB=B3=B5=EC=9E=A1=ED=95=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=B4=20=ED=95=84=EC=9A=94=ED=95=A0=20=EA=B2=83=20?= =?UTF-8?q?=EA=B0=99=EC=A7=80=20=EC=95=8A=EC=95=84=20=EB=8B=A8=EC=88=9C?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/webhook.controller.test.ts | 325 +++++++++++++ src/controllers/webhook.controller.ts | 220 ++------- src/routes/index.ts | 1 - src/routes/webhook.router.ts | 46 +- src/services/sentry.service.ts | 90 ---- src/services/slack.service.ts | 236 ---------- src/types/dto/requests/slackRequest.type.ts | 3 - src/types/index.ts | 23 - src/types/models/Sentry.type.ts | 40 -- src/types/models/Slack.type.ts | 87 ---- src/utils/sentry.util.ts | 63 --- src/utils/slack.util.ts | 431 ------------------ 12 files changed, 367 insertions(+), 1198 deletions(-) create mode 100644 src/controllers/__test__/webhook.controller.test.ts delete mode 100644 src/services/sentry.service.ts delete mode 100644 src/services/slack.service.ts delete mode 100644 src/types/dto/requests/slackRequest.type.ts delete mode 100644 src/types/models/Slack.type.ts delete mode 100644 src/utils/sentry.util.ts delete mode 100644 src/utils/slack.util.ts diff --git a/src/controllers/__test__/webhook.controller.test.ts b/src/controllers/__test__/webhook.controller.test.ts new file mode 100644 index 0000000..18c250b --- /dev/null +++ b/src/controllers/__test__/webhook.controller.test.ts @@ -0,0 +1,325 @@ +import 'reflect-metadata'; +import { Request, Response } from 'express'; +import { WebhookController } from '@/controllers/webhook.controller'; +import { sendSlackMessage } from '@/modules/slack/slack.notifier'; +import { SentryWebhookData } from '@/types'; + +// Mock dependencies +jest.mock('@/modules/slack/slack.notifier'); + +// logger 모킹 +jest.mock('@/configs/logger.config', () => ({ + error: jest.fn(), + info: jest.fn(), +})); + +describe('WebhookController', () => { + let webhookController: WebhookController; + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: jest.Mock; + let mockSendSlackMessage: jest.MockedFunction; + + beforeEach(() => { + // WebhookController 인스턴스 생성 + webhookController = new WebhookController(); + + // Request, Response, NextFunction 모킹 + mockRequest = { + body: {}, + headers: {}, + }; + + mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + }; + + nextFunction = jest.fn(); + mockSendSlackMessage = sendSlackMessage as jest.MockedFunction; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('handleSentryWebhook', () => { + const mockSentryData: SentryWebhookData = { + action: 'created', + data: { + issue: { + id: 'test-issue-123', + title: '테스트 오류입니다', + culprit: 'TestFile.js:10', + status: 'unresolved', + count: 5, + userCount: 3, + firstSeen: '2024-01-01T12:00:00.000Z', + permalink: 'https://velog-dashboardv2.sentry.io/issues/test-issue-123/', + project: { + id: 'project-123', + name: 'Velog Dashboard', + slug: 'velog-dashboard' + } + } + } + }; + + it('유효한 Sentry 웹훅 데이터로 처리에 성공해야 한다', async () => { + mockRequest.body = mockSentryData; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🚨 *새로운 오류가 발생했습니다*') + ); + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🔴 *제목:* 테스트 오류입니다') + ); + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('📍 *위치:* TestFile.js:10') + ); + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/test-issue-123/') + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Sentry 웹훅 처리에 성공하였습니다.', + data: {}, + error: null + }); + }); + + it('permalink가 없는 경우 기본 URL 패턴을 사용해야 한다', async () => { + const dataWithoutPermalink = { + ...mockSentryData, + data: { + ...mockSentryData.data, + issue: { + ...mockSentryData.data.issue, + permalink: undefined + } + } + }; + mockRequest.body = dataWithoutPermalink; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/test-issue-123/') + ); + }); + + it('resolved 액션에 대해 올바른 메시지를 생성해야 한다', async () => { + const resolvedData = { + ...mockSentryData, + action: 'resolved' as const, + data: { + ...mockSentryData.data, + issue: { + ...mockSentryData.data.issue, + status: 'resolved' as const + } + } + }; + mockRequest.body = resolvedData; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🚨 *오류가 해결되었습니다*') + ); + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('✅ *제목:*') + ); + }); + + it('ignored 액션에 대해 올바른 메시지를 생성해야 한다', async () => { + const ignoredData = { + ...mockSentryData, + action: 'ignored' as const, + data: { + ...mockSentryData.data, + issue: { + ...mockSentryData.data.issue, + status: 'ignored' as const + } + } + }; + mockRequest.body = ignoredData; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🚨 *오류가 무시되었습니다*') + ); + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🔇 *제목:*') + ); + }); + + it('알 수 없는 액션에 대해 기본 메시지를 생성해야 한다', async () => { + const unknownActionData = { + ...mockSentryData, + action: 'unknown_action' as 'created' + }; + mockRequest.body = unknownActionData; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('오류 이벤트: unknown_action') + ); + }); + + it('알 수 없는 상태에 대해 기본 이모지를 사용해야 한다', async () => { + const unknownStatusData = { + ...mockSentryData, + data: { + ...mockSentryData.data, + issue: { + ...mockSentryData.data.issue, + status: 'unknown_status' as 'unresolved' + } + } + }; + mockRequest.body = unknownStatusData; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('❓ *제목:*') + ); + }); + + it('Slack 메시지 전송 실패 시 에러를 전달해야 한다', async () => { + mockRequest.body = mockSentryData; + const slackError = new Error('Slack 전송 실패'); + mockSendSlackMessage.mockRejectedValue(slackError); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalledWith(slackError); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + + it('빈 body로 요청 시에도 처리해야 한다', async () => { + mockRequest.body = {}; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + // undefined 값들에 대해서도 처리되어야 함 + expect(mockSendSlackMessage).toHaveBeenCalled(); + expect(mockResponse.status).toHaveBeenCalledWith(200); + }); + + it('필수 필드가 없는 경우에도 처리해야 한다', async () => { + const incompleteData = { + action: 'created', + data: { + issue: { + id: 'test-123' + // title, culprit 등 누락 + } + } + }; + mockRequest.body = incompleteData; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/test-123/') + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + }); + }); + + describe('formatSentryMessage (private method integration test)', () => { + it('완전한 Sentry 데이터로 올바른 형식의 메시지를 생성해야 한다', async () => { + const completeData: SentryWebhookData = { + action: 'created', + data: { + issue: { + id: 'issue-456', + title: 'TypeError: Cannot read property of undefined', + culprit: 'components/UserProfile.tsx:25', + status: 'unresolved', + count: 12, + userCount: 8, + firstSeen: '2024-01-15T14:30:00.000Z', + permalink: 'https://velog-dashboardv2.sentry.io/issues/issue-456/', + project: { + id: 'proj-789', + name: 'Velog Dashboard V2', + slug: 'velog-dashboard-v2' + } + } + } + }; + + mockRequest.body = completeData; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + const expectedMessage = `🚨 *새로운 오류가 발생했습니다* + +🔴 *제목:* TypeError: Cannot read property of undefined + +📍 *위치:* components/UserProfile.tsx:25 + +🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/issue-456/`; + + expect(mockSendSlackMessage).toHaveBeenCalledWith(expectedMessage); + }); + }); +}); \ No newline at end of file diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index 5a631dc..ee3b8b1 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -1,204 +1,64 @@ import { NextFunction, Request, RequestHandler, Response } from 'express'; -import { SlackService } from '@/services/slack.service'; -import { SentryService } from '@/services/sentry.service'; -import { SentryWebhookData, SlackMessage } from '@/types'; -import { SentryActionData, SentryApiAction } from '@/types/models/Sentry.type'; +import { EmptyResponseDto, SentryWebhookData } from '@/types'; import logger from '@/configs/logger.config'; -import { formatSentryIssueForSlack, createStatusUpdateMessage } from '@/utils/slack.util'; -import { getNewStatusFromAction } from '@/utils/sentry.util'; +import { sendSlackMessage } from '@/modules/slack/slack.notifier'; export class WebhookController { - constructor( - private slackService: SlackService, - private sentryService: SentryService, - ) {} + private readonly STATUS_EMOJI = { + 'unresolved': '🔴', + 'resolved': '✅', + 'ignored': '🔇', + 'archived': '📦', + } as const; - handleSentryWebhook: RequestHandler = async ( - req: Request, - res: Response, - next: NextFunction, - ): Promise => { - try { - const sentryData = req.body; - - const slackMessage = await this.formatSentryDataForSlack(sentryData); - - if (slackMessage === null) { - logger.info('기존 메시지 업데이트 완료, 새 메시지 전송 생략'); - res.status(200).json({ message: 'Webhook processed successfully' }); - return; - } - - const issueId = sentryData.data?.issue?.id; - await this.slackService.sendMessage(slackMessage, issueId); - - res.status(200).json({ message: 'Webhook processed successfully' }); - } catch (error) { - logger.error('Sentry webhook 처리 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); - next(error); - } - }; + private readonly ACTION_TEXT = { + 'created': '새로운 오류가 발생했습니다', + 'resolved': '오류가 해결되었습니다', + 'unresolved': '오류가 다시 발생했습니다', + 'ignored': '오류가 무시되었습니다', + 'assigned': '오류가 할당되었습니다', + } as const; - handleSlackInteractive: RequestHandler = async ( + handleSentryWebhook: RequestHandler = async ( req: Request, res: Response, next: NextFunction, ): Promise => { try { - const payload = JSON.parse(req.body.payload); - - if (payload.type === 'interactive_message' && payload.actions && payload.actions[0]) { - const action = payload.actions[0]; - - if (action.name === 'sentry_action') { - const [actionType, issueId, organizationSlug, projectSlug] = action.value.split(':'); - - const actionData: SentryActionData = { - action: actionType as SentryApiAction, - issueId, - organizationSlug, - projectSlug, - }; - - if (actionData.issueId && actionData.organizationSlug && actionData.projectSlug) { - logger.info('Processing Sentry action:', actionData); - - const result = await this.sentryService.handleIssueAction(actionData); - - if (result.success) { - const updatedMessage = this.createSuccessMessage(actionData, payload.original_message || {}); - res.json(updatedMessage); - } else { - const errorMessage = this.createErrorMessage(result.error || 'Unknown error', payload.original_message || {}); - res.json(errorMessage); - } - return; - } - } - } + const sentryData: SentryWebhookData = req.body; + const slackMessage = this.formatSentryMessage(sentryData); + await sendSlackMessage(slackMessage); - res.json({ text: '❌ 잘못된 요청입니다.' }); + const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 성공하였습니다.', {}, null); + res.status(200).json(response); } catch (error) { - logger.error('Slack Interactive 처리 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); + logger.error('Sentry 웹훅 처리 실패:', error); next(error); } }; - private async formatSentryDataForSlack(sentryData: SentryWebhookData): Promise { - const { action, data } = sentryData; - - if (action === 'resolved' || action === 'unresolved' || action === 'ignored') { - return await this.handleIssueStatusChange(sentryData); - } - - if (action === 'created' && data.issue) { - return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken()); - } - - return { - text: `🔔 Sentry 이벤트: ${action || 'Unknown action'}`, - attachments: [ - { - color: 'warning', - fields: [ - { - title: '이벤트 타입', - value: action || 'Unknown', - short: true, - }, - ], - }, - ], - }; - } - - private async handleIssueStatusChange(sentryData: SentryWebhookData): Promise { - const { data } = sentryData; - const issue = data.issue; - - if (!issue) { - logger.warn('이슈 정보가 없습니다:', sentryData); - return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken()); - } - - logger.info(`이슈 상태 변경 감지: ${issue.id} → ${sentryData.action}`); - - const messageInfo = this.slackService.getMessageInfo(issue.id); - - if (messageInfo) { - logger.info('기존 메시지 발견, 업데이트 시도'); - - try { - const updatedMessage = createStatusUpdateMessage( - sentryData, - this.sentryService.hasSentryToken() - ); - - await this.slackService.updateMessage( - messageInfo.channel, - messageInfo.ts, - updatedMessage - ); - - logger.info('기존 메시지 업데이트 완료'); - return null; - - } catch (error) { - logger.error('메시지 업데이트 실패, 새 메시지로 전송:', error instanceof Error ? error.message : '알 수 없는 오류'); - - } - } else { - logger.info('기존 메시지 없음, 새 메시지 생성'); - } - - return formatSentryIssueForSlack(sentryData, this.sentryService.hasSentryToken()); - } - - private createSuccessMessage(actionData: SentryActionData, originalMessage: unknown): unknown { - const { action } = actionData; + private formatSentryMessage(sentryData: SentryWebhookData): string { + const { action, data } = sentryData || {}; + const issue = data?.issue || {}; - const updatedMessage = JSON.parse(JSON.stringify(originalMessage)); + // 알 수 없는 액션에 대한 기본값 처리 + const actionText = this.ACTION_TEXT[action as keyof typeof this.ACTION_TEXT] || `오류 이벤트: ${action}`; - if (updatedMessage.attachments && updatedMessage.attachments[0]) { - const newStatus = getNewStatusFromAction(action); - const statusColors = { - 'resolved': 'good', - 'ignored': 'warning', - 'archived': '#808080', - 'unresolved': 'danger', - }; - - updatedMessage.attachments[0].color = statusColors[newStatus as keyof typeof statusColors] || 'good'; - - const statusMapping = { - 'resolved': 'RESOLVED', - 'ignored': 'IGNORED', - 'archived': 'ARCHIVED', - 'unresolved': 'UNRESOLVED', - }; - - const statusText = statusMapping[newStatus as keyof typeof statusMapping] || newStatus.toUpperCase(); - updatedMessage.attachments[0].footer = `✅ ${statusText} | 처리 완료: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`; - - delete updatedMessage.attachments[0].actions; - } - - return updatedMessage; - } - - private createErrorMessage(error: string, originalMessage: unknown): unknown { - const updatedMessage = JSON.parse(JSON.stringify(originalMessage)); + // 알 수 없는 상태에 대한 기본값 처리 + const statusEmoji = this.STATUS_EMOJI[issue.status as keyof typeof this.STATUS_EMOJI] || '❓'; - if (updatedMessage.attachments && updatedMessage.attachments[0]) { - updatedMessage.attachments[0].fields.push({ - title: '❌ 오류 발생', - value: error, - short: false, - }); + const issueTitle = issue.title || '제목 없음'; + const culprit = issue.culprit || '위치 정보 없음'; + const permalink = issue.permalink; - updatedMessage.attachments[0].color = 'danger'; - } + // URL 생성 - permalink가 있으면 사용, 없으면 실제 프로젝트 URL 패턴으로 생성 + const detailUrl = permalink || `https://velog-dashboardv2.sentry.io/issues/${issue.id || 'unknown'}/`; + + let message = `🚨 *${actionText}*\n\n`; + message += `${statusEmoji} *제목:* ${issueTitle}\n\n`; + message += `📍 *위치:* ${culprit}\n\n`; + message += `🔗 *상세 보기:* ${detailUrl}`; - return updatedMessage; + return message; } } \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index 49ca492..3b87fac 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -6,7 +6,6 @@ import LeaderboardRouter from './leaderboard.router'; import TotalStatsRouter from './totalStats.router'; import WebhookRouter from './webhook.router'; - const router: Router = express.Router(); router.use('/ping', (req, res) => { diff --git a/src/routes/webhook.router.ts b/src/routes/webhook.router.ts index 6d7740f..42ec9be 100644 --- a/src/routes/webhook.router.ts +++ b/src/routes/webhook.router.ts @@ -1,23 +1,17 @@ import express, { Router } from 'express'; import { WebhookController } from '@/controllers/webhook.controller'; -import { SentryService } from '@/services/sentry.service'; -import { SlackService } from '@/services/slack.service'; const router: Router = express.Router(); -// 서비스 인스턴스 생성 -const sentryService = new SentryService(); -const slackService = new SlackService(); - // 컨트롤러 인스턴스 생성 -const webhookController = new WebhookController(slackService, sentryService); +const webhookController = new WebhookController(); /** * @swagger * /webhook/sentry: * post: * summary: Sentry webhook 처리 - * description: Sentry에서 전송되는 webhook 이벤트를 처리합니다. + * description: Sentry에서 전송되는 webhook 이벤트를 처리하고 Slack으로 알림을 전송합니다. * tags: [Webhook] * requestBody: * required: true @@ -55,40 +49,4 @@ const webhookController = new WebhookController(slackService, sentryService); */ router.post('/webhook/sentry', webhookController.handleSentryWebhook); -/** - * @swagger - * /webhook/slack/interactive: - * post: - * summary: Slack Interactive Components 처리 - * description: Slack에서 전송되는 버튼 클릭 등의 상호작용을 처리합니다. - * tags: [Webhook] - * requestBody: - * required: true - * content: - * application/x-www-form-urlencoded: - * schema: - * type: object - * properties: - * payload: - * type: string - * description: JSON 형태의 Slack payload (URL encoded) - * responses: - * 200: - * description: 상호작용 처리 성공 - * content: - * application/json: - * schema: - * type: object - * properties: - * text: - * type: string - * example: "버튼 클릭 처리 완료" - * response_type: - * type: string - * enum: [in_channel, ephemeral] - * 400: - * description: 잘못된 요청 - */ -router.post('/webhook/slack/interactive', webhookController.handleSlackInteractive); - export default router; \ No newline at end of file diff --git a/src/services/sentry.service.ts b/src/services/sentry.service.ts deleted file mode 100644 index ba581df..0000000 --- a/src/services/sentry.service.ts +++ /dev/null @@ -1,90 +0,0 @@ -import axios from 'axios'; -import { SentryActionData, SentryActionResult } from '@/types'; -import logger from '@/configs/logger.config'; - -export class SentryService { - private readonly sentryToken: string; - - constructor() { - this.sentryToken = process.env.SENTRY_AUTH_TOKEN || ''; - } - - - hasSentryToken(): boolean { - return !!this.sentryToken; - } - - async handleIssueAction(actionData: SentryActionData): Promise { - if (!this.sentryToken) { - return { success: false, error: 'Sentry 토큰이 설정되지 않았습니다.' }; - } - - try { - const { action, issueId, organizationSlug, projectSlug } = actionData; - const url = `https://sentry.io/api/0/projects/${organizationSlug}/${projectSlug}/issues/`; - - let data: Record; - - switch (action) { - case 'resolve': - data = { status: 'resolved' }; - break; - case 'ignore': - data = { status: 'ignored' }; - break; - case 'archive': - data = { status: 'ignored', statusDetails: { ignoreUntilEscalating: true } }; - break; - case 'delete': - return await this.deleteIssue(organizationSlug, projectSlug, issueId); - default: - return { success: false, error: `지원하지 않는 액션: ${action}` }; - } - - const response = await axios.put(`${url}`, { - issues: [issueId], - ...data, - }, { - headers: { - 'Authorization': `Bearer ${this.sentryToken}`, - 'Content-Type': 'application/json', - }, - }); - - if (response.status === 200) { - logger.info(`Sentry 이슈 ${action} 성공:`, { issueId, action }); - return { success: true }; - } else { - throw new Error(`Unexpected response status: ${response.status}`); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - logger.error(`Sentry 이슈 ${actionData.action} 실패:`, errorMessage); - return { success: false, error: errorMessage }; - } - } - - private async deleteIssue(organizationSlug: string, projectSlug: string, issueId: string): Promise { - try { - const url = `https://sentry.io/api/0/projects/${organizationSlug}/${projectSlug}/issues/${issueId}/`; - - const response = await axios.delete(url, { - headers: { - 'Authorization': `Bearer ${this.sentryToken}`, - 'Content-Type': 'application/json', - }, - }); - - if (response.status === 202 || response.status === 204) { - logger.info('Sentry 이슈 삭제 성공:', { issueId }); - return { success: true }; - } else { - throw new Error(`Unexpected response status: ${response.status}`); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - logger.error('Sentry 이슈 삭제 실패:', errorMessage); - return { success: false, error: errorMessage }; - } - } -} \ No newline at end of file diff --git a/src/services/slack.service.ts b/src/services/slack.service.ts deleted file mode 100644 index 6b8ebca..0000000 --- a/src/services/slack.service.ts +++ /dev/null @@ -1,236 +0,0 @@ -import axios from 'axios'; -import { SlackMessage, SlackApiResponse, StoredMessageInfo, SlackPermissionsData } from '@/types'; -import { normalizeChannelId } from '@/utils/slack.util'; -import logger from '@/configs/logger.config'; - -const issueMessageMap = new Map(); - -export class SlackService { - private readonly webhookUrl: string; - private readonly botToken: string; - private readonly channelId: string; - - constructor() { - this.webhookUrl = process.env.SLACK_WEBHOOK_URL || ''; - this.botToken = process.env.SLACK_BOT_TOKEN || ''; - this.channelId = process.env.SLACK_CHANNEL_ID || '#general'; - - // 24시간마다 오래된 메시지 정보 정리 - setInterval(() => this.cleanupOldMessages(), 24 * 60 * 60 * 1000); - } - - hasBotToken(): boolean { - return !!this.botToken; - } - - hasWebhookUrl(): boolean { - return !!this.webhookUrl; - } - - storeMessageInfo(issueId: string, messageInfo: Omit): void { - issueMessageMap.set(issueId, { - ...messageInfo, - timestamp: Date.now(), - }); - } - - getMessageInfo(issueId: string): StoredMessageInfo | undefined { - return issueMessageMap.get(issueId); - } - - async checkPermissions(): Promise { - if (!this.botToken) { - return { - hasToken: false, - isValid: false, - permissions: [], - botInfo: null, - channelAccess: false, - recommendations: [ - 'SLACK_BOT_TOKEN 환경 변수를 설정해주세요.', - 'Slack 앱에서 Bot Token을 생성하고 적절한 권한을 부여해주세요.', - ], - }; - } - - try { - const authResponse = await axios.get('https://slack.com/api/auth.test', { - headers: { - 'Authorization': `Bearer ${this.botToken}`, - 'Content-Type': 'application/json', - }, - }); - - if (!authResponse.data.ok) { - throw new Error(authResponse.data.error || 'Token validation failed'); - } - - const channelId = normalizeChannelId(this.channelId); - - const channelResponse = await axios.get('https://slack.com/api/conversations.info', { - params: { channel: channelId }, - headers: { - 'Authorization': `Bearer ${this.botToken}`, - 'Content-Type': 'application/json', - }, - }); - - let historyAccess = false; - - try { - const historyResponse = await axios.get('https://slack.com/api/conversations.history', { - params: { channel: channelId, limit: 1 }, - headers: { - 'Authorization': `Bearer ${this.botToken}`, - 'Content-Type': 'application/json', - }, - }); - historyAccess = historyResponse.data.ok; - } catch (error) { - logger.error('History access check failed:', error); - } - - const permissions = [ - 'chat:write', - 'channels:read', - ...(historyAccess ? ['channels:history'] : []), - ]; - - const recommendations = []; - if (!channelResponse.data.ok) { - recommendations.push(`채널 ${this.channelId}에 대한 접근 권한이 없습니다. 봇을 채널에 초대해주세요.`); - } - if (!historyAccess) { - recommendations.push('메시지 업데이트 기능을 위해 channels:history 권한이 필요합니다.'); - } - - return { - hasToken: true, - isValid: authResponse.data.ok, - permissions, - botInfo: { - userId: authResponse.data.user_id, - username: authResponse.data.user, - teamId: authResponse.data.team_id, - teamName: authResponse.data.team, - }, - channelAccess: channelResponse.data.ok, - recommendations: recommendations.length > 0 ? recommendations : ['모든 권한이 정상적으로 설정되었습니다.'], - }; - } catch (error) { - logger.error('Slack 권한 확인 중 오류:', error); - return { - hasToken: true, - isValid: false, - permissions: [], - botInfo: null, - channelAccess: false, - recommendations: [ - 'Bot Token이 유효하지 않습니다.', - 'Slack 앱 설정을 확인하고 올바른 토큰을 사용해주세요.', - ], - }; - } - } - - async sendMessage(message: SlackMessage, issueId?: string): Promise { - // Interactive 기능이 있다면 Bot Token 사용, 없다면 Webhook 사용 - if (this.botToken) { - return await this.sendMessageWithBot(message, issueId); - } else if (this.webhookUrl) { - return await this.sendMessageWithWebhook(message); - } else { - throw new Error('Slack 설정이 없습니다. SLACK_BOT_TOKEN 또는 SLACK_WEBHOOK_URL을 설정해주세요.'); - } - } - - private async sendMessageWithWebhook(message: SlackMessage): Promise { - try { - const response = await axios.post(this.webhookUrl, message, { - headers: { 'Content-Type': 'application/json' }, - }); - - logger.info('Slack 메시지 전송 성공 (Webhook)'); - return { success: true, data: response.data }; - } catch (error) { - logger.error('Slack 메시지 전송 실패 (Webhook):', error instanceof Error ? error.message : '알 수 없는 오류'); - return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; - } - } - - private async sendMessageWithBot(message: SlackMessage, issueId?: string): Promise { - try { - const channelId = normalizeChannelId(this.channelId); - - const response = await axios.post('https://slack.com/api/chat.postMessage', { - channel: channelId, - ...message, - }, { - headers: { - 'Authorization': `Bearer ${this.botToken}`, - 'Content-Type': 'application/json', - }, - }); - - if (response.data.ok) { - logger.info('Slack 메시지 전송 성공 (Bot)', { channel: channelId }); - - if (issueId && response.data.ts) { - this.storeMessageInfo(issueId, { - channel: channelId, - ts: response.data.ts, - }); - } - - return { success: true, data: response.data }; - } else { - throw new Error(response.data.error || 'Message send failed'); - } - } catch (error) { - logger.error('Slack 메시지 전송 실패 (Bot):', error instanceof Error ? error.message : '알 수 없는 오류'); - return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; - } - } - - async updateMessage(channel: string, ts: string, updatedMessage: SlackMessage): Promise { - if (!this.botToken) { - throw new Error('메시지 업데이트에는 Bot Token이 필요합니다.'); - } - - try { - const response = await axios.post('https://slack.com/api/chat.update', { - channel, - ts, - ...updatedMessage, - }, { - headers: { - 'Authorization': `Bearer ${this.botToken}`, - 'Content-Type': 'application/json', - }, - }); - - if (response.data.ok) { - logger.info('Slack 메시지 업데이트 성공', { channel, ts }); - return { success: true, data: response.data }; - } else { - throw new Error(response.data.error || 'Message update failed'); - } - } catch (error) { - logger.error('Slack 메시지 업데이트 실패:', error instanceof Error ? error.message : '알 수 없는 오류'); - return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; - } - } - - private cleanupOldMessages(): void { - const now = Date.now(); - const twentyFourHours = 24 * 60 * 60 * 1000; - - for (const [issueId, messageInfo] of issueMessageMap.entries()) { - if (now - messageInfo.timestamp > twentyFourHours) { - issueMessageMap.delete(issueId); - } - } - - logger.info(`오래된 메시지 정보 정리 완료. 현재 저장된 메시지: ${issueMessageMap.size}개`); - } -} \ No newline at end of file diff --git a/src/types/dto/requests/slackRequest.type.ts b/src/types/dto/requests/slackRequest.type.ts deleted file mode 100644 index 9638882..0000000 --- a/src/types/dto/requests/slackRequest.type.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface SlackInteractiveRequestBody { - payload: string; -} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 5194b72..e241d80 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -40,37 +40,14 @@ export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse. // Sentry 관련 export type { - SentryIssuePriority, SentryIssueStatus, SentryAction, - SentryApiAction, } from '@/types/models/Sentry.type'; export type { SentryProject, - SentryIssueMetadata, SentryIssue, - SentryActor, SentryWebhookData, - SentryActionData, - SentryActionResult, } from '@/types/models/Sentry.type'; -// Slack 관련 -export type { - SlackAttachmentField, - SlackAction, - SlackAttachment, - SlackMessage, - SlackInteractiveAction, - SlackInteractivePayload, - StoredMessageInfo, - SlackApiResponse, - SlackPermissionsData, -} from '@/types/models/Slack.type'; - -export type { - SlackInteractiveRequestBody, -} from '@/types/dto/requests/slackRequest.type'; - // Common export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type'; diff --git a/src/types/models/Sentry.type.ts b/src/types/models/Sentry.type.ts index 6520579..cfbf14b 100644 --- a/src/types/models/Sentry.type.ts +++ b/src/types/models/Sentry.type.ts @@ -1,71 +1,31 @@ - -export type SentryIssuePriority = 'high' | 'medium' | 'low'; - export type SentryIssueStatus = 'resolved' | 'unresolved' | 'ignored'; export type SentryAction = 'created' | 'resolved' | 'unresolved' | 'ignored'; -export interface SentryOrganization { - id: string; - slug: string; - name: string; -} - export interface SentryProject { id: string; name: string; slug: string; platform?: string; - organization?: SentryOrganization; -} - -export interface SentryIssueMetadata { - value?: string; - type?: string; } export interface SentryIssue { id: string; - shortId?: string; title: string; culprit?: string; - metadata?: SentryIssueMetadata; status?: SentryIssueStatus; - priority?: SentryIssuePriority; count: number; userCount: number; firstSeen: string; lastSeen?: string; project?: SentryProject; - platform?: string; permalink?: string; } -export interface SentryActor { - id: string; - name: string; - email?: string; -} - export interface SentryWebhookData { action: SentryAction; data: { issue: SentryIssue; project?: SentryProject; }; - actor?: SentryActor; -} - -export type SentryApiAction = 'resolve' | 'unresolve' | 'ignore' | 'archive' | 'unarchive' | 'delete'; - -export interface SentryActionData { - issueId: string; - organizationSlug: string; - projectSlug: string; - action: SentryApiAction; -} - -export interface SentryActionResult { - success: boolean; - error?: string; } \ No newline at end of file diff --git a/src/types/models/Slack.type.ts b/src/types/models/Slack.type.ts deleted file mode 100644 index eeded83..0000000 --- a/src/types/models/Slack.type.ts +++ /dev/null @@ -1,87 +0,0 @@ -export interface SlackAttachmentField { - title: string; - value: string; - short: boolean; -} - -export interface SlackAction { - type: 'button'; - text: string; - name?: string; - value?: string; - url?: string; - style?: 'default' | 'primary' | 'danger' | 'good'; - confirm?: { - title: string; - text: string; - ok_text: string; - dismiss_text: string; - }; -} - -export interface SlackAttachment { - callback_id?: string; - color?: string; - fields?: SlackAttachmentField[]; - actions?: SlackAction[]; - footer?: string; - footer_icon?: string; - ts?: number; - text?: string; - title?: string; - title_link?: string; - mrkdwn_in?: string[]; -} - -export interface SlackMessage { - text: string; - attachments?: SlackAttachment[]; - response_type?: 'in_channel' | 'ephemeral'; - [key: string]: unknown; -} - -export interface SlackInteractiveAction { - name: string; - value: string; - type: string; -} - -export interface SlackInteractivePayload { - type: string; - callback_id?: string; - actions?: SlackInteractiveAction[]; - original_message?: SlackMessage; - response_url?: string; - user?: { - id: string; - name: string; - }; -} - -export interface StoredMessageInfo { - channel: string; - ts: string; - timestamp: number; -} - -export interface SlackApiResponse { - success: boolean; - error?: string; - data?: unknown; -} - -export interface SlackBotInfo { - userId: string; - username: string; - teamId: string; - teamName: string; -} - -export interface SlackPermissionsData { - hasToken: boolean; - isValid: boolean; - permissions: string[]; - botInfo: SlackBotInfo | null; - channelAccess: boolean; - recommendations: string[]; -} \ No newline at end of file diff --git a/src/utils/sentry.util.ts b/src/utils/sentry.util.ts deleted file mode 100644 index 5d746d2..0000000 --- a/src/utils/sentry.util.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { SentryActionData } from '@/types'; - -/** - * Sentry API 액션에 따른 새로운 상태를 반환하는 함수 - * @param action - Sentry API 액션 - * @returns 새로운 이슈 상태 - */ -export function getNewStatusFromAction(action: string): string { - const statusMap: Record = { - 'resolve': 'resolved', - 'ignore': 'ignored', - 'archive': 'archived', - 'delete': 'deleted', - }; - - return statusMap[action] || 'unresolved'; -} - -/** - * Sentry API 요청 데이터를 생성하는 함수 - * @param actionData - 액션 데이터 - * @returns API 요청을 위한 데이터와 메소드 - */ -export function prepareSentryApiRequest(actionData: SentryActionData): { - method: 'PUT' | 'DELETE'; - data?: { status: string }; -} { - const { action } = actionData; - - switch (action) { - case 'resolve': - return { method: 'PUT', data: { status: 'resolved' } }; - case 'unresolve': - return { method: 'PUT', data: { status: 'unresolved' } }; - case 'archive': - return { method: 'PUT', data: { status: 'ignored' } }; - case 'unarchive': - return { method: 'PUT', data: { status: 'unresolved' } }; - case 'delete': - return { method: 'DELETE' }; - default: - throw new Error('지원되지 않는 액션입니다.'); - } -} - -/** - * Sentry API URL을 생성하는 함수 - * @param issueId - 이슈 ID - * @returns Sentry API URL - */ -export function getSentryApiUrl(issueId: string): string { - return `https://sentry.io/api/0/issues/${issueId}/`; -} - -/** - * Sentry 이슈 URL을 생성하는 함수 - * @param issueId - 이슈 ID - * @param orgSlug - 조직 슬러그 (기본: velog-dashboardv2) - * @returns Sentry 이슈 상세 페이지 URL - */ -export function getSentryIssueUrl(issueId: string, orgSlug: string = 'velog-dashboardv2'): string { - return `https://sentry.io/organizations/${orgSlug}/issues/${issueId}/`; -} \ No newline at end of file diff --git a/src/utils/slack.util.ts b/src/utils/slack.util.ts deleted file mode 100644 index fbb6874..0000000 --- a/src/utils/slack.util.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { SlackMessage, SlackAction, SentryIssue, SentryWebhookData } from '@/types'; -import { SlackAttachment } from '@/types/models/Slack.type'; - -/** - * 날짜/시간을 상대적 또는 절대적 형식으로 포맷팅하는 함수 - * @param dateString - 포맷팅할 날짜 문자열 - * @returns 포맷팅된 날짜 문자열 - */ -export function formatDateTime(dateString?: string): string { - if (!dateString) return 'Unknown'; - - try { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / (1000 * 60)); - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - // 상대 시간 표시 - if (diffMins < 1) return '방금 전'; - if (diffMins < 60) return `${diffMins}분 전`; - if (diffHours < 24) return `${diffHours}시간 전`; - if (diffDays < 7) return `${diffDays}일 전`; - - // 절대 시간 표시 (한국 시간) - return date.toLocaleString('ko-KR', { - timeZone: 'Asia/Seoul', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); - } catch { - return dateString; - } -} - -/** - * 이슈 상태별 액션 버튼을 생성하는 함수 - * @param issue - Sentry 이슈 정보 - * @param issueUrl - 이슈 상세 페이지 URL - * @param hasSentryToken - Sentry API 토큰 존재 여부 - * @returns Slack 액션 버튼 배열 - */ -export function generateIssueActions(issue: SentryIssue, issueUrl: string, hasSentryToken: boolean = false): SlackAction[] { - const actions: SlackAction[] = [ - { - type: 'button', - text: '🔍 Sentry에서 자세히 보기', - url: issueUrl, - style: 'primary' - } - ]; - - // Interactive 기능이 활성화된 경우에만 액션 버튼 추가 - if (!hasSentryToken) { - return actions; - } - - const issueStatus = issue.status || 'unresolved'; - const baseActionData = { - issueId: issue.id, - projectSlug: issue.project?.slug || 'unknown' - }; - - switch (issueStatus) { - case 'unresolved': - // 미해결 상태: 해결, 보관, 삭제 버튼 - actions.push( - { - type: 'button', - text: '✅ 문제 해결', - name: 'resolve_issue', - value: JSON.stringify({ ...baseActionData, action: 'resolve' }), - style: 'good', - confirm: { - title: '이슈 해결 확인', - text: '이 이슈를 해결됨으로 표시하시겠습니까?', - ok_text: '해결', - dismiss_text: '취소' - } - }, - { - type: 'button', - text: '📦 보관', - name: 'archive_issue', - value: JSON.stringify({ ...baseActionData, action: 'archive' }), - style: 'default', - confirm: { - title: '이슈 보관 확인', - text: '이 이슈를 보관하시겠습니까?', - ok_text: '보관', - dismiss_text: '취소' - } - }, - { - type: 'button', - text: '🗑️ 삭제', - name: 'delete_issue', - value: JSON.stringify({ ...baseActionData, action: 'delete' }), - style: 'danger', - confirm: { - title: '이슈 삭제 확인', - text: '이 이슈를 완전히 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', - ok_text: '삭제', - dismiss_text: '취소' - } - } - ); - break; - - case 'resolved': - // 해결됨 상태: 해결 취소, 보관, 삭제 버튼 - actions.push( - { - type: 'button', - text: '↩️ 해결 취소', - name: 'unresolve_issue', - value: JSON.stringify({ ...baseActionData, action: 'unresolve' }), - style: 'default', - confirm: { - title: '해결 취소 확인', - text: '이 이슈의 해결 상태를 취소하시겠습니까?', - ok_text: '취소', - dismiss_text: '아니오' - } - }, - { - type: 'button', - text: '📦 보관', - name: 'archive_issue', - value: JSON.stringify({ ...baseActionData, action: 'archive' }), - style: 'default' - } - ); - break; - - case 'ignored': - // 보관 상태: 보관 취소, 해결, 삭제 버튼 - actions.push( - { - type: 'button', - text: '📤 보관 취소', - name: 'unarchive_issue', - value: JSON.stringify({ ...baseActionData, action: 'unarchive' }), - style: 'default', - confirm: { - title: '보관 취소 확인', - text: '이 이슈의 보관 상태를 취소하시겠습니까?', - ok_text: '취소', - dismiss_text: '아니오' - } - }, - { - type: 'button', - text: '✅ 문제 해결', - name: 'resolve_issue', - value: JSON.stringify({ ...baseActionData, action: 'resolve' }), - style: 'good' - } - ); - break; - } - - return actions; -} - -/** - * Sentry 이슈 생성 이벤트를 Slack 메시지로 변환하는 함수 - * @param sentryData - Sentry 웹훅 데이터 - * @param hasSentryToken - Sentry API 토큰 존재 여부 - * @returns Slack 메시지 객체 - */ -export function formatSentryIssueForSlack(sentryData: SentryWebhookData, hasSentryToken: boolean): SlackMessage { - const { action, data } = sentryData; - const issue = data.issue; - - if (!issue) { - return { - text: `🔔 Sentry 이벤트: ${action}`, - attachments: [ - { - color: 'warning', - fields: [ - { - title: '오류', - value: '이슈 정보를 찾을 수 없습니다.', - short: false, - }, - ], - }, - ], - }; - } - - const statusEmoji = getStatusEmoji(issue.status); - const statusColor = getStatusColor(issue.status); - - const fields = [ - { - title: '프로젝트', - value: issue.project?.name || 'Unknown', - short: true, - }, - { - title: '상태', - value: `${statusEmoji} ${issue.status?.toUpperCase() || 'UNKNOWN'}`, - short: true, - }, - { - title: '발생 횟수', - value: issue.count?.toString() || '0', - short: true, - }, - { - title: '사용자 수', - value: issue.userCount?.toString() || '0', - short: true, - }, - ]; - - if (issue.culprit) { - fields.push({ - title: '위치', - value: issue.culprit, - short: false, - }); - } - - const attachment: SlackAttachment = { - color: statusColor, - title: issue.title || 'Unknown Error', - title_link: issue.permalink, - fields, - footer: `Sentry | ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`, - mrkdwn_in: ['text', 'pretext'], - }; - - // Sentry 토큰이 있을 때만 Interactive 버튼 추가 - if (hasSentryToken && issue.status !== 'resolved') { - attachment.actions = createActionButtons( - issue.id, - data.project?.organization?.slug || issue.project?.organization?.slug, - data.project?.slug || issue.project?.slug - ); - } - - return { - text: `🚨 *${getActionText(action)}*`, - attachments: [attachment], - }; -} - -/** - * Sentry 이슈 상태 변경을 위한 Slack 메시지 업데이트 함수 - * @param sentryData - Sentry 웹훅 데이터 - * @param originalMessage - 원본 Slack 메시지 - * @param hasSentryToken - Sentry API 토큰 존재 여부 - * @returns 업데이트된 Slack 메시지 - */ -export function createStatusUpdateMessage(sentryData: SentryWebhookData, hasSentryToken: boolean = false): SlackMessage { - const { data } = sentryData; - const issue = data.issue; - - if (!issue) { - return { - text: '❌ 이슈 정보를 찾을 수 없습니다.', - }; - } - - const statusEmoji = getStatusEmoji(issue.status); - const statusColor = getStatusColor(issue.status); - - const fields = [ - { - title: '프로젝트', - value: issue.project?.name || 'Unknown', - short: true, - }, - { - title: '상태', - value: `${statusEmoji} ${issue.status?.toUpperCase() || 'UNKNOWN'}`, - short: true, - }, - { - title: '발생 횟수', - value: issue.count?.toString() || '0', - short: true, - }, - { - title: '사용자 수', - value: issue.userCount?.toString() || '0', - short: true, - }, - ]; - - if (issue.culprit) { - fields.push({ - title: '위치', - value: issue.culprit, - short: false, - }); - } - - const attachment: SlackAttachment = { - color: statusColor, - title: issue.title || 'Unknown Error', - title_link: issue.permalink, - fields, - footer: `Sentry | 상태 변경: ${new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}`, - mrkdwn_in: ['text', 'pretext'], - }; - - // 해결되지 않은 상태이고 Sentry 토큰이 있을 때만 액션 버튼 추가 - if (hasSentryToken && issue.status !== 'resolved') { - attachment.actions = createActionButtons( - issue.id, - data.project?.organization?.slug || issue.project?.organization?.slug, - data.project?.slug || issue.project?.slug - ); - } - - return { - text: `🔄 *이슈 상태가 변경되었습니다*`, - attachments: [attachment], - }; -} - -/** - * 채널 ID 형식을 정규화하는 함수 - * @param channelId - 채널 ID 또는 이름 - * @returns 정규화된 채널 ID - */ -export function normalizeChannelId(channelId: string): string { - if (!channelId.startsWith('C') && !channelId.startsWith('#')) { - return '#' + channelId; - } - return channelId; -} - -function createActionButtons(issueId: string, organizationSlug?: string, projectSlug?: string): SlackAction[] { - if (!organizationSlug || !projectSlug) { - return []; - } - - return [ - { - name: 'sentry_action', - text: '✅ 해결', - type: 'button' as const, - value: `resolve:${issueId}:${organizationSlug}:${projectSlug}`, - style: 'primary', - confirm: { - title: '이슈 해결', - text: '이 이슈를 해결된 상태로 변경하시겠습니까?', - ok_text: '해결', - dismiss_text: '취소', - }, - }, - { - name: 'sentry_action', - text: '🔇 무시', - type: 'button' as const, - value: `ignore:${issueId}:${organizationSlug}:${projectSlug}`, - confirm: { - title: '이슈 무시', - text: '이 이슈를 무시하시겠습니까?', - ok_text: '무시', - dismiss_text: '취소', - }, - }, - { - name: 'sentry_action', - text: '📦 보관', - type: 'button' as const, - value: `archive:${issueId}:${organizationSlug}:${projectSlug}`, - confirm: { - title: '이슈 보관', - text: '이 이슈를 보관하시겠습니까?', - ok_text: '보관', - dismiss_text: '취소', - }, - }, - { - name: 'sentry_action', - text: '🗑️ 삭제', - type: 'button' as const, - value: `delete:${issueId}:${organizationSlug}:${projectSlug}`, - style: 'danger', - confirm: { - title: '이슈 삭제', - text: '⚠️ 이 작업은 되돌릴 수 없습니다. 정말로 이 이슈를 삭제하시겠습니까?', - ok_text: '삭제', - dismiss_text: '취소', - }, - }, - ]; -} - -function getStatusEmoji(status?: string): string { - const emojiMap: Record = { - 'unresolved': '🔴', - 'resolved': '✅', - 'ignored': '🔇', - 'archived': '📦', - }; - return emojiMap[status || 'unresolved'] || '❓'; -} - -function getStatusColor(status?: string): string { - const colorMap: Record = { - 'unresolved': 'danger', - 'resolved': 'good', - 'ignored': 'warning', - 'archived': '#808080', - }; - return colorMap[status || 'unresolved'] || 'warning'; -} - -function getActionText(action?: string): string { - const actionMap: Record = { - 'created': '새로운 오류가 발생했습니다', - 'resolved': '오류가 해결되었습니다', - 'unresolved': '오류가 다시 발생했습니다', - 'ignored': '오류가 무시되었습니다', - 'assigned': '오류가 할당되었습니다', - }; - return actionMap[action || 'created'] || `오류 이벤트: ${action}`; -} \ No newline at end of file From 538c57ee6b8def402c3785731baf61a61de3f37a Mon Sep 17 00:00:00 2001 From: six-standard Date: Tue, 1 Jul 2025 09:23:45 +0900 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20=EC=9E=98=EB=AA=BB=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=EB=90=9C=20=EA=B0=9C=EB=B0=9C=ED=99=98=EA=B2=BD=20?= =?UTF-8?q?=EC=84=B8=ED=8C=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/configs/db.config.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/configs/db.config.ts b/src/configs/db.config.ts index 45a7c8a..ac0d850 100644 --- a/src/configs/db.config.ts +++ b/src/configs/db.config.ts @@ -16,17 +16,17 @@ const poolConfig: pg.PoolConfig = { max: 10, // 최대 연결 수 idleTimeoutMillis: 30000, // 연결 유휴 시간 (30초) connectionTimeoutMillis: 5000, // 연결 시간 초과 (5초) - ssl: false, - // ssl: { - // rejectUnauthorized: false, - // }, + // ssl: false, + ssl: { + rejectUnauthorized: false, + }, }; -// if (process.env.NODE_ENV === 'production') { -// poolConfig.ssl = { -// rejectUnauthorized: false, -// }; -// } +if (process.env.NODE_ENV === 'production') { + poolConfig.ssl = { + rejectUnauthorized: false, + }; +} const pool = new Pool(poolConfig); From 37f8b31c54f16bd7b7d211209f34e1b405611566 Mon Sep 17 00:00:00 2001 From: six-standard Date: Tue, 1 Jul 2025 11:25:14 +0900 Subject: [PATCH 05/15] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/webhook.controller.test.ts | 66 +++++++++++++++++-- src/types/models/Sentry.type.ts | 62 ++++++++++++++--- 2 files changed, 113 insertions(+), 15 deletions(-) diff --git a/src/controllers/__test__/webhook.controller.test.ts b/src/controllers/__test__/webhook.controller.test.ts index 18c250b..467fb39 100644 --- a/src/controllers/__test__/webhook.controller.test.ts +++ b/src/controllers/__test__/webhook.controller.test.ts @@ -2,7 +2,6 @@ import 'reflect-metadata'; import { Request, Response } from 'express'; import { WebhookController } from '@/controllers/webhook.controller'; import { sendSlackMessage } from '@/modules/slack/slack.notifier'; -import { SentryWebhookData } from '@/types'; // Mock dependencies jest.mock('@/modules/slack/slack.notifier'); @@ -44,7 +43,8 @@ describe('WebhookController', () => { }); describe('handleSentryWebhook', () => { - const mockSentryData: SentryWebhookData = { + // 실제 동작에 필요한 필수 값만 사용하도록 타입 미적용 + const mockSentryData = { action: 'created', data: { issue: { @@ -52,7 +52,7 @@ describe('WebhookController', () => { title: '테스트 오류입니다', culprit: 'TestFile.js:10', status: 'unresolved', - count: 5, + count: "5", userCount: 3, firstSeen: '2024-01-01T12:00:00.000Z', permalink: 'https://velog-dashboardv2.sentry.io/issues/test-issue-123/', @@ -179,6 +179,61 @@ describe('WebhookController', () => { ); }); + it('assigned 액션에 대해 올바른 메시지를 생성해야 한다', async () => { + const assignedData = { + ...mockSentryData, + action: 'assigned' as const, + data: { + ...mockSentryData.data, + issue: { + ...mockSentryData.data.issue, + status: 'unresolved' as const + } + } + }; + mockRequest.body = assignedData; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🚨 *오류가 할당되었습니다*') + ); + }); + + it('archived 액션에 대해 올바른 메시지를 생성해야 한다', async () => { + const archivedData = { + ...mockSentryData, + action: 'archived' as const, + data: { + ...mockSentryData.data, + issue: { + ...mockSentryData.data.issue, + status: 'archived' as const + } + } + }; + mockRequest.body = archivedData; + mockSendSlackMessage.mockResolvedValue(); + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('🚨 *오류가 아카이브되었습니다*') + ); + expect(mockSendSlackMessage).toHaveBeenCalledWith( + expect.stringContaining('📦 *제목:*') + ); + }); + it('알 수 없는 액션에 대해 기본 메시지를 생성해야 한다', async () => { const unknownActionData = { ...mockSentryData, @@ -281,7 +336,8 @@ describe('WebhookController', () => { describe('formatSentryMessage (private method integration test)', () => { it('완전한 Sentry 데이터로 올바른 형식의 메시지를 생성해야 한다', async () => { - const completeData: SentryWebhookData = { + // 실제 동작에 필요한 필수 값만 사용하도록 타입 미적용 + const completeData = { action: 'created', data: { issue: { @@ -289,7 +345,7 @@ describe('WebhookController', () => { title: 'TypeError: Cannot read property of undefined', culprit: 'components/UserProfile.tsx:25', status: 'unresolved', - count: 12, + count: "12", userCount: 8, firstSeen: '2024-01-15T14:30:00.000Z', permalink: 'https://velog-dashboardv2.sentry.io/issues/issue-456/', diff --git a/src/types/models/Sentry.type.ts b/src/types/models/Sentry.type.ts index cfbf14b..7f6d726 100644 --- a/src/types/models/Sentry.type.ts +++ b/src/types/models/Sentry.type.ts @@ -1,6 +1,5 @@ export type SentryIssueStatus = 'resolved' | 'unresolved' | 'ignored'; - -export type SentryAction = 'created' | 'resolved' | 'unresolved' | 'ignored'; +export type SentryIssueSubStatus = "archived_until_escalating" | "archived_until_condition_met" | "archived_forever" | "escalating" | "ongoing" | "regressed" | "new" export interface SentryProject { id: string; @@ -9,23 +8,66 @@ export interface SentryProject { platform?: string; } +export interface SentryMetadata { + filename: string; + type: string; + value: string; +} + +export interface SentryIssueStatusDetails { + inRelease: string; + inNextRelease: boolean; + inCommit: string; + ignoreCount: string; + ignoreWindow: string; +} + export interface SentryIssue { + url?: string; + web_url?: string; + project_url?: string; id: string; + shareId?: string | null; + shortId: string; title: string; - culprit?: string; - status?: SentryIssueStatus; - count: number; + culprit: string; + permalink?: string | null; + logger?: string | null; + level: string; + status: SentryIssueStatus; + statusDetails?: SentryIssueStatusDetails; + substatus: SentryIssueSubStatus; + isPublic: boolean; + platform: string; + project: SentryProject; + type: string; + metadata?: SentryMetadata; + numComments: number; + assignedTo?: string | null; + isBookmarked: boolean; + isSubscribed: boolean; + subscriptionDetails?: string | null; + hasSeen: boolean; + annotations: []; + issueType: string; + issueCategory: string; + priority: string; + priorityLockedAt?: string | null; + seerFixabilityScore?: string | null; + seerAutofixLastTriggered?: string | null; + isUnhandled: boolean; + count: string; userCount: number; firstSeen: string; - lastSeen?: string; - project?: SentryProject; - permalink?: string; + lastSeen: string; } +// 무조건 오류 생성(created) 메세지만 받도록 해 두었기 때문에, 무조건 해당 타입의 형태로 넘어옵니다 +// 참고: https://docs.sentry.io/organization/integrations/integration-platform/webhooks/issues/ export interface SentryWebhookData { - action: SentryAction; + action: 'created'; + installation: { uuid: string }; data: { issue: SentryIssue; - project?: SentryProject; }; } \ No newline at end of file From 9db3759e2a17eb90fea61ded2305a84b27b77b1b Mon Sep 17 00:00:00 2001 From: six-standard Date: Tue, 1 Jul 2025 11:25:30 +0900 Subject: [PATCH 06/15] =?UTF-8?q?refactor:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/webhook.controller.ts | 34 +++++++++------------------ 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index ee3b8b1..b27f845 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -1,7 +1,7 @@ import { NextFunction, Request, RequestHandler, Response } from 'express'; import { EmptyResponseDto, SentryWebhookData } from '@/types'; import logger from '@/configs/logger.config'; -import { sendSlackMessage } from '@/modules/slack/slack.notifier'; +// import { sendSlackMessage } from '@/modules/slack/slack.notifier'; export class WebhookController { private readonly STATUS_EMOJI = { @@ -11,14 +11,6 @@ export class WebhookController { 'archived': '📦', } as const; - private readonly ACTION_TEXT = { - 'created': '새로운 오류가 발생했습니다', - 'resolved': '오류가 해결되었습니다', - 'unresolved': '오류가 다시 발생했습니다', - 'ignored': '오류가 무시되었습니다', - 'assigned': '오류가 할당되었습니다', - } as const; - handleSentryWebhook: RequestHandler = async ( req: Request, res: Response, @@ -26,8 +18,10 @@ export class WebhookController { ): Promise => { try { const sentryData: SentryWebhookData = req.body; + if(sentryData.action !== "created") res.status(400).json(new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null)); const slackMessage = this.formatSentryMessage(sentryData); - await sendSlackMessage(slackMessage); + console.log(slackMessage); + // await sendSlackMessage(slackMessage); const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 성공하였습니다.', {}, null); res.status(200).json(response); @@ -38,23 +32,17 @@ export class WebhookController { }; private formatSentryMessage(sentryData: SentryWebhookData): string { - const { action, data } = sentryData || {}; - const issue = data?.issue || {}; + const { data: { issue } } = sentryData; + + if(!issue.status || !issue.title || !issue.culprit || !issue.permalink || !issue.id) throw new Error('Sentry 웹훅 데이터가 올바르지 않습니다'); - // 알 수 없는 액션에 대한 기본값 처리 - const actionText = this.ACTION_TEXT[action as keyof typeof this.ACTION_TEXT] || `오류 이벤트: ${action}`; - - // 알 수 없는 상태에 대한 기본값 처리 - const statusEmoji = this.STATUS_EMOJI[issue.status as keyof typeof this.STATUS_EMOJI] || '❓'; - - const issueTitle = issue.title || '제목 없음'; - const culprit = issue.culprit || '위치 정보 없음'; - const permalink = issue.permalink; + const { status, title: issueTitle, culprit, permalink, id } = issue; + const statusEmoji = this.STATUS_EMOJI[status as keyof typeof this.STATUS_EMOJI]; // URL 생성 - permalink가 있으면 사용, 없으면 실제 프로젝트 URL 패턴으로 생성 - const detailUrl = permalink || `https://velog-dashboardv2.sentry.io/issues/${issue.id || 'unknown'}/`; + const detailUrl = permalink || `https://velog-dashboardv2.sentry.io/issues/${id}/`; - let message = `🚨 *${actionText}*\n\n`; + let message = `🚨 *새로운 오류가 발생하였습니다*\n\n`; message += `${statusEmoji} *제목:* ${issueTitle}\n\n`; message += `📍 *위치:* ${culprit}\n\n`; message += `🔗 *상세 보기:* ${detailUrl}`; From 19bc8fbb6f3184386c839aea624b5e46a019c82b Mon Sep 17 00:00:00 2001 From: six-standard Date: Tue, 1 Jul 2025 11:28:58 +0900 Subject: [PATCH 07/15] =?UTF-8?q?refactor:=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EA=B0=92=EC=9D=B4=20=EC=97=86=EC=9D=84=20=EA=B2=BD=EC=9A=B0?= =?UTF-8?q?=EC=9D=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=ED=95=84=EC=88=98=20=EA=B0=92=EC=9D=B4=20=EC=97=86=EC=9D=84=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EB=AC=B4=EC=A1=B0=EA=B1=B4=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EA=B0=80=20=EB=B0=9C=EC=83=9D=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=ED=95=98=EC=98=80=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/webhook.controller.test.ts | 197 ------------------ 1 file changed, 197 deletions(-) diff --git a/src/controllers/__test__/webhook.controller.test.ts b/src/controllers/__test__/webhook.controller.test.ts index 467fb39..7809203 100644 --- a/src/controllers/__test__/webhook.controller.test.ts +++ b/src/controllers/__test__/webhook.controller.test.ts @@ -121,163 +121,6 @@ describe('WebhookController', () => { ); }); - it('resolved 액션에 대해 올바른 메시지를 생성해야 한다', async () => { - const resolvedData = { - ...mockSentryData, - action: 'resolved' as const, - data: { - ...mockSentryData.data, - issue: { - ...mockSentryData.data.issue, - status: 'resolved' as const - } - } - }; - mockRequest.body = resolvedData; - mockSendSlackMessage.mockResolvedValue(); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockSendSlackMessage).toHaveBeenCalledWith( - expect.stringContaining('🚨 *오류가 해결되었습니다*') - ); - expect(mockSendSlackMessage).toHaveBeenCalledWith( - expect.stringContaining('✅ *제목:*') - ); - }); - - it('ignored 액션에 대해 올바른 메시지를 생성해야 한다', async () => { - const ignoredData = { - ...mockSentryData, - action: 'ignored' as const, - data: { - ...mockSentryData.data, - issue: { - ...mockSentryData.data.issue, - status: 'ignored' as const - } - } - }; - mockRequest.body = ignoredData; - mockSendSlackMessage.mockResolvedValue(); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockSendSlackMessage).toHaveBeenCalledWith( - expect.stringContaining('🚨 *오류가 무시되었습니다*') - ); - expect(mockSendSlackMessage).toHaveBeenCalledWith( - expect.stringContaining('🔇 *제목:*') - ); - }); - - it('assigned 액션에 대해 올바른 메시지를 생성해야 한다', async () => { - const assignedData = { - ...mockSentryData, - action: 'assigned' as const, - data: { - ...mockSentryData.data, - issue: { - ...mockSentryData.data.issue, - status: 'unresolved' as const - } - } - }; - mockRequest.body = assignedData; - mockSendSlackMessage.mockResolvedValue(); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockSendSlackMessage).toHaveBeenCalledWith( - expect.stringContaining('🚨 *오류가 할당되었습니다*') - ); - }); - - it('archived 액션에 대해 올바른 메시지를 생성해야 한다', async () => { - const archivedData = { - ...mockSentryData, - action: 'archived' as const, - data: { - ...mockSentryData.data, - issue: { - ...mockSentryData.data.issue, - status: 'archived' as const - } - } - }; - mockRequest.body = archivedData; - mockSendSlackMessage.mockResolvedValue(); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockSendSlackMessage).toHaveBeenCalledWith( - expect.stringContaining('🚨 *오류가 아카이브되었습니다*') - ); - expect(mockSendSlackMessage).toHaveBeenCalledWith( - expect.stringContaining('📦 *제목:*') - ); - }); - - it('알 수 없는 액션에 대해 기본 메시지를 생성해야 한다', async () => { - const unknownActionData = { - ...mockSentryData, - action: 'unknown_action' as 'created' - }; - mockRequest.body = unknownActionData; - mockSendSlackMessage.mockResolvedValue(); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockSendSlackMessage).toHaveBeenCalledWith( - expect.stringContaining('오류 이벤트: unknown_action') - ); - }); - - it('알 수 없는 상태에 대해 기본 이모지를 사용해야 한다', async () => { - const unknownStatusData = { - ...mockSentryData, - data: { - ...mockSentryData.data, - issue: { - ...mockSentryData.data.issue, - status: 'unknown_status' as 'unresolved' - } - } - }; - mockRequest.body = unknownStatusData; - mockSendSlackMessage.mockResolvedValue(); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockSendSlackMessage).toHaveBeenCalledWith( - expect.stringContaining('❓ *제목:*') - ); - }); - it('Slack 메시지 전송 실패 시 에러를 전달해야 한다', async () => { mockRequest.body = mockSentryData; const slackError = new Error('Slack 전송 실패'); @@ -292,46 +135,6 @@ describe('WebhookController', () => { expect(nextFunction).toHaveBeenCalledWith(slackError); expect(mockResponse.json).not.toHaveBeenCalled(); }); - - it('빈 body로 요청 시에도 처리해야 한다', async () => { - mockRequest.body = {}; - mockSendSlackMessage.mockResolvedValue(); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - // undefined 값들에 대해서도 처리되어야 함 - expect(mockSendSlackMessage).toHaveBeenCalled(); - expect(mockResponse.status).toHaveBeenCalledWith(200); - }); - - it('필수 필드가 없는 경우에도 처리해야 한다', async () => { - const incompleteData = { - action: 'created', - data: { - issue: { - id: 'test-123' - // title, culprit 등 누락 - } - } - }; - mockRequest.body = incompleteData; - mockSendSlackMessage.mockResolvedValue(); - - await webhookController.handleSentryWebhook( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockSendSlackMessage).toHaveBeenCalledWith( - expect.stringContaining('🔗 *상세 보기:* https://velog-dashboardv2.sentry.io/issues/test-123/') - ); - expect(mockResponse.status).toHaveBeenCalledWith(200); - }); }); describe('formatSentryMessage (private method integration test)', () => { From 81785ad5ea5d0325d8ea429a31667a424cc51a53 Mon Sep 17 00:00:00 2001 From: six-standard Date: Tue, 1 Jul 2025 11:58:06 +0900 Subject: [PATCH 08/15] =?UTF-8?q?refactor:=20=EC=9D=80=EB=8B=A4=EB=8B=98?= =?UTF-8?q?=20=EC=A0=9C=EC=95=88=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/webhook.controller.ts | 13 ++++++++----- src/types/models/Sentry.type.ts | 5 +++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index b27f845..3663093 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -1,7 +1,7 @@ import { NextFunction, Request, RequestHandler, Response } from 'express'; import { EmptyResponseDto, SentryWebhookData } from '@/types'; import logger from '@/configs/logger.config'; -// import { sendSlackMessage } from '@/modules/slack/slack.notifier'; +import { sendSlackMessage } from '@/modules/slack/slack.notifier'; export class WebhookController { private readonly STATUS_EMOJI = { @@ -17,11 +17,14 @@ export class WebhookController { next: NextFunction, ): Promise => { try { + if (!req.body || typeof req.body !== 'object' || req.body.action !== "created") { + res.status(400).json(new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null)); + } + const sentryData: SentryWebhookData = req.body; - if(sentryData.action !== "created") res.status(400).json(new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null)); + const slackMessage = this.formatSentryMessage(sentryData); - console.log(slackMessage); - // await sendSlackMessage(slackMessage); + await sendSlackMessage(slackMessage); const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 성공하였습니다.', {}, null); res.status(200).json(response); @@ -34,7 +37,7 @@ export class WebhookController { private formatSentryMessage(sentryData: SentryWebhookData): string { const { data: { issue } } = sentryData; - if(!issue.status || !issue.title || !issue.culprit || !issue.permalink || !issue.id) throw new Error('Sentry 웹훅 데이터가 올바르지 않습니다'); + if(!issue.status || !issue.title || !issue.culprit || !issue.id) throw new Error('Sentry 웹훅 데이터가 올바르지 않습니다'); const { status, title: issueTitle, culprit, permalink, id } = issue; const statusEmoji = this.STATUS_EMOJI[status as keyof typeof this.STATUS_EMOJI]; diff --git a/src/types/models/Sentry.type.ts b/src/types/models/Sentry.type.ts index 7f6d726..b91a05f 100644 --- a/src/types/models/Sentry.type.ts +++ b/src/types/models/Sentry.type.ts @@ -70,4 +70,9 @@ export interface SentryWebhookData { data: { issue: SentryIssue; }; + actor: { + type: string; + id: string; + name: string; + }; } \ No newline at end of file From fe71429e27aa6086a48a1f319f3e8fe6f93cfe19 Mon Sep 17 00:00:00 2001 From: six-standard Date: Tue, 1 Jul 2025 12:05:10 +0900 Subject: [PATCH 09/15] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/models/Sentry.type.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/models/Sentry.type.ts b/src/types/models/Sentry.type.ts index b91a05f..ffebf75 100644 --- a/src/types/models/Sentry.type.ts +++ b/src/types/models/Sentry.type.ts @@ -18,8 +18,8 @@ export interface SentryIssueStatusDetails { inRelease: string; inNextRelease: boolean; inCommit: string; - ignoreCount: string; - ignoreWindow: string; + ignoreCount: number; + ignoreWindow: number; } export interface SentryIssue { From 5002eff039044a83aa10fd83a32a8705147296c4 Mon Sep 17 00:00:00 2001 From: six-standard Date: Tue, 1 Jul 2025 12:05:27 +0900 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20=EC=98=A4=EB=A5=98=EA=B0=80=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/webhook.controller.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index 3663093..6a43c17 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -8,7 +8,6 @@ export class WebhookController { 'unresolved': '🔴', 'resolved': '✅', 'ignored': '🔇', - 'archived': '📦', } as const; handleSentryWebhook: RequestHandler = async ( @@ -18,7 +17,9 @@ export class WebhookController { ): Promise => { try { if (!req.body || typeof req.body !== 'object' || req.body.action !== "created") { - res.status(400).json(new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null)); + const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null); + res.status(400).json(response); + return; } const sentryData: SentryWebhookData = req.body; From 3c38b639b281b1a9239a727f8093e772ea238ba8 Mon Sep 17 00:00:00 2001 From: six-standard Date: Wed, 2 Jul 2025 08:36:58 +0900 Subject: [PATCH 11/15] =?UTF-8?q?refactor:=20=EC=9E=98=EB=AA=BB=EB=90=9C?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=EA=B0=92=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/configs/db.config.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/configs/db.config.ts b/src/configs/db.config.ts index ac0d850..b8b0773 100644 --- a/src/configs/db.config.ts +++ b/src/configs/db.config.ts @@ -16,10 +16,6 @@ const poolConfig: pg.PoolConfig = { max: 10, // 최대 연결 수 idleTimeoutMillis: 30000, // 연결 유휴 시간 (30초) connectionTimeoutMillis: 5000, // 연결 시간 초과 (5초) - // ssl: false, - ssl: { - rejectUnauthorized: false, - }, }; if (process.env.NODE_ENV === 'production') { From 4d46adbe99e4c9ee6552746710ca8f6b69030654 Mon Sep 17 00:00:00 2001 From: six-standard Date: Wed, 2 Jul 2025 08:54:58 +0900 Subject: [PATCH 12/15] =?UTF-8?q?refactor:=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/webhook.controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index 6a43c17..e76693f 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -16,7 +16,8 @@ export class WebhookController { next: NextFunction, ): Promise => { try { - if (!req.body || typeof req.body !== 'object' || req.body.action !== "created") { + + if (req.body?.action !== "created") { const response = new EmptyResponseDto(true, 'Sentry 웹훅 처리에 실패했습니다', {}, null); res.status(400).json(response); return; From fc761ac181167e9d2dc3b8b4ad60416cf2c03506 Mon Sep 17 00:00:00 2001 From: six-standard Date: Wed, 2 Jul 2025 08:55:06 +0900 Subject: [PATCH 13/15] =?UTF-8?q?refactor:=20=EC=B0=B8=EC=A1=B0=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=9C=84=EC=B9=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/models/Sentry.type.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/types/models/Sentry.type.ts b/src/types/models/Sentry.type.ts index ffebf75..04ef4ff 100644 --- a/src/types/models/Sentry.type.ts +++ b/src/types/models/Sentry.type.ts @@ -1,3 +1,7 @@ +// 모든 상세 타입의 정보는 해당 문서 페이지에서 확인하실 수 있습니다. +// Sentry AI 왈 내용이 문서와 실제 전송되는 값들이 조금씩 다를 수 있다고 하는데, 전체적인 구조와 각 값의 타입은 동일하다고 하네요 +// 참고: https://docs.sentry.io/organization/integrations/integration-platform/webhooks/issues/#statusdetails + export type SentryIssueStatus = 'resolved' | 'unresolved' | 'ignored'; export type SentryIssueSubStatus = "archived_until_escalating" | "archived_until_condition_met" | "archived_forever" | "escalating" | "ongoing" | "regressed" | "new" @@ -62,8 +66,6 @@ export interface SentryIssue { lastSeen: string; } -// 무조건 오류 생성(created) 메세지만 받도록 해 두었기 때문에, 무조건 해당 타입의 형태로 넘어옵니다 -// 참고: https://docs.sentry.io/organization/integrations/integration-platform/webhooks/issues/ export interface SentryWebhookData { action: 'created'; installation: { uuid: string }; From 424607caab0f7a1836aa6cba2f59aa79a14af08a Mon Sep 17 00:00:00 2001 From: six-standard Date: Wed, 2 Jul 2025 09:20:03 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20=EC=A0=95=EB=A6=AC=EB=90=98?= =?UTF-8?q?=EC=97=88=EC=9C=BC=EB=82=98=20=EB=86=93=EC=B3=90=EC=A7=84=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/types/index.ts b/src/types/index.ts index e241d80..ba888fc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -41,7 +41,6 @@ export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse. // Sentry 관련 export type { SentryIssueStatus, - SentryAction, } from '@/types/models/Sentry.type'; export type { SentryProject, From 7db5b02fef677699a8f87e56369b035af7c40438 Mon Sep 17 00:00:00 2001 From: six-standard Date: Wed, 2 Jul 2025 09:20:20 +0900 Subject: [PATCH 15/15] =?UTF-8?q?refactor:=20invalid=20body=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/webhook.controller.test.ts | 131 +++++++++++++++++- 1 file changed, 129 insertions(+), 2 deletions(-) diff --git a/src/controllers/__test__/webhook.controller.test.ts b/src/controllers/__test__/webhook.controller.test.ts index 7809203..cec602a 100644 --- a/src/controllers/__test__/webhook.controller.test.ts +++ b/src/controllers/__test__/webhook.controller.test.ts @@ -76,7 +76,7 @@ describe('WebhookController', () => { ); expect(mockSendSlackMessage).toHaveBeenCalledWith( - expect.stringContaining('🚨 *새로운 오류가 발생했습니다*') + expect.stringContaining('🚨 *새로운 오류가 발생하였습니다*') ); expect(mockSendSlackMessage).toHaveBeenCalledWith( expect.stringContaining('🔴 *제목:* 테스트 오류입니다') @@ -135,6 +135,133 @@ describe('WebhookController', () => { expect(nextFunction).toHaveBeenCalledWith(slackError); expect(mockResponse.json).not.toHaveBeenCalled(); }); + + // Invalid Body 케이스 테스트들 + it('action이 created가 아닌 경우 400 에러를 반환해야 한다', async () => { + mockRequest.body = { action: 'resolved' }; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Sentry 웹훅 처리에 실패했습니다', + data: {}, + error: null + }); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('빈 body인 경우 400 에러를 반환해야 한다', async () => { + mockRequest.body = {}; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Sentry 웹훅 처리에 실패했습니다', + data: {}, + error: null + }); + }); + + it('action이 없는 경우 400 에러를 반환해야 한다', async () => { + mockRequest.body = { data: { issue: {} } }; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Sentry 웹훅 처리에 실패했습니다', + data: {}, + error: null + }); + }); + + it('전혀 다른 형태의 객체인 경우 400 에러를 반환해야 한다', async () => { + mockRequest.body = { + username: 'test', + password: '123456', + email: 'test@example.com' + }; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: 'Sentry 웹훅 처리에 실패했습니다', + data: {}, + error: null + }); + }); + + it('action은 created이지만 필수 필드가 없는 경우 에러를 전달해야 한다', async () => { + mockRequest.body = { + action: 'created', + data: { + issue: { + // 필수 필드들이 누락됨 + } + } + }; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Sentry 웹훅 데이터가 올바르지 않습니다' + }) + ); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + + it('action은 created이지만 data가 없는 경우 에러를 전달해야 한다', async () => { + mockRequest.body = { action: 'created' }; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + + it('잘못된 타입의 body인 경우 400 에러를 반환해야 한다', async () => { + mockRequest.body = 'invalid string body'; + + await webhookController.handleSentryWebhook( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + }); }); describe('formatSentryMessage (private method integration test)', () => { @@ -170,7 +297,7 @@ describe('WebhookController', () => { nextFunction ); - const expectedMessage = `🚨 *새로운 오류가 발생했습니다* + const expectedMessage = `🚨 *새로운 오류가 발생하였습니다* 🔴 *제목:* TypeError: Cannot read property of undefined