feat(admin-schedule): 일반 일정 생성/수정 라우트 추가 (컴백·팬사인회·기타)

전용 폼이 없는 단순 카테고리용 POST/PUT /admin/schedules 신규.
제목·날짜·시간·카테고리·멤버 + date_precision(월만=날짜 미정) 처리,
month이면 날짜를 해당 월 1일로 정규화. schedules + schedule_members
트랜잭션 + meili 동기화 + 월별 캐시 무효화. (앨범→컴백 카테고리 리네임 동반)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-06-16 21:58:57 +09:00
parent ad20149c88
commit a11a027682
2 changed files with 128 additions and 0 deletions

View file

@ -0,0 +1,124 @@
/**
* 일반 일정 생성/수정 라우트 (전용 폼이 없는 단순 카테고리용 컴백·팬사인회·기타)
* 제목·날짜·시간·멤버 + date_precision(컴백의 "날짜 미정"). 이미지/장소/설명은 미지원.
*/
import { errorResponse } from '../../schemas/index.js';
import { badRequest, notFound } from '../../utils/error.js';
import { logActivity } from '../../utils/log.js';
import { withTransaction } from '../../utils/transaction.js';
import { syncScheduleById } from '../../services/meilisearch/index.js';
const scheduleBody = {
type: 'object',
properties: {
title: { type: 'string' },
date: { type: 'string' }, // YYYY-MM-DD
time: { type: ['string', 'null'] },
category: { type: 'integer' },
datePrecision: { type: 'string', enum: ['day', 'month'], default: 'day' },
members: { type: 'array', items: { type: 'integer' }, default: [] },
},
required: ['title', 'date', 'category'],
};
/**
* date_precision이 month면 날짜를 해당 1일로 정규화
*/
function normalizeDate(date, precision) {
if (precision === 'month' && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
return `${date.slice(0, 7)}-01`;
}
return date;
}
export default async function schedulesAdminRoutes(fastify) {
const { db, meilisearch, redis } = fastify;
/**
* POST /api/admin/schedules 일정 생성
*/
fastify.post('/', {
schema: {
tags: ['admin/schedules'],
summary: '일반 일정 생성',
security: [{ bearerAuth: [] }],
body: scheduleBody,
response: { 201: { type: 'object', additionalProperties: true }, 400: errorResponse },
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { title, date, time = null, category, datePrecision = 'day', members = [] } = request.body;
if (!title?.trim()) return badRequest(reply, '제목은 필수입니다.');
if (!date) return badRequest(reply, '날짜는 필수입니다.');
const finalDate = normalizeDate(date, datePrecision);
const scheduleId = await withTransaction(db, async (conn) => {
const [result] = await conn.query(
'INSERT INTO schedules (category_id, title, date, time, date_precision) VALUES (?, ?, ?, ?, ?)',
[category, title.trim(), finalDate, time || null, datePrecision]
);
const sid = result.insertId;
if (members.length > 0) {
await conn.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
[members.map((m) => [sid, m])]);
}
return sid;
});
await syncScheduleById(meilisearch, db, scheduleId, redis);
logActivity(db, {
actor: 'admin', action: 'create', category: 'schedule',
targetType: 'schedule', targetId: scheduleId,
summary: `일정 생성: ${title.trim()}`,
});
reply.code(201);
return { success: true, scheduleId };
});
/**
* PUT /api/admin/schedules/:id 일정 수정
*/
fastify.put('/:id', {
schema: {
tags: ['admin/schedules'],
summary: '일반 일정 수정',
security: [{ bearerAuth: [] }],
params: { type: 'object', properties: { id: { type: 'integer' } }, required: ['id'] },
body: scheduleBody,
response: { 200: { type: 'object', additionalProperties: true }, 404: errorResponse },
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params;
const { title, date, time = null, category, datePrecision = 'day', members = [] } = request.body;
if (!title?.trim()) return badRequest(reply, '제목은 필수입니다.');
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
if (existing.length === 0) return notFound(reply, '일정을 찾을 수 없습니다.');
const finalDate = normalizeDate(date, datePrecision);
await withTransaction(db, async (conn) => {
await conn.query(
'UPDATE schedules SET category_id = ?, title = ?, date = ?, time = ?, date_precision = ? WHERE id = ?',
[category, title.trim(), finalDate, time || null, datePrecision, id]
);
await conn.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
if (members.length > 0) {
await conn.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
[members.map((m) => [id, m])]);
}
});
await syncScheduleById(meilisearch, db, parseInt(id), redis);
logActivity(db, {
actor: 'admin', action: 'update', category: 'schedule',
targetType: 'schedule', targetId: parseInt(id),
summary: `일정 수정: ${title.trim()}`,
});
return { success: true };
});
}

View file

@ -12,6 +12,7 @@ import xAdminRoutes from './admin/x.js';
import concertAdminRoutes from './admin/concert.js';
import eventsAdminRoutes from './admin/events.js';
import varietyAdminRoutes from './admin/variety.js';
import schedulesAdminRoutes from './admin/schedules.js';
import placesAdminRoutes from './admin/places.js';
import logsAdminRoutes from './admin/logs.js';
@ -62,6 +63,9 @@ export default async function routes(fastify) {
// 관리자 - 예능 라우트
fastify.register(varietyAdminRoutes, { prefix: '/admin/variety' });
// 관리자 - 일반 일정 라우트 (컴백·팬사인회·기타)
fastify.register(schedulesAdminRoutes, { prefix: '/admin/schedules' });
// 관리자 - 장소 검색 라우트
fastify.register(placesAdminRoutes, { prefix: '/admin' });