2026-01-19 12:49:29 +09:00
import { fetchVideoInfo } from '../../services/youtube/api.js' ;
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js' ;
2026-01-21 13:38:25 +09:00
import { CATEGORY _IDS } from '../../config/index.js' ;
2026-01-21 14:58:07 +09:00
import {
errorResponse ,
youtubeVideoInfo ,
youtubeScheduleCreate ,
youtubeScheduleUpdate ,
idParam ,
} from '../../schemas/index.js' ;
2026-01-23 11:24:42 +09:00
import { badRequest , notFound , conflict , serverError } from '../../utils/error.js' ;
2026-03-02 17:04:07 +09:00
import { logActivity } from '../../utils/log.js' ;
2026-01-19 12:49:29 +09:00
2026-01-21 13:38:25 +09:00
const YOUTUBE _CATEGORY _ID = CATEGORY _IDS . YOUTUBE ;
2026-01-19 12:49:29 +09:00
/ * *
* YouTube 관련 관리자 라우트
* /
export default async function youtubeRoutes ( fastify ) {
const { db , meilisearch } = fastify ;
/ * *
* GET / api / admin / youtube / video - info
* YouTube 영상 정보 조회
* /
fastify . get ( '/video-info' , {
schema : {
tags : [ 'admin/youtube' ] ,
summary : 'YouTube 영상 정보 조회' ,
2026-01-21 14:58:07 +09:00
description : 'YouTube URL에서 영상 정보를 추출합니다.' ,
2026-01-19 12:49:29 +09:00
security : [ { bearerAuth : [ ] } ] ,
2026-01-21 14:58:07 +09:00
querystring : youtubeVideoInfo ,
response : {
200 : {
type : 'object' ,
properties : {
videoId : { type : 'string' } ,
title : { type : 'string' } ,
channelId : { type : 'string' } ,
channelName : { type : 'string' } ,
publishedAt : { type : 'string' } ,
date : { type : 'string' } ,
time : { type : 'string' } ,
videoType : { type : 'string' } ,
videoUrl : { type : 'string' } ,
} ,
2026-01-19 12:49:29 +09:00
} ,
2026-01-21 14:58:07 +09:00
400 : errorResponse ,
404 : errorResponse ,
500 : errorResponse ,
2026-01-19 12:49:29 +09:00
} ,
} ,
preHandler : [ fastify . authenticate ] ,
} , async ( request , reply ) => {
const { url } = request . query ;
// YouTube URL에서 video ID 추출
const videoId = extractVideoId ( url ) ;
if ( ! videoId ) {
2026-01-23 11:24:42 +09:00
return badRequest ( reply , '유효하지 않은 YouTube URL입니다.' ) ;
2026-01-19 12:49:29 +09:00
}
try {
const video = await fetchVideoInfo ( videoId ) ;
if ( ! video ) {
2026-01-23 11:24:42 +09:00
return notFound ( reply , '영상을 찾을 수 없습니다.' ) ;
2026-01-19 12:49:29 +09:00
}
return {
videoId : video . videoId ,
title : video . title ,
channelId : video . channelId ,
channelName : video . channelTitle ,
publishedAt : video . publishedAt ,
date : video . date ,
time : video . time ,
videoType : video . videoType ,
videoUrl : video . videoUrl ,
} ;
} catch ( err ) {
fastify . log . error ( ` YouTube 영상 조회 오류: ${ err . message } ` ) ;
2026-01-23 11:24:42 +09:00
return serverError ( reply , err . message ) ;
2026-01-19 12:49:29 +09:00
}
} ) ;
/ * *
* POST / api / admin / youtube / schedule
* YouTube 일정 저장
* /
fastify . post ( '/schedule' , {
schema : {
tags : [ 'admin/youtube' ] ,
summary : 'YouTube 일정 저장' ,
2026-01-21 14:58:07 +09:00
description : 'YouTube 영상을 일정으로 등록합니다.' ,
2026-01-19 12:49:29 +09:00
security : [ { bearerAuth : [ ] } ] ,
2026-01-21 14:58:07 +09:00
body : youtubeScheduleCreate ,
response : {
200 : {
type : 'object' ,
properties : {
success : { type : 'boolean' } ,
scheduleId : { type : 'integer' } ,
} ,
2026-01-19 12:49:29 +09:00
} ,
2026-01-21 14:58:07 +09:00
409 : errorResponse ,
500 : errorResponse ,
2026-01-19 12:49:29 +09:00
} ,
} ,
preHandler : [ fastify . authenticate ] ,
} , async ( request , reply ) => {
const { videoId , title , channelId , channelName , date , time , videoType } = request . body ;
try {
// 중복 체크
const [ existing ] = await db . query (
'SELECT id FROM schedule_youtube WHERE video_id = ?' ,
[ videoId ]
) ;
if ( existing . length > 0 ) {
2026-01-23 11:24:42 +09:00
return conflict ( reply , '이미 등록된 영상입니다.' ) ;
2026-01-19 12:49:29 +09:00
}
// schedules 테이블에 저장
const [ result ] = await db . query (
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)' ,
[ YOUTUBE _CATEGORY _ID , title , date , time || null ]
) ;
const scheduleId = result . insertId ;
// schedule_youtube 테이블에 저장
await db . query (
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)' ,
[ scheduleId , videoId , videoType || 'video' , channelId , channelName ]
) ;
// Meilisearch 동기화
const [ categoryRows ] = await db . query (
'SELECT name, color FROM schedule_categories WHERE id = ?' ,
[ YOUTUBE _CATEGORY _ID ]
) ;
const category = categoryRows [ 0 ] || { } ;
await addOrUpdateSchedule ( meilisearch , {
id : scheduleId ,
title ,
date ,
time : time || '' ,
category _id : YOUTUBE _CATEGORY _ID ,
category _name : category . name || '' ,
category _color : category . color || '' ,
source _name : channelName || '' ,
} ) ;
2026-03-02 17:04:07 +09:00
logActivity ( db , { actor : 'admin' , action : 'create' , category : 'schedule' , targetType : 'youtube_schedule' , targetId : scheduleId , summary : ` YouTube 일정 생성: ${ title } ` } ) ;
2026-01-19 12:49:29 +09:00
return { success : true , scheduleId } ;
} catch ( err ) {
fastify . log . error ( ` YouTube 일정 저장 오류: ${ err . message } ` ) ;
2026-01-23 11:24:42 +09:00
return serverError ( reply , err . message ) ;
2026-01-19 12:49:29 +09:00
}
} ) ;
2026-01-20 14:06:02 +09:00
/ * *
* PUT / api / admin / youtube / schedule / : id
* YouTube 일정 수정 ( 멤버 , 영상 유형 수정 가능 )
* /
fastify . put ( '/schedule/:id' , {
schema : {
tags : [ 'admin/youtube' ] ,
summary : 'YouTube 일정 수정' ,
2026-01-21 14:58:07 +09:00
description : 'YouTube 일정의 멤버와 영상 유형을 수정합니다.' ,
2026-01-20 14:06:02 +09:00
security : [ { bearerAuth : [ ] } ] ,
2026-01-21 14:58:07 +09:00
params : idParam ,
body : youtubeScheduleUpdate ,
response : {
200 : {
type : 'object' ,
properties : {
success : { type : 'boolean' } ,
} ,
2026-01-20 14:06:02 +09:00
} ,
2026-01-21 14:58:07 +09:00
404 : errorResponse ,
500 : errorResponse ,
2026-01-20 14:06:02 +09:00
} ,
} ,
preHandler : [ fastify . authenticate ] ,
} , async ( request , reply ) => {
const { id } = request . params ;
const { memberIds = [ ] , videoType } = request . body ;
try {
// 일정 존재 확인
const [ schedules ] = await db . query (
'SELECT id, title, date, time FROM schedules WHERE id = ? AND category_id = ?' ,
[ id , YOUTUBE _CATEGORY _ID ]
) ;
if ( schedules . length === 0 ) {
2026-01-23 11:24:42 +09:00
return notFound ( reply , 'YouTube 일정을 찾을 수 없습니다.' ) ;
2026-01-20 14:06:02 +09:00
}
// 영상 유형 수정
if ( videoType ) {
await db . query (
'UPDATE schedule_youtube SET video_type = ? WHERE schedule_id = ?' ,
[ videoType , id ]
) ;
}
// 기존 멤버 삭제
await db . query ( 'DELETE FROM schedule_members WHERE schedule_id = ?' , [ id ] ) ;
// 새 멤버 추가
if ( memberIds . length > 0 ) {
const values = memberIds . map ( memberId => [ id , memberId ] ) ;
await db . query (
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?' ,
[ values ]
) ;
}
// 멤버 이름 조회 (Meilisearch 동기화용)
let memberNames = '' ;
if ( memberIds . length > 0 ) {
const [ members ] = await db . query (
'SELECT name FROM members WHERE id IN (?) ORDER BY id' ,
[ memberIds ]
) ;
memberNames = members . map ( m => m . name ) . join ( ',' ) ;
}
// YouTube 채널 정보 조회
const [ youtubeInfo ] = await db . query (
'SELECT channel_name FROM schedule_youtube WHERE schedule_id = ?' ,
[ id ]
) ;
const channelName = youtubeInfo [ 0 ] ? . channel _name || '' ;
// 카테고리 정보 조회
const [ categoryRows ] = await db . query (
'SELECT name, color FROM schedule_categories WHERE id = ?' ,
[ YOUTUBE _CATEGORY _ID ]
) ;
const category = categoryRows [ 0 ] || { } ;
// Meilisearch 동기화
const schedule = schedules [ 0 ] ;
await addOrUpdateSchedule ( meilisearch , {
id : schedule . id ,
title : schedule . title ,
date : schedule . date ,
time : schedule . time || '' ,
category _id : YOUTUBE _CATEGORY _ID ,
category _name : category . name || '' ,
category _color : category . color || '' ,
member _names : memberNames ,
source _name : channelName ,
} ) ;
2026-03-02 17:04:07 +09:00
logActivity ( db , { actor : 'admin' , action : 'update' , category : 'schedule' , targetType : 'youtube_schedule' , targetId : parseInt ( id ) , summary : ` YouTube 일정 수정: ${ schedules [ 0 ] . title } ` } ) ;
2026-01-20 14:06:02 +09:00
return { success : true } ;
} catch ( err ) {
fastify . log . error ( ` YouTube 일정 수정 오류: ${ err . message } ` ) ;
2026-01-23 11:24:42 +09:00
return serverError ( reply , err . message ) ;
2026-01-20 14:06:02 +09:00
}
} ) ;
2026-01-19 12:49:29 +09:00
}
/ * *
* YouTube URL에서 video ID 추출
* /
function extractVideoId ( url ) {
if ( ! url ) return null ;
const patterns = [
// https://www.youtube.com/watch?v=VIDEO_ID
/(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/ ,
// https://youtu.be/VIDEO_ID
/(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/ ,
// https://www.youtube.com/shorts/VIDEO_ID
/(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/ ,
// https://www.youtube.com/embed/VIDEO_ID
/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/ ,
// https://www.youtube.com/v/VIDEO_ID
/(?:youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/ ,
] ;
for ( const pattern of patterns ) {
const match = url . match ( pattern ) ;
if ( match ) {
return match [ 1 ] ;
}
}
return null ;
}