fromis_9/backend/src/routes/admin/events.js

330 lines
10 KiB
JavaScript
Raw Normal View History

import { CATEGORY_IDS } from '../../config/index.js';
import { withTransaction } from '../../utils/transaction.js';
import { uploadEventPoster } from '../../services/image.js';
import { upsertVenue } from '../../services/event.js';
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]
);
}
// Meilisearch 동기화 + 월별 캐시 무효화
await syncScheduleById(meilisearch, db, scheduleId, fastify.redis);
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]
);
await syncScheduleById(meilisearch, db, parseInt(id), fastify.redis);
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 };
});
}