480 lines
20 KiB
JavaScript
480 lines
20 KiB
JavaScript
// 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 (maxMembers > 0 && 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: '팀 핸디캡 저장 중 오류가 발생했습니다.' });
|
|
}
|
|
};
|