feat(schedule): 행사 카테고리 추가 (학교 행사)
- schedule_categories에 '행사' 카테고리(id=11) 시드, CATEGORY_IDS.EVENT 상수 추가
- event_venues / schedule_event 테이블 생성 (subtype, school_name, venue_id, post_urls, poster_image_ids)
- routes/admin/events.js 신설: multipart 기반 CRUD + 다중 포스터 업로드 + 카카오맵 venue upsert
- services/image.js에 uploadEventPoster 추가 (event/{scheduleId}/poster/...)
- 공개 /schedules 서비스의 SCHEDULE_LIST_SQL / getScheduleDetail에 행사 JOIN 및 응답(subtype, schoolName, venue, posters, postUrls)
- buildSource에 EVENT 분기 추가 → source.name = 학교명
- Meilisearch 동기화: source_name에 school_name 포함, 부분 검색 대응을 위한 resolveSchoolNames 추가
- 프론트: form/index.jsx에 '행사' 분기, EventForm 컴포넌트 신설 (LocationSearchDialog 재사용, 다중 포스터/URL)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:04:42 +09:00
|
|
|
import { CATEGORY_IDS } from '../../config/index.js';
|
|
|
|
|
import { withTransaction } from '../../utils/transaction.js';
|
|
|
|
|
import { uploadEventPoster } from '../../services/image.js';
|
2026-05-20 22:28:24 +09:00
|
|
|
import { upsertVenue } from '../../services/event.js';
|
feat(schedule): 행사 카테고리 추가 (학교 행사)
- schedule_categories에 '행사' 카테고리(id=11) 시드, CATEGORY_IDS.EVENT 상수 추가
- event_venues / schedule_event 테이블 생성 (subtype, school_name, venue_id, post_urls, poster_image_ids)
- routes/admin/events.js 신설: multipart 기반 CRUD + 다중 포스터 업로드 + 카카오맵 venue upsert
- services/image.js에 uploadEventPoster 추가 (event/{scheduleId}/poster/...)
- 공개 /schedules 서비스의 SCHEDULE_LIST_SQL / getScheduleDetail에 행사 JOIN 및 응답(subtype, schoolName, venue, posters, postUrls)
- buildSource에 EVENT 분기 추가 → source.name = 학교명
- Meilisearch 동기화: source_name에 school_name 포함, 부분 검색 대응을 위한 resolveSchoolNames 추가
- 프론트: form/index.jsx에 '행사' 분기, EventForm 컴포넌트 신설 (LocationSearchDialog 재사용, 다중 포스터/URL)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:04:42 +09:00
|
|
|
import { logActivity } from '../../utils/log.js';
|
|
|
|
|
import { syncScheduleById } from '../../services/meilisearch/index.js';
|
|
|
|
|
|
|
|
|
|
const EVENT_CATEGORY_ID = CATEGORY_IDS.EVENT;
|
|
|
|
|
const VALID_SUBTYPES = ['university'];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* multipart에서 payload(JSON 문자열) + poster 파일들 추출
|
|
|
|
|
*/
|
|
|
|
|
async function parseMultipartEventForm(request) {
|
|
|
|
|
const parts = request.parts();
|
|
|
|
|
let payload = null;
|
|
|
|
|
const posterFiles = [];
|
|
|
|
|
|
|
|
|
|
for await (const part of parts) {
|
|
|
|
|
if (part.type === 'file') {
|
|
|
|
|
const buf = await part.toBuffer();
|
|
|
|
|
posterFiles.push({
|
|
|
|
|
filename: part.filename,
|
|
|
|
|
buffer: buf,
|
|
|
|
|
mimetype: part.mimetype,
|
|
|
|
|
});
|
|
|
|
|
} else if (part.fieldname === 'payload') {
|
|
|
|
|
payload = JSON.parse(part.value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { payload, posterFiles };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* images 테이블에 INSERT 후 id 반환
|
|
|
|
|
*/
|
|
|
|
|
async function saveImageRecord(db, { originalUrl, mediumUrl, thumbUrl }) {
|
|
|
|
|
const [result] = await db.query(
|
|
|
|
|
`INSERT INTO images (original_url, medium_url, thumb_url) VALUES (?, ?, ?)`,
|
|
|
|
|
[originalUrl, mediumUrl, thumbUrl]
|
|
|
|
|
);
|
|
|
|
|
return result.insertId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 행사 관련 관리자 라우트
|
|
|
|
|
*/
|
|
|
|
|
export default async function eventsRoutes(fastify) {
|
|
|
|
|
const { db, meilisearch } = fastify;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/admin/events/:id
|
|
|
|
|
* 행사 상세 조회 (수정 폼용)
|
|
|
|
|
*/
|
|
|
|
|
fastify.get('/:id', {
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { id } = request.params;
|
|
|
|
|
|
|
|
|
|
const [rows] = await db.query(`
|
|
|
|
|
SELECT s.id, s.title, s.date, s.time,
|
|
|
|
|
se.subtype, se.school_name, se.venue_id, se.post_urls, se.poster_image_ids,
|
|
|
|
|
ev.name as venue_name, ev.address as venue_address,
|
|
|
|
|
ev.road_address as venue_road_address, ev.lat as venue_lat, ev.lng as venue_lng,
|
|
|
|
|
ev.kakao_id as venue_kakao_id
|
|
|
|
|
FROM schedules s
|
|
|
|
|
JOIN schedule_event se ON s.id = se.schedule_id
|
|
|
|
|
LEFT JOIN event_venues ev ON se.venue_id = ev.id
|
|
|
|
|
WHERE s.id = ?
|
|
|
|
|
`, [id]);
|
|
|
|
|
|
|
|
|
|
if (rows.length === 0) {
|
|
|
|
|
return reply.code(404).send({ error: '행사를 찾을 수 없습니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const r = rows[0];
|
|
|
|
|
|
|
|
|
|
// 멤버
|
|
|
|
|
const [memberRows] = await db.query(
|
|
|
|
|
'SELECT member_id FROM schedule_members WHERE schedule_id = ?',
|
|
|
|
|
[id]
|
|
|
|
|
);
|
|
|
|
|
const memberIds = memberRows.map(m => m.member_id);
|
|
|
|
|
|
|
|
|
|
// 포스터 이미지 (순서 유지)
|
|
|
|
|
const posterIds = r.poster_image_ids
|
|
|
|
|
? (typeof r.poster_image_ids === 'string' ? JSON.parse(r.poster_image_ids) : r.poster_image_ids)
|
|
|
|
|
: [];
|
|
|
|
|
let posters = [];
|
|
|
|
|
if (posterIds.length > 0) {
|
|
|
|
|
const [posterRows] = await db.query(
|
|
|
|
|
`SELECT id, original_url, medium_url, thumb_url FROM images WHERE id IN (?) ORDER BY FIELD(id, ?)`,
|
|
|
|
|
[posterIds, posterIds]
|
|
|
|
|
);
|
|
|
|
|
posters = posterRows.map(p => ({
|
|
|
|
|
id: p.id,
|
|
|
|
|
originalUrl: p.original_url,
|
|
|
|
|
mediumUrl: p.medium_url,
|
|
|
|
|
thumbUrl: p.thumb_url,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const date = r.date instanceof Date
|
|
|
|
|
? r.date.toISOString().split('T')[0]
|
|
|
|
|
: String(r.date).split('T')[0];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: r.id,
|
|
|
|
|
title: r.title,
|
|
|
|
|
date,
|
|
|
|
|
time: r.time ? r.time.substring(0, 5) : '',
|
|
|
|
|
subtype: r.subtype,
|
|
|
|
|
schoolName: r.school_name || '',
|
|
|
|
|
memberIds,
|
|
|
|
|
venue: r.venue_id ? {
|
|
|
|
|
id: r.venue_id,
|
|
|
|
|
name: r.venue_name,
|
|
|
|
|
address: r.venue_address,
|
|
|
|
|
roadAddress: r.venue_road_address,
|
|
|
|
|
lat: r.venue_lat,
|
|
|
|
|
lng: r.venue_lng,
|
|
|
|
|
kakao_id: r.venue_kakao_id,
|
|
|
|
|
} : null,
|
|
|
|
|
postUrls: r.post_urls
|
|
|
|
|
? (typeof r.post_urls === 'string' ? JSON.parse(r.post_urls) : r.post_urls)
|
|
|
|
|
: [],
|
|
|
|
|
posters,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /api/admin/events
|
|
|
|
|
* 행사 생성 (multipart/form-data: payload + poster 파일들)
|
|
|
|
|
*/
|
|
|
|
|
fastify.post('/', {
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { payload, posterFiles } = await parseMultipartEventForm(request);
|
|
|
|
|
|
|
|
|
|
if (!payload) {
|
|
|
|
|
return reply.code(400).send({ error: 'payload가 필요합니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
title, date, time, subtype = 'university', schoolName,
|
|
|
|
|
memberIds = [], venue, postUrls = [],
|
|
|
|
|
} = payload;
|
|
|
|
|
|
|
|
|
|
if (!title || !date || !schoolName) {
|
|
|
|
|
return reply.code(400).send({ error: '제목/날짜/학교명은 필수입니다.' });
|
|
|
|
|
}
|
|
|
|
|
if (!VALID_SUBTYPES.includes(subtype)) {
|
|
|
|
|
return reply.code(400).send({ error: `알 수 없는 subtype: ${subtype}` });
|
|
|
|
|
}
|
|
|
|
|
if (!venue) {
|
|
|
|
|
return reply.code(400).send({ error: '장소가 필요합니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const scheduleId = await withTransaction(db, async (conn) => {
|
|
|
|
|
// 1) venue upsert
|
|
|
|
|
const venueId = await upsertVenue(conn, venue);
|
|
|
|
|
|
|
|
|
|
// 2) schedules INSERT
|
|
|
|
|
const [sResult] = await conn.query(
|
|
|
|
|
`INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)`,
|
|
|
|
|
[EVENT_CATEGORY_ID, title, date, time || null]
|
|
|
|
|
);
|
|
|
|
|
const sid = sResult.insertId;
|
|
|
|
|
|
|
|
|
|
// 3) schedule_event INSERT (poster는 트랜잭션 후 업로드, 그 때 UPDATE)
|
|
|
|
|
await conn.query(
|
|
|
|
|
`INSERT INTO schedule_event (schedule_id, subtype, school_name, venue_id, post_urls)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
|
|
|
[
|
|
|
|
|
sid,
|
|
|
|
|
subtype,
|
|
|
|
|
schoolName,
|
|
|
|
|
venueId,
|
|
|
|
|
postUrls.length > 0 ? JSON.stringify(postUrls) : null,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 4) 멤버 연결
|
|
|
|
|
if (memberIds.length > 0) {
|
|
|
|
|
const values = memberIds.map(mid => [sid, mid]);
|
|
|
|
|
await conn.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sid;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 5) 포스터 업로드 (트랜잭션 밖 — S3 I/O)
|
|
|
|
|
if (posterFiles.length > 0) {
|
|
|
|
|
const uploadedIds = [];
|
|
|
|
|
for (let i = 0; i < posterFiles.length; i++) {
|
|
|
|
|
const ext = (posterFiles[i].filename.split('.').pop() || 'webp').toLowerCase();
|
|
|
|
|
const filename = `${String(i + 1).padStart(2, '0')}.${ext === 'jpg' ? 'jpeg' : ext}`;
|
|
|
|
|
const urls = await uploadEventPoster(scheduleId, filename, posterFiles[i].buffer);
|
|
|
|
|
const imgId = await saveImageRecord(db, urls);
|
|
|
|
|
uploadedIds.push(imgId);
|
|
|
|
|
}
|
|
|
|
|
await db.query(
|
|
|
|
|
`UPDATE schedule_event SET poster_image_ids = ? WHERE schedule_id = ?`,
|
|
|
|
|
[JSON.stringify(uploadedIds), scheduleId]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 16:39:40 +09:00
|
|
|
// Meilisearch 동기화 + 월별 캐시 무효화
|
|
|
|
|
await syncScheduleById(meilisearch, db, scheduleId, fastify.redis);
|
feat(schedule): 행사 카테고리 추가 (학교 행사)
- schedule_categories에 '행사' 카테고리(id=11) 시드, CATEGORY_IDS.EVENT 상수 추가
- event_venues / schedule_event 테이블 생성 (subtype, school_name, venue_id, post_urls, poster_image_ids)
- routes/admin/events.js 신설: multipart 기반 CRUD + 다중 포스터 업로드 + 카카오맵 venue upsert
- services/image.js에 uploadEventPoster 추가 (event/{scheduleId}/poster/...)
- 공개 /schedules 서비스의 SCHEDULE_LIST_SQL / getScheduleDetail에 행사 JOIN 및 응답(subtype, schoolName, venue, posters, postUrls)
- buildSource에 EVENT 분기 추가 → source.name = 학교명
- Meilisearch 동기화: source_name에 school_name 포함, 부분 검색 대응을 위한 resolveSchoolNames 추가
- 프론트: form/index.jsx에 '행사' 분기, EventForm 컴포넌트 신설 (LocationSearchDialog 재사용, 다중 포스터/URL)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:04:42 +09:00
|
|
|
|
|
|
|
|
logActivity(db, {
|
|
|
|
|
actor: 'admin', action: 'create', category: 'schedule',
|
|
|
|
|
targetType: 'event_schedule', targetId: scheduleId,
|
|
|
|
|
summary: `행사 생성: ${title}`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
reply.code(201);
|
|
|
|
|
return { id: scheduleId };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* PUT /api/admin/events/:id
|
|
|
|
|
* 행사 수정 (multipart: payload + 새 poster 파일들)
|
|
|
|
|
* payload.keepPosterIds: 유지할 기존 포스터 ID 배열 (순서대로)
|
|
|
|
|
*/
|
|
|
|
|
fastify.put('/:id', {
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { id } = request.params;
|
|
|
|
|
const { payload, posterFiles } = await parseMultipartEventForm(request);
|
|
|
|
|
|
|
|
|
|
if (!payload) {
|
|
|
|
|
return reply.code(400).send({ error: 'payload가 필요합니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [existing] = await db.query('SELECT schedule_id, poster_image_ids FROM schedule_event WHERE schedule_id = ?', [id]);
|
|
|
|
|
if (existing.length === 0) {
|
|
|
|
|
return reply.code(404).send({ error: '행사를 찾을 수 없습니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
title, date, time, subtype, schoolName,
|
|
|
|
|
memberIds = [], venue, postUrls = [], keepPosterIds = [],
|
|
|
|
|
} = payload;
|
|
|
|
|
|
|
|
|
|
await withTransaction(db, async (conn) => {
|
|
|
|
|
// schedules UPDATE
|
|
|
|
|
await conn.query(
|
|
|
|
|
`UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?`,
|
|
|
|
|
[title, date, time || null, id]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// venue upsert
|
|
|
|
|
const venueId = venue ? await upsertVenue(conn, venue) : null;
|
|
|
|
|
|
|
|
|
|
// schedule_event UPDATE
|
|
|
|
|
await conn.query(
|
|
|
|
|
`UPDATE schedule_event
|
|
|
|
|
SET subtype = ?, school_name = ?, venue_id = ?, post_urls = ?
|
|
|
|
|
WHERE schedule_id = ?`,
|
|
|
|
|
[
|
|
|
|
|
subtype,
|
|
|
|
|
schoolName,
|
|
|
|
|
venueId,
|
|
|
|
|
postUrls.length > 0 ? JSON.stringify(postUrls) : null,
|
|
|
|
|
id,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 멤버 재등록
|
|
|
|
|
await conn.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
|
|
|
|
|
if (memberIds.length > 0) {
|
|
|
|
|
const values = memberIds.map(mid => [id, mid]);
|
|
|
|
|
await conn.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 포스터: 새 파일 업로드 후 keepPosterIds + 새 id 순서로 저장
|
|
|
|
|
const newIds = [];
|
|
|
|
|
for (let i = 0; i < posterFiles.length; i++) {
|
|
|
|
|
const ext = (posterFiles[i].filename.split('.').pop() || 'webp').toLowerCase();
|
|
|
|
|
const filename = `${Date.now()}_${i}.${ext === 'jpg' ? 'jpeg' : ext}`;
|
|
|
|
|
const urls = await uploadEventPoster(id, filename, posterFiles[i].buffer);
|
|
|
|
|
const imgId = await saveImageRecord(db, urls);
|
|
|
|
|
newIds.push(imgId);
|
|
|
|
|
}
|
|
|
|
|
const finalIds = [...keepPosterIds, ...newIds];
|
|
|
|
|
await db.query(
|
|
|
|
|
`UPDATE schedule_event SET poster_image_ids = ? WHERE schedule_id = ?`,
|
|
|
|
|
[finalIds.length > 0 ? JSON.stringify(finalIds) : null, id]
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-07 16:39:40 +09:00
|
|
|
await syncScheduleById(meilisearch, db, parseInt(id), fastify.redis);
|
feat(schedule): 행사 카테고리 추가 (학교 행사)
- schedule_categories에 '행사' 카테고리(id=11) 시드, CATEGORY_IDS.EVENT 상수 추가
- event_venues / schedule_event 테이블 생성 (subtype, school_name, venue_id, post_urls, poster_image_ids)
- routes/admin/events.js 신설: multipart 기반 CRUD + 다중 포스터 업로드 + 카카오맵 venue upsert
- services/image.js에 uploadEventPoster 추가 (event/{scheduleId}/poster/...)
- 공개 /schedules 서비스의 SCHEDULE_LIST_SQL / getScheduleDetail에 행사 JOIN 및 응답(subtype, schoolName, venue, posters, postUrls)
- buildSource에 EVENT 분기 추가 → source.name = 학교명
- Meilisearch 동기화: source_name에 school_name 포함, 부분 검색 대응을 위한 resolveSchoolNames 추가
- 프론트: form/index.jsx에 '행사' 분기, EventForm 컴포넌트 신설 (LocationSearchDialog 재사용, 다중 포스터/URL)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:04:42 +09:00
|
|
|
|
|
|
|
|
logActivity(db, {
|
|
|
|
|
actor: 'admin', action: 'update', category: 'schedule',
|
|
|
|
|
targetType: 'event_schedule', targetId: parseInt(id),
|
|
|
|
|
summary: `행사 수정: ${title}`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { id: parseInt(id) };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* DELETE /api/admin/events/:id
|
|
|
|
|
*/
|
|
|
|
|
fastify.delete('/:id', {
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { id } = request.params;
|
|
|
|
|
|
|
|
|
|
const [existing] = await db.query('SELECT s.title FROM schedules s JOIN schedule_event se ON s.id = se.schedule_id WHERE s.id = ?', [id]);
|
|
|
|
|
if (existing.length === 0) {
|
|
|
|
|
return reply.code(404).send({ error: '행사를 찾을 수 없습니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// schedules CASCADE로 schedule_event/schedule_members/schedule_images도 정리됨
|
|
|
|
|
await db.query('DELETE FROM schedules WHERE id = ?', [id]);
|
|
|
|
|
|
|
|
|
|
logActivity(db, {
|
|
|
|
|
actor: 'admin', action: 'delete', category: 'schedule',
|
|
|
|
|
targetType: 'event_schedule', targetId: parseInt(id),
|
|
|
|
|
summary: `행사 삭제: ${existing[0].title}`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
});
|
|
|
|
|
}
|