diff --git a/backend/app.js b/backend/app.js index e084ba7..39e58f1 100644 --- a/backend/app.js +++ b/backend/app.js @@ -7,6 +7,7 @@ const socketIo = require('socket.io'); const adminRoutes = require('./routes/adminRoutes'); const authRoutes = require('./routes/authRoutes'); const eventRoutes = require('./routes/eventRoutes'); +const publicEventRoutes = require('./routes/publicEventRoutes'); const clubRoutes = require('./routes/clubRoutes'); const userRoutes = require('./routes/userRoutes'); const notificationRoutes = require('./routes/notificationRoutes'); @@ -75,6 +76,7 @@ app.use('/api/auth', authRoutes); app.use('/api/admin', adminRoutes); app.use('/api/club', clubRoutes); app.use('/api/club/events', eventRoutes); +app.use('/api/public', publicEventRoutes); app.use('/api/features', featureRoutes); app.use('/api/subscriptions', subscriptionRoutes); app.use('/api/users', userRoutes); diff --git a/backend/config/database.js b/backend/config/database.js index 9bbc731..488cda2 100644 --- a/backend/config/database.js +++ b/backend/config/database.js @@ -18,7 +18,7 @@ const sequelize = new Sequelize( host: process.env.DB_HOST, port: process.env.DB_PORT, dialect: 'mysql', - logging: process.env.NODE_ENV === 'development' ? console.log : false, // 개발 환경에서만 로깅 활성화 + logging: false, //process.env.NODE_ENV === 'development' ? console.log : false, // 개발 환경에서만 로깅 활성화 pool: { max: 5, min: 0, diff --git a/backend/controllers/eventController.js b/backend/controllers/eventController.js index f2f62df..d0dfe9f 100644 --- a/backend/controllers/eventController.js +++ b/backend/controllers/eventController.js @@ -141,6 +141,22 @@ exports.createEvent = async (req, res) => { return res.status(404).json({ message: '클럽을 찾을 수 없습니다.' }); } const endDateVal = endDate ? new Date(endDate) : null; + // 랜덤 해시 생성 함수 + const generateRandomHash = async () => { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let hash; + let exists = true; + while (exists) { + hash = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); + // 중복 체크 + exists = await Event.findOne({ where: { publicHash: hash } }); + } + return hash; + }; + + const publicHash = await generateRandomHash(); + const accessPassword = req.body.accessPassword || null; + const event = await Event.create({ clubId, title, @@ -153,7 +169,9 @@ exports.createEvent = async (req, res) => { entryFee: entryFee || 0, gameCount: gameCount || 3, laneCount: laneCount || 1, - status: status || '활성' + status: status || '활성', + publicHash, + accessPassword }); const createdEvent = await Event.findOne({ @@ -180,6 +198,248 @@ exports.createEvent = async (req, res) => { } }; +// 공개 이벤트 조회 및 비밀번호 인증 +exports.getEventByPublicHash = async (req, res) => { + const { publicHash } = req.params; + const { password } = req.body || {}; + + try { + // 1. 이벤트 조회 + const event = await Event.findOne({ + where: { publicHash }, + include: [{ + model: Club, + attributes: ['id', 'name'] + }] + }); + if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' }); + if (event.status === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' }); + + // 2. 비밀번호 필요 여부 및 인증 + if (event.accessPassword) { + if (!password) return res.status(401).json({ needPassword: true, message: '비밀번호 필요' }); + if (event.accessPassword !== password) return res.status(401).json({ needPassword: true, message: '비밀번호 불일치' }); + } + + // 3. 전체 회원 명단 추출 (clubId 사용, clubId는 응답에 포함 X) + const allMembers = await Member.findAll({ + where: { clubId: event.clubId }, + attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average'] + }); + + // 4. 참가자 명단 추출 (참가자 테이블 + Member 조인) + const participants = await EventParticipant.findAll({ + where: { eventId: event.id, status: { [Op.ne]: '취소' } }, + include: [ + { model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average'] } + ] + }); + // 5. 참가 신청 가능 명단(아직 참가하지 않은 회원) + // Sequelize 인스턴스에서 dataValues 추출 + const allMemberObjs = allMembers.map(m => m.dataValues); + // '취소'가 아닌 status가 하나라도 있으면 참가자로 간주 (중복 제거) + const participantMemberIds = [ + ...new Set( + participants + .filter(p => p.dataValues.status !== '취소') + .map(p => parseInt(p.dataValues.memberId)) + ) + ]; + const availableMembers = allMemberObjs.filter(m => !participantMemberIds.includes(parseInt(m.memberId))); + // 6. 팀 정보 등 추가 조회 (기존 코드 유지) + const teams = await EventTeam.findAll({ + where: { eventId: event.id }, + order: [['teamNumber', 'ASC']], + include: [ + { + model: EventTeamMember, + include: [ + { + model: EventParticipant, + include: [{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap', 'average'] }] + } + ] + } + ] + }); + + // 점수 + const scores = await EventScore.findAll({ where: { eventId: event.id } }); + + // 팀별 그룹핑 + let teamList = []; + if (teams.length > 0) { + teamList = teams.map(team => ({ + teamId: team.id, + teamNumber: team.teamNumber, + handicap: team.handicap, + members: (team.EventTeamMembers || []).map(tm => { + const participant = tm.EventParticipant; + const member = participant?.Member; + const scoreObj = scores.find(s => s.participantId === participant?.id); + return { + participantId: participant?.id, + memberId: member?.memberId, + name: member?.name || `참가자 ${participant?.id}`, + gender: member?.gender, + memberType: member?.memberType, + average: member?.average || 0, + handicap: member?.handicap || 0, + status: participant?.status, + paymentStatus: participant?.paymentStatus, + comment: participant?.comment, + isGuest: member?.memberType === '게스트', + score: scoreObj ? scoreObj.score : null + }; + }) + })); + } + + // 참가자 리스트(팀 없는 경우) + let participantList = []; + participantList = participants.map(p => { + const member = p.Member; + const scoreObj = scores.find(s => s.participantId === p.id); + return { + participantId: p.id, + memberId: p.memberId, + name: member?.name || `참가자 ${p.id}`, + gender: member?.gender, + memberType: member?.memberType, + handicap: member?.handicap || 0, + average: member?.average || 0, + status: p.status, + paymentStatus: p.paymentStatus, + comment: p.comment, + isGuest: member?.memberType === '게스트', + score: scoreObj ? scoreObj.score : null + }; + }); + // 반환 데이터 + res.json({ + 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, + availableMembers, + needPassword: !!event.accessPassword + }); + } catch (e) { + console.error('공개 이벤트 조회 오류:', e); + res.status(500).json({ message: '이벤트 정보를 가져오는 중 오류가 발생했습니다.' }); + } +}; + +// 공개 URL(publicHash) 재생성 +exports.regeneratePublicHash = async (req, res) => { + const { id } = req.params; + try { + const event = await Event.findOne({ where: { id } }); + if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' }); + // 랜덤 해시 생성 (중복 없는 값) + const generateRandomHash = async () => { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let hash; + let exists = true; + while (exists) { + hash = Array.from({ length: 16 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); + exists = await Event.findOne({ where: { publicHash: hash } }); + } + return hash; + }; + const newHash = await generateRandomHash(); + await event.update({ publicHash: newHash }); + res.json({ publicHash: newHash }); + } catch (e) { + console.error('공개 URL 재생성 오류:', e); + res.status(500).json({ message: '공개 URL 재생성 중 오류가 발생했습니다.' }); + } +}; + +// 공개 참가자 등록/수정 +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 === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' }); + // 기존 참가자 있는지 검사(이름+연락처 기준) + let member = await Member.findOne({ where: { id:memberId } }); + // if (!member) { + // member = await Member.create({ memberId, memberType: '일반' }); + // } + let participant = await EventParticipant.findOne({ where: { id: participantId, memberId: member.id } }); + if (!participant) { + participant = await EventParticipant.create({ eventId: event.id, memberId: member.id, comment, status, paymentStatus }); + } else { + await participant.update({ comment, status, paymentStatus }); + } + res.json({ message: '참가 신청/수정이 완료되었습니다.' }); + } catch (e) { + console.error('공개 참가자 등록 오류:', e); + res.status(500).json({ message: '참가 신청/수정 중 오류가 발생했습니다.' }); + } +}; + +// 공개 게스트 추가 +exports.addPublicGuest = async (req, res) => { + const { publicHash } = req.params; + const { name, password } = 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: '취소된 이벤트입니다.' }); + if (event.accessPassword) { + if (!password || event.accessPassword !== password) return res.status(401).json({ message: '비밀번호가 올바르지 않습니다.' }); + } + // 게스트 멤버 생성 + const member = await Member.create({ name, memberType: '게스트' }); + await EventParticipant.create({ eventId: event.id, memberId: member.id }); + res.json({ message: '게스트가 추가되었습니다.' }); + } catch (e) { + console.error('공개 게스트 추가 오류:', e); + res.status(500).json({ message: '게스트 추가 중 오류가 발생했습니다.' }); + } +}; + +// 공개 점수 수정 +exports.updatePublicScore = async (req, res) => { + const { publicHash } = req.params; + const { participantId, score, password } = req.body || {}; + try { + const event = await Event.findOne({ where: { publicHash } }); + if (!event) return res.status(404).json({ message: '이벤트를 찾을 수 없습니다.' }); + if (event.status !== '준비' && event.status !== '활성') return res.status(403).json({ message: '점수 수정이 불가능한 상태입니다.' }); + if (event.accessPassword) { + if (!password || event.accessPassword !== password) return res.status(401).json({ message: '비밀번호가 올바르지 않습니다.' }); + } + let eventScore = await EventScore.findOne({ where: { eventId: event.id, participantId } }); + if (!eventScore) { + eventScore = await EventScore.create({ eventId: event.id, participantId, score }); + } else { + await eventScore.update({ score }); + } + res.json({ message: '점수가 저장되었습니다.' }); + } catch (e) { + console.error('공개 점수 수정 오류:', e); + res.status(500).json({ message: '점수 저장 중 오류가 발생했습니다.' }); + } +}; + // 이벤트 정보 업데이트 exports.updateEvent = async (req, res) => { try { @@ -194,7 +454,9 @@ exports.updateEvent = async (req, res) => { entryFee, gameCount, laneCount, - status + status, + publicHash, + accessPassword } = req.body; const eventId = req.body.id; let endDate = req.body.endDate; @@ -228,9 +490,10 @@ exports.updateEvent = async (req, res) => { entryFee: entryFee !== undefined ? entryFee : event.entryFee, gameCount: gameCount || event.gameCount, laneCount: laneCount || event.laneCount, - status: status || event.status + status: status || event.status, + publicHash: publicHash || event.publicHash, + accessPassword: accessPassword || null }; - await event.update(updateData); const updatedEvent = await Event.findOne({ diff --git a/backend/controllers/publicController.js b/backend/controllers/publicController.js new file mode 100644 index 0000000..3b69880 --- /dev/null +++ b/backend/controllers/publicController.js @@ -0,0 +1,304 @@ +// 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'); + +// 공개 이벤트 최초 진입 (토큰 또는 비밀번호 인증) +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 === '취소') { + 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', 'average'] + }); + const participants = await EventParticipant.findAll({ + where: { eventId: event.id, status: { [Op.ne]: '취소' } }, + include: [ { model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'average'] } ] + }); + const allMemberObjs = allMembers.map(m => m.dataValues); + const participantMemberIds = [ + ...new Set(participants.filter(p => p.dataValues.status !== '취소').map(p => parseInt(p.dataValues.memberId))) + ]; + // 상태별로 미참가자/게스트 추가 제한 + let availableMembers = []; + if (event.status === '준비') { + 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]: '취소' } }, + include: [{ model: Member, attributes: [[sequelize.col('id'), 'memberId'], 'name', 'memberType', 'gender', 'handicap', 'average'] }] + }] + }] + }); + 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] = s.score; + } + }); + return { + participantId: participant?.id, + memberId: member?.memberId, + name: member?.name || `참가자 ${participant?.id}`, + gender: member?.gender, + memberType: member?.memberType, + average: member?.average || 0, + handicap: member?.handicap || 0, + status: participant?.status, + paymentStatus: participant?.paymentStatus, + comment: participant?.comment, + isGuest: member?.memberType === '게스트', + 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] = 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: member?.average || 0, + status: p.status, + paymentStatus: p.paymentStatus, + comment: p.comment, + isGuest: member?.memberType === '게스트', + 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: member?.average || 0, + status: p.status, + paymentStatus: p.paymentStatus, + comment: p.comment, + isGuest: member?.memberType === '게스트', + scores: scoresArr + }; + }); + 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 === '취소') return res.status(403).json({ message: '취소된 이벤트입니다.' }); + let member = await Member.findOne({ where: { id: memberId } }); + let participant = await EventParticipant.findOne({ where: { id: participantId, memberId: member.id } }); + if (!participant) { + participant = await EventParticipant.create({ eventId: event.id, memberId: member.id, comment, status, paymentStatus }); + } else { + await participant.update({ comment, status, paymentStatus }); + } + res.json({ message: '참가 신청/수정이 완료되었습니다.' }); + } catch (e) { + console.error('공개 참가자 등록 오류:', e); + 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: '취소된 이벤트입니다.' }); + // publicAuth 미들웨어로 인증됨 + const member = await Member.create({ + name, + phone, + gender, + clubId: event.clubId, + memberType: '게스트', + average: average || 0 + }); + const participant = await EventParticipant.create({ eventId: event.id, memberId: member.id }); + 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: member.average, + 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 !== '준비' && event.status !== '활성') 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 }); + } + res.json({ message: '점수가 저장되었습니다.' }); + } catch (e) { + console.error('공개 점수 수정 오류:', e); + res.status(500).json({ message: '점수 저장 중 오류가 발생했습니다.' }); + } +}; diff --git a/backend/middleware/publicAuth.js b/backend/middleware/publicAuth.js new file mode 100644 index 0000000..7a8e55e --- /dev/null +++ b/backend/middleware/publicAuth.js @@ -0,0 +1,13 @@ +// middleware/publicAuth.js +const { verifyPublicEventToken } = require('../utils/publicJwt'); + +module.exports = (req, res, next) => { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ message: '토큰 필요' }); + try { + req.publicAuth = verifyPublicEventToken(auth.slice(7)); + next(); + } catch (e) { + return res.status(401).json({ message: '토큰 인증 실패' }); + } +}; diff --git a/backend/models/Event.js b/backend/models/Event.js index 3d9f79e..b17f821 100644 --- a/backend/models/Event.js +++ b/backend/models/Event.js @@ -33,6 +33,16 @@ const Event = sequelize.define('Event', { defaultValue: '정기전', comment: '이벤트 유형' }, + publicHash: { + type: DataTypes.STRING, + allowNull: true, + comment: '공개 URL 해시' + }, + accessPassword: { + type: DataTypes.STRING, + allowNull: true, + comment: '공개 URL 비밀번호' + }, startDate: { type: DataTypes.DATE, allowNull: false, diff --git a/backend/models/EventParticipant.js b/backend/models/EventParticipant.js index 5748461..ca83795 100644 --- a/backend/models/EventParticipant.js +++ b/backend/models/EventParticipant.js @@ -35,6 +35,11 @@ const EventParticipant = sequelize.define('EventParticipant', { }, comment: '이벤트 팀 ID' }, + comment: { + type: DataTypes.STRING, + allowNull: true, + comment: '비고' + }, status: { type: DataTypes.ENUM('참가예정', '참가확정', '취소'), allowNull: false, diff --git a/backend/models/Member.js b/backend/models/Member.js index 5314604..ae72c64 100644 --- a/backend/models/Member.js +++ b/backend/models/Member.js @@ -39,7 +39,7 @@ const Member = sequelize.define('Member', { comment: '성별' }, memberType: { - type: DataTypes.ENUM('정회원', '준회원', '운영진', '모임장'), + type: DataTypes.ENUM('정회원', '준회원', '게스트', '운영진', '모임장'), allowNull: false, defaultValue: '준회원', comment: '회원 유형' diff --git a/backend/models/index.js b/backend/models/index.js index 717116f..462f7d7 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -27,8 +27,8 @@ const syncModels = async () => { // await sequelize.query('SET FOREIGN_KEY_CHECKS = 1'); // 운영 alter: true - 구조변경만 alter처리 - // await sequelize.sync({ alter: true }); - await sequelize.sync(); + await sequelize.sync({ alter: true }); + // await sequelize.sync(); } catch (error) { console.error('모델 동기화 중 오류 발생:', error); } diff --git a/backend/routes/eventRoutes.js b/backend/routes/eventRoutes.js index 9de3cfc..7fa86e6 100644 --- a/backend/routes/eventRoutes.js +++ b/backend/routes/eventRoutes.js @@ -31,4 +31,9 @@ router.delete('/:id/participants/:participantId/scores', authenticateJWT, requir // 사용자별 이벤트 관리 router.get('/user/events', authenticateJWT, requireFeature('event_management'), eventController.getUserEvents); + + +// 공개 URL(publicHash) 재생성 +router.patch('/:id/publicHash', eventController.regeneratePublicHash); + module.exports = router; \ No newline at end of file diff --git a/backend/routes/publicEventRoutes.js b/backend/routes/publicEventRoutes.js new file mode 100644 index 0000000..d73b14b --- /dev/null +++ b/backend/routes/publicEventRoutes.js @@ -0,0 +1,15 @@ +const express = require('express'); +const router = express.Router(); +const publicController = require('../controllers/publicController'); +const publicAuth = require('../middleware/publicAuth'); + +// 공개 이벤트 최초 진입 (비밀번호 인증 또는 토큰 인증) +router.post('/:publicHash', publicController.publicEventEntry); +// 공개 참가자 등록/수정 +router.post('/:publicHash/participant', publicAuth, publicController.registerPublicParticipant); +// 공개 게스트 추가 +router.post('/:publicHash/guest', publicAuth, publicController.addPublicGuest); +// 공개 점수 수정 +router.put('/:publicHash/score', publicAuth, publicController.updatePublicScore); + +module.exports = router; diff --git a/backend/utils/publicJwt.js b/backend/utils/publicJwt.js new file mode 100644 index 0000000..47e66f0 --- /dev/null +++ b/backend/utils/publicJwt.js @@ -0,0 +1,12 @@ +// utils/publicJwt.js +const jwt = require('jsonwebtoken'); +const SECRET = process.env.PUBLIC_EVENT_JWT_SECRET || 'default_public_event_secret'; + +exports.signPublicEventToken = (payload) => { + const token = jwt.sign(payload, SECRET, { expiresIn: '2h' }); + return token; +}; +exports.verifyPublicEventToken = (token) => { + const decoded = jwt.verify(token, SECRET); + return decoded; +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0da3eb8..320b668 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,16 +13,20 @@ "@fullcalendar/interaction": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15", "@fullcalendar/vue3": "^6.1.15", + "@primeuix/themes": "^1.0.3", + "@primevue/themes": "^4.3.3", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", "axios": "^1.8.2", "chart.js": "^4.4.8", "dayjs": "^1.11.10", "jwt-decode": "^4.0.0", + "lodash-es": "^4.17.21", "pinia": "^2.1.7", "primeflex": "^3.3.1", - "primeicons": "^6.0.1", - "primevue": "^3.49.1", + "primeicons": "^7.0.0", + "primelocale": "^2.1.2", + "primevue": "^4.3.3", "socket.io-client": "^4.8.1", "vee-validate": "^4.12.5", "vue": "^3.5.13", @@ -1045,6 +1049,87 @@ "dev": true, "license": "MIT" }, + "node_modules/@primeuix/styled": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.5.1.tgz", + "integrity": "sha512-5Ftw/KSauDPClQ8F2qCyCUF7cIUEY4yLNikf0rKV7Vsb8zGYNK0dahQe7CChaR6M2Kn+NA2DSBSk76ZXqj6Uog==", + "license": "MIT", + "dependencies": { + "@primeuix/utils": "^0.5.3" + }, + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/@primeuix/styles": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@primeuix/styles/-/styles-1.0.3.tgz", + "integrity": "sha512-yHj/Q+fosJ1736Ty5lRbpqhKa9piou+xZPPppNHUDshq0+XhrFwDGggvPGmDAJyUIM+ChM/Nj8lPY/AwTNXAkg==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.5.1" + } + }, + "node_modules/@primeuix/themes": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@primeuix/themes/-/themes-1.0.3.tgz", + "integrity": "sha512-f/1qadrv5TFMHfvtVv4Y9zjrkeDP2BO/cuzbHBO9DYxKL6YBIPT9BjKec2K4Kg8PcfGm6CAvxAvICadJSWejRw==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.5.1" + } + }, + "node_modules/@primeuix/utils": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.5.3.tgz", + "integrity": "sha512-7SGh7734wcF1/uK6RzO6Z6CBjGQ97GDHfpyl2F1G/c7R0z9hkT/V72ypDo82AWcCS7Ta07oIjDpOCTkSVZuEGQ==", + "license": "MIT", + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/@primevue/core": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@primevue/core/-/core-4.3.3.tgz", + "integrity": "sha512-kSkN5oourG7eueoFPIqiNX3oDT/f0I5IRK3uOY/ytz+VzTZp5yuaCN0Nt42ZQpVXjDxMxDvUhIdaXVrjr58NhQ==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.5.0", + "@primeuix/utils": "^0.5.1" + }, + "engines": { + "node": ">=12.11.0" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@primevue/icons": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@primevue/icons/-/icons-4.3.3.tgz", + "integrity": "sha512-ouQaxHyeFB6MSfEGGbjaK5Qv9efS1xZGetZoU5jcPm090MSYLFtroP1CuK3lZZAQals06TZ6T6qcoNukSHpK5w==", + "license": "MIT", + "dependencies": { + "@primeuix/utils": "^0.5.1", + "@primevue/core": "4.3.3" + }, + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/@primevue/themes": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@primevue/themes/-/themes-4.3.3.tgz", + "integrity": "sha512-LiYlSXsHeA8DFm8+yGyiDFQc3SEQwHcESTN1/rV+rrZ+UPuPisHY9fNIGRFQKA5XUQPDTQDQjtwYGx25Jikwhg==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.5.0", + "@primeuix/themes": "^1.0.0" + }, + "engines": { + "node": ">=12.11.0" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", @@ -2561,6 +2646,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2844,18 +2935,35 @@ "license": "MIT" }, "node_modules/primeicons": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-6.0.1.tgz", - "integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", + "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==", "license": "MIT" }, - "node_modules/primevue": { - "version": "3.53.1", - "resolved": "https://registry.npmjs.org/primevue/-/primevue-3.53.1.tgz", - "integrity": "sha512-Bp4peZPdhfKYXwvtsOGGh5dfgmTelm+LZEZKGs/c5mOHhsUJ6xi3EcOZoQVI6oklS946ayMQvgD5L0S7itGO0g==", + "node_modules/primelocale": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/primelocale/-/primelocale-2.1.2.tgz", + "integrity": "sha512-sTDkqu/QBwpqzq86oZGXaU/QbPLKLZ3Qix2gbqHXr410B7tfQCx39HSWjM9Hsnpzqn9KVdXJzk1DH1yW1iNNPg==", "license": "MIT", - "peerDependencies": { - "vue": "^3.0.0" + "engines": { + "node": ">=18.0.0", + "npm": ">=8.6.0" + } + }, + "node_modules/primevue": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/primevue/-/primevue-4.3.3.tgz", + "integrity": "sha512-nooYVoEz5CdP3EhUkD6c3qTdRmpLHZh75fBynkUkl46K8y5rksHTjdSISiDijwTA5STQIOkyqLb+RM+HQ6nC1Q==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.5.0", + "@primeuix/styles": "^1.0.0", + "@primeuix/utils": "^0.5.1", + "@primevue/core": "4.3.3", + "@primevue/icons": "4.3.3" + }, + "engines": { + "node": ">=12.11.0" } }, "node_modules/property-expr": { diff --git a/frontend/package.json b/frontend/package.json index e1a9c91..1b17d80 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,16 +15,20 @@ "@fullcalendar/interaction": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15", "@fullcalendar/vue3": "^6.1.15", + "@primeuix/themes": "^1.0.3", + "@primevue/themes": "^4.3.3", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", "axios": "^1.8.2", "chart.js": "^4.4.8", "dayjs": "^1.11.10", "jwt-decode": "^4.0.0", + "lodash-es": "^4.17.21", "pinia": "^2.1.7", "primeflex": "^3.3.1", - "primeicons": "^6.0.1", - "primevue": "^3.49.1", + "primeicons": "^7.0.0", + "primelocale": "^2.1.2", + "primevue": "^4.3.3", "socket.io-client": "^4.8.1", "vee-validate": "^4.12.5", "vue": "^3.5.13", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ae5c245..73e57b9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,100 +1,103 @@ - + diff --git a/frontend/src/components/event/ScoreManagement.vue b/frontend/src/components/event/ScoreManagement.vue index 35a48d5..94af8af 100644 --- a/frontend/src/components/event/ScoreManagement.vue +++ b/frontend/src/components/event/ScoreManagement.vue @@ -72,6 +72,7 @@ :max="300" :placeholder="game.gameNumber + '게임 점수 입력(핸디캡 제외)'" @update:modelValue="() => onScoreChange(game)" + class="w-full" /> @@ -85,6 +86,7 @@ :max="300" placeholder="핸디캡 입력" @update:modelValue="() => onScoreChange(game)" + class="w-full" /> diff --git a/frontend/src/components/event/TeamGeneration.vue b/frontend/src/components/event/TeamGeneration.vue index 872ec4d..b0c494b 100644 --- a/frontend/src/components/event/TeamGeneration.vue +++ b/frontend/src/components/event/TeamGeneration.vue @@ -44,7 +44,7 @@ {{ mIdx + 1 }} {{ member.name }} ({{ getMemberAverage(member) }}) -