// publicController.js // 공개 이벤트 관련 API만 담당 (비밀번호 인증, 점수입력 등) const { Event, EventParticipant, Member, Club, EventScore, ClubMember, EventTeam, EventTeamMember, sequelize } = require('../models'); const { Op } = require('sequelize'); const { signPublicEventToken, verifyPublicEventToken } = require('../utils/publicJwt'); const averageService = require('../services/averageService'); // 공개 이벤트 최초 진입 (토큰 또는 비밀번호 인증) exports.publicEventEntry = async (req, res) => { const { publicHash } = req.params; const { password } = req.body || {}; const auth = req.headers.authorization; let event; try { event = await Event.findOne({ where: { publicHash }, include: [{ model: Club, attributes: ['id', 'name'] }] }); if (!event) { return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' }); } if (event.status === 'canceled') { return res.status(403).json({ message: '취소된 이벤트입니다.' }); } // 클럽의 구독 활성 여부 확인 - 가장 최근 구독 정보 가져오기 const clubSubscription = await sequelize.models.ClubSubscription.findOne({ where: { clubId: event.clubId }, include: [{ model: sequelize.models.SubscriptionPlan }], order: [['endDate', 'DESC']] // 가장 최근 구독 정보를 가져오기 위해 만료일 기준 내림차순 정렬 }); // 구독이 없거나 만료된 경우 접근 제한 const now = new Date(); if (!clubSubscription || new Date(clubSubscription.endDate) < now) { return res.status(403).json({ message: '클럽의 구독이 만료되어 이벤트에 접근할 수 없습니다. 클럽 관리자에게 문의하세요.' }); } let token = null; let isAuthenticated = false; // 1. 토큰 인증 우선 if (auth && auth.startsWith('Bearer ')) { try { const payload = verifyPublicEventToken(auth.slice(7)); if (payload.publicHash === event.publicHash) { isAuthenticated = true; } } catch (e) { // 토큰 인증 실패: isAuthenticated는 false } } // 2. 토큰 인증 실패 시 if (!isAuthenticated) { // 비밀번호가 필요한 이벤트 if (event.accessPassword) { if (!password) { return res.status(401).json({ message: '비밀번호가 필요합니다.' }); } if (password !== event.accessPassword) { return res.status(401).json({ message: '비밀번호가 일치하지 않습니다.' }); } } // 비밀번호가 없거나(공개 이벤트) or 비밀번호 인증 성공 isAuthenticated = true; token = signPublicEventToken({ publicHash: event.publicHash }); } // 3. 인증 성공 시 데이터 반환 if (isAuthenticated) { const data = await exports.getEventByPublicHash(event, token); return res.json(data); } // 4. 나머지는 모두 인증 실패 return res.status(401).json({ message: '인증이 필요합니다.' }); } catch (e) { console.error('[publicEventEntry] 공개 이벤트 최초 진입 오류', { publicHash, error: e }); res.status(500).json({ message: '이벤트 정보를 가져오는 중 오류가 발생했습니다.' }); } }; // 공개 이벤트 조회 (내부 데이터 반환 전용) exports.getEventByPublicHash = async (event, token = null) => { try { const allMembers = await Member.findAll({ where: { clubId: event.clubId }, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap'] }); const participants = await EventParticipant.findAll({ where: { eventId: event.id, status: { [Op.ne]: 'canceled' } }, include: [ { model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap'] } ] }); // 모든 회원에 대한 에버리지 계산 const allMemberObjs = await Promise.all(allMembers.map(async m => { const memberData = m.dataValues; try { const averageData = await averageService.calculateMemberAverageByClubSetting(memberData.memberId, event.clubId); memberData.average = averageData.average; } catch (error) { console.error(`회원 ${memberData.memberId}의 에버리지 계산 중 오류:`, error); memberData.average = 0; } return memberData; })); // 참가자에 대한 에버리지 추가 for (const participant of participants) { if (participant.Member) { try { const averageData = await averageService.calculateMemberAverageByClubSetting(participant.Member.dataValues.memberId, event.clubId); participant.Member.dataValues.average = averageData.average; } catch (error) { console.error(`참가자 ${participant.Member.dataValues.memberId}의 에버리지 계산 중 오류:`, error); participant.Member.dataValues.average = 0; } } } const participantMemberIds = [ ...new Set(participants.filter(p => p.dataValues.status !== 'canceled').map(p => parseInt(p.dataValues.memberId))) ]; // 상태별로 미참가자/게스트 추가 제한 let availableMembers = []; if (event.status === 'ready') { availableMembers = allMemberObjs.filter(m => !participantMemberIds.includes(parseInt(m.memberId))); } // 준비 아닐 땐 빈 배열(신청/게스트 추가 불가) // 팀 정보 const teams = await EventTeam.findAll({ where: { eventId: event.id }, order: [['teamNumber', 'ASC']], include: [{ model: EventTeamMember, include: [{ model: EventParticipant, where: { status: { [Op.ne]: 'canceled' } }, include: [{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap'] }] }] }] }); // 팀 멤버들에게 에버리지 정보 추가 for (const team of teams) { for (const teamMember of team.EventTeamMembers || []) { if (teamMember.EventParticipant?.Member) { const member = teamMember.EventParticipant.Member; try { const averageData = await averageService.calculateMemberAverageByClubSetting(member.dataValues.memberId, event.clubId); member.dataValues.average = averageData.average; } catch (error) { console.error(`팀 멤버 ${member.dataValues.memberId}의 에버리지 계산 중 오류:`, error); member.dataValues.average = 0; } } } } const scores = await EventScore.findAll({ where: { eventId: event.id } }); // 참가자 id → 참가자 객체 Map const participantMap = new Map(); participants.forEach(p => participantMap.set(p.id, p)); // 팀에 소속된 참가자 id Set const assignedParticipantIds = new Set(); const teamList = teams.map(team => ({ teamId: team.id, teamNumber: team.teamNumber, handicap: team.handicap, members: (team.EventTeamMembers || []).map(tm => { const participant = tm.EventParticipant; if (participant?.id) assignedParticipantIds.add(participant.id); const member = participant?.Member; // 각 게임별 점수 배열 생성 const scoresArr = Array(event.gameCount).fill(null); scores.filter(s => s.participantId === participant?.id).forEach(s => { if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) { scoresArr[s.gameNumber - 1] = { score: s.score, handicap: s.handicap }; } }); return { participantId: participant?.id, memberId: member?.memberId, name: member?.name || `참가자 ${participant?.id}`, gender: member?.gender, memberType: member?.memberType, average: allMemberObjs.find(m => Number(m.memberId) === Number(member?.memberId))?.average, handicap: member?.handicap || 0, status: participant?.status, paymentStatus: participant?.paymentStatus, comment: participant?.comment, isGuest: member?.memberType === 'guest', scores: scoresArr }; }) })); // 팀에 소속되지 않은 참가자(미배정) const unassignedMembers = participants .filter(p => !assignedParticipantIds.has(p.id)) .map(p => { const member = p.Member; // 각 게임별 점수 배열 생성 const scoresArr = Array(event.gameCount).fill(null); scores.filter(s => s.participantId === p.id).forEach(s => { if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) { scoresArr[s.gameNumber - 1] = { score: s.score, handicap: s.handicap }; } }); return { participantId: p.id, memberId: p.memberId, name: member?.name || `참가자 ${p.id}`, gender: member?.gender, memberType: member?.memberType, handicap: member?.handicap || 0, average: allMemberObjs.find(m => Number(m.memberId) === Number(p?.memberId))?.average, status: p.status, paymentStatus: p.paymentStatus, comment: p.comment, isGuest: member?.memberType === 'guest', scores: scoresArr }; }); let participantList = participants.map(p => { const member = p.Member; // 각 게임별 점수 배열 생성 const scoresArr = Array(event.gameCount).fill(null); scores.filter(s => s.participantId === p.id).forEach(s => { if (s.gameNumber && s.gameNumber >= 1 && s.gameNumber <= event.gameCount) { scoresArr[s.gameNumber - 1] = s.score; } }); return { participantId: p.id, memberId: p.memberId, name: member?.name || `참가자 ${p.id}`, gender: member?.gender, memberType: member?.memberType, handicap: member?.handicap || 0, average: allMemberObjs.find(m => Number(m.memberId) === Number(p?.memberId))?.average, status: p.status, paymentStatus: p.paymentStatus, comment: p.comment, isGuest: member?.memberType === 'guest', scores: scoresArr }; }); // 디버깅: 최종 average 값 확인 return { clubName: event.Club.name, event: { id: event.id, title: event.title, description: event.description, eventType: event.eventType, startDate: event.startDate, endDate: event.endDate, location: event.location, gameCount: event.gameCount, registrationDeadline: event.registrationDeadline, maxParticipants: event.maxParticipants, participantFee: event.participantFee, status: event.status }, teams: teamList, participants: participantList, unassignedMembers, availableMembers, needPassword: !!event.accessPassword, ...(token ? { token } : {}) }; } catch (e) { console.error('공개 이벤트 조회 오류:', e); throw new Error('이벤트 정보를 가져오는 중 오류가 발생했습니다.'); } }; // 공개 참가자 등록/수정 exports.registerPublicParticipant = async (req, res) => { const { publicHash } = req.params; const { participantId, memberId, status, paymentStatus, comment } = req.body || {}; try { const event = await Event.findOne({ where: { publicHash } }); if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' }); if (event.status === 'canceled') return res.status(403).json({ message: '취소된 이벤트입니다.' }); // 입력값 정규화 및 검증 const toInt = (v) => { if (v === undefined || v === null || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null; }; const pid = toInt(participantId); const mid = toInt(memberId); if (!mid) { return res.status(400).json({ message: '유효한 회원 ID가 필요합니다.' }); } // 상태/납부 검증 (프런트가 백엔드 값에 맞춰 보냄) const allowedStatus = ['pending', 'confirmed', 'canceled']; const allowedPayment = ['unpaid', 'paid']; const normStatus = status || 'pending'; const normPayment = paymentStatus || 'unpaid'; if (!allowedStatus.includes(normStatus)) { return res.status(400).json({ message: `유효하지 않은 참가 상태입니다. 허용값: ${allowedStatus.join(', ')}` }); } if (!allowedPayment.includes(normPayment)) { return res.status(400).json({ message: `유효하지 않은 납부 상태입니다. 허용값: ${allowedPayment.join(', ')}` }); } // 멤버 소속 검증 (이벤트 클럽 소속) const member = await Member.findOne({ where: { id: mid, clubId: event.clubId } }); if (!member) { return res.status(404).json({ message: '클럽 소속 회원을 찾을 수 없습니다.' }); } let participant = null; if (pid) { participant = await EventParticipant.findOne({ where: { id: pid, memberId: member.id } }); } if (!participant) { // 중복 참가 방지: 동일 eventId/memberId의 취소되지 않은 레코드가 있으면 업데이트로 처리 const existing = await EventParticipant.findOne({ where: { eventId: event.id, memberId: member.id, status: { [Op.ne]: 'canceled' } } }); if (existing) { await existing.update({ comment: comment ?? existing.comment, status: normStatus || existing.status, paymentStatus: normPayment || existing.paymentStatus }); } else { await EventParticipant.create({ eventId: event.id, memberId: member.id, comment: comment || null, status: normStatus, paymentStatus: normPayment }); } } else { await participant.update({ comment, status: normStatus, paymentStatus: normPayment }); } res.json({ message: '참가 신청/수정이 완료되었습니다.' }); } catch (e) { console.error('공개 참가자 등록 오류:', { error: e?.message, stack: e?.stack }); res.status(500).json({ message: '참가 신청/수정 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.' }); } }; // 공개 게스트 추가 exports.addPublicGuest = async (req, res) => { const { publicHash } = req.params; const { name, phone, gender, average } = req.body || {}; try { const event = await Event.findOne({ where: { publicHash } }); if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' }); if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' }); // 클럽의 회원 수와 구독 플랜의 최대 회원 수 확인 const memberCount = await Member.count({ where: { clubId: event.clubId } }); // 클럽 정보 가져오기 (여성 기본 핸디캡 확인을 위해) const club = await Club.findByPk(event.clubId); if (!club) return res.status(404).json({ message: '클럽 정보를 찾을 수 없습니다.' }); // 클럽의 구독 정보 가져오기 - 가장 최근 구독 정보 const clubSubscription = await sequelize.models.ClubSubscription.findOne({ where: { clubId: event.clubId }, include: [{ model: sequelize.models.SubscriptionPlan }], order: [['endDate', 'DESC']] // 가장 최근 구독 정보를 가져오기 위해 만료일 기준 내림차순 정렬 }); // 구독이 없거나 만료된 경우 접근 제한 const now = new Date(); if (!clubSubscription || new Date(clubSubscription.endDate) < now) { return res.status(403).json({ message: '클럽의 구독이 만료되어 이벤트에 접근할 수 없습니다. 클럽 관리자에게 문의하세요.' }); } // 회원 수가 최대치를 초과하는 경우 if (clubSubscription.SubscriptionPlan) { const maxMembers = clubSubscription.SubscriptionPlan.maxMembers; if (memberCount >= maxMembers) { return res.status(403).json({ message: `회원 수가 구독 플랜의 최대 허용 인원(${maxMembers}명)을 초과했습니다. 구독 업그레이드가 필요합니다.` }); } } // 여성 핸디캡 적용 let handicap = 0; if (gender === 'female' && club.femaleHandicap) { handicap = club.femaleHandicap; } // publicAuth 미들웨어로 인증됨 const member = await Member.create({ name, phone, gender, clubId: event.clubId, memberType: 'guest', average: 0, // 에버리지는 이제 사용하지 않지만 기본값은 설정 handicap: handicap // 여성 핸디캡 적용 }); const participant = await EventParticipant.create({ eventId: event.id, memberId: member.id }); // 클럽 설정에 따른 에버리지 계산 let calculatedAverage = 0; try { const averageData = await averageService.calculateMemberAverageByClubSetting(member.id, event.clubId); calculatedAverage = averageData.average; } catch (error) { console.error(`게스트 ${member.id}의 에버리지 계산 중 오류:`, error); } res.json({ message: '게스트가 추가되었습니다.', data: { memberId: member.id, participantId: participant.id, name: member.name, phone: member.phone, gender: member.gender, memberType: member.memberType, handicap: member.handicap, average: calculatedAverage, group: '미신청' } }); } catch (e) { console.error('공개 게스트 추가 오류:', e); res.status(500).json({ message: '게스트 추가 중 오류가 발생했습니다.' }); } }; // 공개 점수 수정 exports.updatePublicScore = async (req, res) => { const { publicHash } = req.params; let { participantId, gameNumber, score, handicap, teamNumber } = req.body || {}; score = Number(score); handicap = Number(handicap) || 0; // undefined/null이면 0 teamNumber = teamNumber ?? null; try { const event = await Event.findOne({ where: { publicHash } }); if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' }); if (event.status !== 'ready' && event.status !== 'active') return res.status(403).json({ message: '점수 수정이 불가능한 상태입니다.' }); // publicAuth 미들웨어로 인증됨 let eventScore = await EventScore.findOne({ where: { eventId: event.id, participantId, gameNumber } }); if (!eventScore) { eventScore = await EventScore.create({ eventId: event.id, participantId, gameNumber, teamNumber, score, handicap, totalScore: score + handicap }); } else { await eventScore.update({ teamNumber, score, handicap, totalScore: score + handicap }); } // 점수 변경 시 해당 회원의 에버리지 캩0시 무효화 try { const participant = await EventParticipant.findByPk(participantId); if (participant) { await averageService.invalidateMemberAverageCache(participant.memberId); } } catch (error) { console.error('에버리지 캐시 무효화 중 오류:', error); // 캩0시 무효화 오류는 점수 저장에 영향을 주지 않음 } res.json({ message: '점수가 저장되었습니다.' }); } catch (e) { console.error('공개 점수 수정 오류:', e); res.status(500).json({ message: '점수 저장 중 오류가 발생했습니다.' }); } }; // 팀 핸디캡 수정 (top-level export) exports.updateTeamHandicap = async (req, res) => { const { publicHash } = req.params; const { teamNumber, handicap } = req.body || {}; try { const event = await Event.findOne({ where: { publicHash } }); if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' }); if (event.status !== 'ready' && event.status !== 'active') return res.status(403).json({ message: '팀 핸디캡 수정이 불가능한 상태입니다.' }); const team = await EventTeam.findOne({ where: { eventId: event.id, teamNumber } }); if (!team) return res.status(404).json({ message: '팀을 찾을 수 없습니다.' }); const hc = Number(handicap) || 0; await team.update({ handicap: hc }); return res.json({ message: '팀 핸디캡이 저장되었습니다.', data: { teamNumber, handicap: hc } }); } catch (e) { console.error('팀 핸디캡 수정 오류:', e); res.status(500).json({ message: '팀 핸디캡 저장 중 오류가 발생했습니다.' }); } };