From 283c8a7f4ab88bb6b025641b1939926e25a7a0f3 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Sat, 26 Apr 2025 08:04:04 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=BD=94=EB=A9=98?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=85=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/models/Club.js | 31 +++++++++----- backend/models/ClubFeature.js | 22 ++++++---- backend/models/ClubSubscription.js | 22 ++++++---- backend/models/Event.js | 49 +++++++++++++++-------- backend/models/EventParticipant.js | 19 ++++++--- backend/models/EventScore.js | 23 +++++++---- backend/models/EventTeam.js | 6 +++ backend/models/EventTeamMember.js | 10 +++-- backend/models/Feature.js | 25 ++++++++---- backend/models/Member.js | 40 ++++++++++++------ backend/models/Menu.js | 39 +++++++++++------- backend/models/Notification.js | 31 +++++++++----- backend/models/Participant.js | 31 +++++++++----- backend/models/SubscriptionPlan.js | 19 ++++++--- backend/models/SubscriptionPlanFeature.js | 13 ++++-- backend/models/User.js | 31 +++++++++----- backend/models/activityLog.js | 16 +++++--- backend/seeders/20250424-init-menus.js | 6 +-- 18 files changed, 293 insertions(+), 140 deletions(-) diff --git a/backend/models/Club.js b/backend/models/Club.js index e8b9ee1..b35bf9a 100644 --- a/backend/models/Club.js +++ b/backend/models/Club.js @@ -5,15 +5,18 @@ const Club = sequelize.define('Club', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '클럽 고유 식별자' }, name: { type: DataTypes.STRING, - allowNull: false + allowNull: false, + comment: '클럽명' }, description: { type: DataTypes.TEXT, - allowNull: true + allowNull: true, + comment: '클럽 설명' }, ownerId: { type: DataTypes.INTEGER.UNSIGNED, @@ -21,36 +24,44 @@ const Club = sequelize.define('Club', { references: { model: 'Users', key: 'id' - } + }, + comment: '클럽장(소유자) 유저 ID' }, location: { type: DataTypes.STRING, - allowNull: true + allowNull: true, + comment: '주 활동 지역' }, contact: { type: DataTypes.STRING, - allowNull: true + allowNull: true, + comment: '연락처' }, status: { type: DataTypes.ENUM('active', 'inactive', 'suspended'), allowNull: false, - defaultValue: 'active' + defaultValue: 'active', + comment: '클럽 상태' }, memberCount: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '회원 수' }, createdAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '생성일' }, updatedAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '수정일' } }, { tableName: 'Clubs', + comment: '볼링 클럽 정보를 저장하는 테이블', timestamps: true }); diff --git a/backend/models/ClubFeature.js b/backend/models/ClubFeature.js index c7f6205..ea4632f 100644 --- a/backend/models/ClubFeature.js +++ b/backend/models/ClubFeature.js @@ -5,7 +5,8 @@ const ClubFeature = sequelize.define('ClubFeature', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '클럽-기능 연결 고유 식별자' }, clubId: { type: DataTypes.INTEGER.UNSIGNED, @@ -13,7 +14,8 @@ const ClubFeature = sequelize.define('ClubFeature', { references: { model: 'Clubs', key: 'id' - } + }, + comment: '클럽 ID' }, featureId: { type: DataTypes.INTEGER.UNSIGNED, @@ -21,27 +23,33 @@ const ClubFeature = sequelize.define('ClubFeature', { references: { model: 'Features', key: 'id' - } + }, + comment: '기능 ID' }, enabled: { type: DataTypes.BOOLEAN, allowNull: false, - defaultValue: true + defaultValue: true, + comment: '기능 활성화 여부' }, expiryDate: { type: DataTypes.DATE, - allowNull: true + allowNull: true, + comment: '기능 만료일' }, createdAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '생성일' }, updatedAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '수정일' } }, { tableName: 'ClubFeatures', + comment: '클럽별 활성화된 기능 정보를 저장하는 테이블', timestamps: true }); diff --git a/backend/models/ClubSubscription.js b/backend/models/ClubSubscription.js index fa831d5..cc4dc96 100644 --- a/backend/models/ClubSubscription.js +++ b/backend/models/ClubSubscription.js @@ -5,7 +5,8 @@ const ClubSubscription = sequelize.define('ClubSubscription', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '클럽-구독 연결 고유 식별자' }, clubId: { type: DataTypes.INTEGER.UNSIGNED, @@ -13,7 +14,8 @@ const ClubSubscription = sequelize.define('ClubSubscription', { references: { model: 'Clubs', key: 'id' - } + }, + comment: '클럽 ID' }, planId: { type: DataTypes.INTEGER.UNSIGNED, @@ -21,28 +23,34 @@ const ClubSubscription = sequelize.define('ClubSubscription', { references: { model: 'SubscriptionPlans', key: 'id' - } + }, + comment: '구독 플랜 ID' }, status: { type: DataTypes.ENUM('active', 'expired', 'suspended'), defaultValue: 'active', - allowNull: false + allowNull: false, + comment: '구독 상태' }, startDate: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '구독 시작일' }, endDate: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '구독 종료일' }, price: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '구독 가격' } }, { tableName: 'ClubSubscriptions', + comment: '클럽별 구독 정보(플랜 등)를 저장하는 테이블', timestamps: true }); diff --git a/backend/models/Event.js b/backend/models/Event.js index 3b807d8..3d9f79e 100644 --- a/backend/models/Event.js +++ b/backend/models/Event.js @@ -5,7 +5,8 @@ const Event = sequelize.define('Event', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '이벤트 고유 식별자' }, clubId: { type: DataTypes.INTEGER.UNSIGNED, @@ -13,72 +14,88 @@ const Event = sequelize.define('Event', { references: { model: 'Clubs', key: 'id' - } + }, + comment: '소속 클럽 ID' }, title: { type: DataTypes.STRING, - allowNull: false + allowNull: false, + comment: '이벤트 제목' }, description: { type: DataTypes.TEXT, - allowNull: true + allowNull: true, + comment: '이벤트 설명' }, eventType: { type: DataTypes.ENUM('정기전', '번개', '연습', '교류전', '이벤트', '기타'), allowNull: false, - defaultValue: '정기전' + defaultValue: '정기전', + comment: '이벤트 유형' }, startDate: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '시작일' }, endDate: { type: DataTypes.DATE, - allowNull: true + allowNull: true, + comment: '종료일' }, location: { type: DataTypes.STRING, - allowNull: true + allowNull: true, + comment: '장소' }, gameCount: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 3 + defaultValue: 3, + comment: '게임 수' }, participantFee: { type: DataTypes.DECIMAL(10, 2), allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '참가비' }, maxParticipants: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '최대 참가 인원' }, registrationDeadline: { type: DataTypes.DATE, - allowNull: true + allowNull: true, + comment: '참가 신청 마감일' }, participantCount: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '참가자 수' }, status: { type: DataTypes.ENUM('준비', '활성', '완료', '취소', '삭제'), allowNull: false, - defaultValue: '활성' + defaultValue: '활성', + comment: '이벤트 상태' }, createdAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '생성일' }, updatedAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '수정일' } }, { tableName: 'Events', + comment: '클럽 내 이벤트(대회, 모임 등) 정보를 저장하는 테이블', timestamps: true }); diff --git a/backend/models/EventParticipant.js b/backend/models/EventParticipant.js index c9fbd2a..5748461 100644 --- a/backend/models/EventParticipant.js +++ b/backend/models/EventParticipant.js @@ -5,7 +5,8 @@ const EventParticipant = sequelize.define('EventParticipant', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '이벤트 참가자 고유 식별자' }, eventId: { type: DataTypes.INTEGER.UNSIGNED, @@ -13,7 +14,8 @@ const EventParticipant = sequelize.define('EventParticipant', { references: { model: 'Events', key: 'id' - } + }, + comment: '이벤트 ID' }, memberId: { type: DataTypes.INTEGER.UNSIGNED, @@ -21,7 +23,8 @@ const EventParticipant = sequelize.define('EventParticipant', { references: { model: 'Members', key: 'id' - } + }, + comment: '회원 ID' }, teamId: { type: DataTypes.INTEGER.UNSIGNED, @@ -29,20 +32,24 @@ const EventParticipant = sequelize.define('EventParticipant', { references: { model: 'EventTeams', key: 'id' - } + }, + comment: '이벤트 팀 ID' }, status: { type: DataTypes.ENUM('참가예정', '참가확정', '취소'), allowNull: false, - defaultValue: '참가예정' + defaultValue: '참가예정', + comment: '참가 상태' }, paymentStatus: { type: DataTypes.ENUM('미납', '납부완료'), allowNull: false, - defaultValue: '미납' + defaultValue: '미납', + comment: '참가비 납부 상태' } }, { tableName: 'EventParticipants', + comment: '이벤트별 참가자 정보를 저장하는 테이블', timestamps: true }); diff --git a/backend/models/EventScore.js b/backend/models/EventScore.js index ed9488f..a71ffd3 100644 --- a/backend/models/EventScore.js +++ b/backend/models/EventScore.js @@ -5,7 +5,8 @@ const EventScore = sequelize.define('EventScore', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '점수 고유 식별자' }, eventId: { type: DataTypes.INTEGER.UNSIGNED, @@ -13,7 +14,8 @@ const EventScore = sequelize.define('EventScore', { references: { model: 'Events', key: 'id' - } + }, + comment: '이벤트 ID' }, participantId: { type: DataTypes.INTEGER.UNSIGNED, @@ -21,31 +23,38 @@ const EventScore = sequelize.define('EventScore', { references: { model: 'EventParticipants', key: 'id' - } + }, + comment: '이벤트 참가자 ID' }, teamNumber: { type: DataTypes.INTEGER.UNSIGNED, allowNull: true, + comment: '팀 번호' }, gameNumber: { type: DataTypes.INTEGER.UNSIGNED, - allowNull: false + allowNull: false, + comment: '게임 번호' }, score: { type: DataTypes.INTEGER, - allowNull: false + allowNull: false, + comment: '점수' }, handicap: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '핸디캡' }, totalScore: { type: DataTypes.INTEGER, - allowNull: false + allowNull: false, + comment: '총점' } }, { tableName: 'EventScores', + comment: '이벤트별 참가자 점수 정보를 저장하는 테이블', timestamps: true }); diff --git a/backend/models/EventTeam.js b/backend/models/EventTeam.js index 85e4b0f..691138e 100644 --- a/backend/models/EventTeam.js +++ b/backend/models/EventTeam.js @@ -6,25 +6,31 @@ const EventTeam = sequelize.define('EventTeam', { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, autoIncrement: true, + comment: '이벤트 팀 고유 식별자', }, eventId: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, + comment: '이벤트 ID', }, teamNumber: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, + comment: '팀 번호', }, name: { type: DataTypes.STRING, allowNull: true, + comment: '팀명', }, handicap: { type: DataTypes.INTEGER, allowNull: true, + comment: '팀 핸디캡', }, }, { tableName: 'EventTeams', + comment: '이벤트별 팀 정보를 저장하는 테이블', timestamps: true, }); diff --git a/backend/models/EventTeamMember.js b/backend/models/EventTeamMember.js index b16fded..50ae716 100644 --- a/backend/models/EventTeamMember.js +++ b/backend/models/EventTeamMember.js @@ -5,20 +5,24 @@ const EventTeamMember = sequelize.define('EventTeamMember', { eventTeamId: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, - primaryKey: true + primaryKey: true, + comment: '이벤트 팀 ID' }, eventParticipantId: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, - primaryKey: true + primaryKey: true, + comment: '이벤트 참가자 ID' }, order: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, - defaultValue: 1 + defaultValue: 1, + comment: '팀 내 순번' } }, { tableName: 'EventTeamMembers', + comment: '이벤트 팀별 멤버 정보를 저장하는 테이블', timestamps: false }); diff --git a/backend/models/Feature.js b/backend/models/Feature.js index c636d76..ac2a30f 100644 --- a/backend/models/Feature.js +++ b/backend/models/Feature.js @@ -5,41 +5,50 @@ const Feature = sequelize.define('Feature', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '기능 고유 식별자' }, code: { type: DataTypes.STRING, allowNull: false, - unique: true + unique: true, + comment: '기능 코드' }, name: { type: DataTypes.STRING, - allowNull: false + allowNull: false, + comment: '기능명' }, description: { type: DataTypes.TEXT, - allowNull: true + allowNull: true, + comment: '기능 설명' }, price: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '기능 가격' }, status: { type: DataTypes.ENUM('active', 'inactive', 'suspended'), allowNull: false, - defaultValue: 'active' + defaultValue: 'active', + comment: '기능 상태' }, createdAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '생성일' }, updatedAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '수정일' } }, { tableName: 'Features', + comment: '클럽에서 제공하는 기능 정보를 저장하는 테이블', timestamps: true }); diff --git a/backend/models/Member.js b/backend/models/Member.js index a637a9d..5314604 100644 --- a/backend/models/Member.js +++ b/backend/models/Member.js @@ -7,7 +7,8 @@ const Member = sequelize.define('Member', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '회원 고유 식별자' }, clubId: { type: DataTypes.INTEGER.UNSIGNED, @@ -15,7 +16,8 @@ const Member = sequelize.define('Member', { references: { model: 'Clubs', key: 'id' - } + }, + comment: '소속 클럽 ID' }, userId: { type: DataTypes.INTEGER.UNSIGNED, @@ -23,56 +25,68 @@ const Member = sequelize.define('Member', { references: { model: 'Users', key: 'id' - } + }, + comment: '유저 ID' }, name: { type: DataTypes.STRING, - allowNull: false + allowNull: false, + comment: '회원 이름' }, gender: { type: DataTypes.ENUM('남성', '여성'), - allowNull: false + allowNull: false, + comment: '성별' }, memberType: { type: DataTypes.ENUM('정회원', '준회원', '운영진', '모임장'), allowNull: false, - defaultValue: '준회원' + defaultValue: '준회원', + comment: '회원 유형' }, handicap: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '핸디캡' }, average: { type: DataTypes.FLOAT, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '평균 점수' }, games: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '게임 수' }, phone: { type: DataTypes.STRING, - allowNull: true + allowNull: true, + comment: '전화번호' }, email: { type: DataTypes.STRING, - allowNull: true + allowNull: true, + comment: '이메일' }, joinDate: { type: DataTypes.DATE, allowNull: false, - defaultValue: DataTypes.NOW + defaultValue: DataTypes.NOW, + comment: '가입일' }, status: { type: DataTypes.ENUM('active', 'inactive', 'suspended', 'deleted'), allowNull: false, - defaultValue: 'active' + defaultValue: 'active', + comment: '회원 상태' } }, { tableName: 'Members', + comment: '클럽 회원 정보를 저장하는 테이블', timestamps: true }); Member.associate = (models) => { diff --git a/backend/models/Menu.js b/backend/models/Menu.js index 8b0716a..800204a 100644 --- a/backend/models/Menu.js +++ b/backend/models/Menu.js @@ -5,7 +5,8 @@ const Menu = sequelize.define('Menu', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '메뉴 고유 식별자' }, parentId: { type: DataTypes.INTEGER.UNSIGNED, @@ -13,19 +14,23 @@ const Menu = sequelize.define('Menu', { references: { model: 'Menus', // 실제 테이블명 key: 'id' - } + }, + comment: '상위 메뉴 ID' }, title: { type: DataTypes.STRING, - allowNull: false + allowNull: false, + comment: '메뉴명' }, icon: { type: DataTypes.STRING, - allowNull: true + allowNull: true, + comment: '아이콘' }, to: { type: DataTypes.STRING, - allowNull: false + allowNull: false, + comment: '라우팅 경로' }, featureCode: { type: DataTypes.STRING, @@ -33,37 +38,41 @@ const Menu = sequelize.define('Menu', { references: { model: 'Features', key: 'code' - } - }, - requiredSubscriptionTier: { - type: DataTypes.ENUM('basic', 'standard', 'premium'), - allowNull: true + }, + comment: '연결된 기능 코드' }, + role: { type: DataTypes.STRING, allowNull: false, - defaultValue: 'user' + defaultValue: 'user', + comment: '권한 역할' }, order: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '정렬 순서' }, visible: { type: DataTypes.BOOLEAN, allowNull: false, - defaultValue: true + defaultValue: true, + comment: '표시 여부' }, createdAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '생성일' }, updatedAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '수정일' } }, { tableName: 'Menus', + comment: '서비스 내 메뉴 구조 및 정보를 저장하는 테이블', timestamps: true }); diff --git a/backend/models/Notification.js b/backend/models/Notification.js index 97bc168..115deb2 100644 --- a/backend/models/Notification.js +++ b/backend/models/Notification.js @@ -5,7 +5,8 @@ const Notification = sequelize.define('Notification', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '알림 고유 식별자' }, userId: { type: DataTypes.INTEGER.UNSIGNED, @@ -13,46 +14,56 @@ const Notification = sequelize.define('Notification', { references: { model: 'Users', key: 'id' - } + }, + comment: '유저 ID' }, title: { type: DataTypes.STRING, - allowNull: false + allowNull: false, + comment: '알림 제목' }, message: { type: DataTypes.TEXT, - allowNull: false + allowNull: false, + comment: '알림 내용' }, icon: { type: DataTypes.STRING, - defaultValue: 'pi pi-info-circle' + defaultValue: 'pi pi-info-circle', + comment: '아이콘' }, type: { type: DataTypes.ENUM('info', 'success', 'warning', 'error'), - defaultValue: 'info' + defaultValue: 'info', + comment: '알림 타입' }, read: { type: DataTypes.BOOLEAN, allowNull: false, - defaultValue: false + defaultValue: false, + comment: '읽음 여부' }, link: { type: DataTypes.STRING, allowNull: true, - defaultValue: null + defaultValue: null, + comment: '관련 링크' }, createdAt: { type: DataTypes.DATE, allowNull: false, - defaultValue: DataTypes.NOW + defaultValue: DataTypes.NOW, + comment: '생성일' }, updatedAt: { type: DataTypes.DATE, allowNull: false, - defaultValue: DataTypes.NOW + defaultValue: DataTypes.NOW, + comment: '수정일' } }, { timestamps: true, + comment: '유저 알림 정보를 저장하는 테이블', tableName: 'Notifications' }); diff --git a/backend/models/Participant.js b/backend/models/Participant.js index 6863cac..14ac24a 100644 --- a/backend/models/Participant.js +++ b/backend/models/Participant.js @@ -5,7 +5,8 @@ const Participant = sequelize.define('Participant', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '미팅 참가자 고유 식별자' }, meetingId: { type: DataTypes.INTEGER.UNSIGNED, @@ -13,7 +14,8 @@ const Participant = sequelize.define('Participant', { references: { model: 'Meetings', key: 'id' - } + }, + comment: '미팅 ID' }, memberId: { type: DataTypes.INTEGER.UNSIGNED, @@ -21,42 +23,51 @@ const Participant = sequelize.define('Participant', { references: { model: 'Members', key: 'id' - } + }, + comment: '회원 ID' }, teamNumber: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, - defaultValue: 1 + defaultValue: 1, + comment: '팀 번호' }, status: { type: DataTypes.ENUM('참가예정', '참가완료', '취소'), allowNull: false, - defaultValue: '참가예정' + defaultValue: '참가예정', + comment: '참가 상태' }, attendance: { type: DataTypes.BOOLEAN, - defaultValue: false + defaultValue: false, + comment: '출석 여부' }, paymentStatus: { type: DataTypes.ENUM('미납', '납부완료'), allowNull: false, - defaultValue: '미납' + defaultValue: '미납', + comment: '참가비 납부 상태' }, paymentAmount: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '납부 금액' }, createdAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '생성일' }, updatedAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '수정일' } }, { tableName: 'EventParticipants', + comment: '미팅 참가자 정보를 저장하는 테이블', timestamps: true }); diff --git a/backend/models/SubscriptionPlan.js b/backend/models/SubscriptionPlan.js index 30093ea..998a5e9 100644 --- a/backend/models/SubscriptionPlan.js +++ b/backend/models/SubscriptionPlan.js @@ -5,31 +5,38 @@ const SubscriptionPlan = sequelize.define('SubscriptionPlan', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '구독 플랜 고유 식별자' }, name: { type: DataTypes.STRING, - allowNull: false + allowNull: false, + comment: '플랜명' }, description: { - type: DataTypes.TEXT + type: DataTypes.TEXT, + comment: '플랜 설명' }, maxMembers: { type: DataTypes.INTEGER, - allowNull: false + allowNull: false, + comment: '최대 허용 회원 수' }, price: { type: DataTypes.INTEGER, allowNull: false, - defaultValue: 0 + defaultValue: 0, + comment: '플랜 가격' }, status: { type: DataTypes.ENUM('active', 'inactive'), defaultValue: 'active', - allowNull: false + allowNull: false, + comment: '플랜 상태' } }, { tableName: 'SubscriptionPlan', + comment: '구독 플랜 정보를 저장하는 테이블', timestamps: true }); diff --git a/backend/models/SubscriptionPlanFeature.js b/backend/models/SubscriptionPlanFeature.js index c9f055a..8e68b1b 100644 --- a/backend/models/SubscriptionPlanFeature.js +++ b/backend/models/SubscriptionPlanFeature.js @@ -5,7 +5,8 @@ const SubscriptionPlanFeature = sequelize.define('SubscriptionPlanFeature', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '구독-기능 연결 고유 식별자' }, planId: { type: DataTypes.INTEGER.UNSIGNED, @@ -13,7 +14,8 @@ const SubscriptionPlanFeature = sequelize.define('SubscriptionPlanFeature', { references: { model: 'SubscriptionPlans', key: 'id' - } + }, + comment: '구독 플랜 ID' }, featureId: { type: DataTypes.INTEGER.UNSIGNED, @@ -21,15 +23,18 @@ const SubscriptionPlanFeature = sequelize.define('SubscriptionPlanFeature', { references: { model: 'Features', key: 'id' - } + }, + comment: '기능 ID' }, status: { type: DataTypes.ENUM('active', 'inactive'), defaultValue: 'active', - allowNull: false + allowNull: false, + comment: '상태' } }, { tableName: 'SubscriptionPlanFeatures', + comment: '구독 플랜별 제공 기능 정보를 저장하는 테이블', timestamps: true }); diff --git a/backend/models/User.js b/backend/models/User.js index be40e7c..e4f04fb 100644 --- a/backend/models/User.js +++ b/backend/models/User.js @@ -6,20 +6,24 @@ const User = sequelize.define('User', { id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '유저 고유 식별자' }, username: { type: DataTypes.STRING, allowNull: false, - unique: true + unique: true, + comment: '로그인 계정명' }, password: { type: DataTypes.STRING, - allowNull: false + allowNull: false, + comment: '비밀번호(해시)' }, name: { type: DataTypes.STRING, - allowNull: false + allowNull: false, + comment: '이름' }, email: { type: DataTypes.STRING, @@ -27,32 +31,39 @@ const User = sequelize.define('User', { unique: true, validate: { isEmail: true - } + }, + comment: '이메일' }, role: { type: DataTypes.ENUM('admin', 'clubadmin', 'user'), allowNull: false, - defaultValue: 'user' + defaultValue: 'user', + comment: '권한 역할' }, status: { type: DataTypes.ENUM('active', 'inactive', 'suspended'), allowNull: false, - defaultValue: 'active' + defaultValue: 'active', + comment: '계정 상태' }, lastLoginAt: { type: DataTypes.DATE, - allowNull: true + allowNull: true, + comment: '마지막 로그인 일시' }, createdAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '생성일' }, updatedAt: { type: DataTypes.DATE, - allowNull: false + allowNull: false, + comment: '수정일' } }, { tableName: 'Users', + comment: '서비스 회원(유저) 정보를 저장하는 테이블', timestamps: true, hooks: { beforeCreate: async (user) => { diff --git a/backend/models/activityLog.js b/backend/models/activityLog.js index f1c485b..7d3c14e 100644 --- a/backend/models/activityLog.js +++ b/backend/models/activityLog.js @@ -7,7 +7,8 @@ ActivityLog.init({ id: { type: DataTypes.INTEGER.UNSIGNED, primaryKey: true, - autoIncrement: true + autoIncrement: true, + comment: '활동 로그 고유 식별자' }, userId: { type: DataTypes.INTEGER.UNSIGNED, @@ -15,22 +16,27 @@ ActivityLog.init({ references: { model: 'Users', key: 'id' - } + }, + comment: '유저 ID' }, type: { type: DataTypes.STRING(50), - allowNull: false + allowNull: false, + comment: '로그 유형' }, description: { type: DataTypes.TEXT, - allowNull: true + allowNull: true, + comment: '상세 설명' }, createdAt: { type: DataTypes.DATE, - defaultValue: DataTypes.NOW + defaultValue: DataTypes.NOW, + comment: '생성일' } }, { sequelize, + comment: '유저 활동 로그를 저장하는 테이블', modelName: 'ActivityLog', tableName: 'ActivityLogs', timestamps: true, diff --git a/backend/seeders/20250424-init-menus.js b/backend/seeders/20250424-init-menus.js index 422b1af..80ed2a2 100644 --- a/backend/seeders/20250424-init-menus.js +++ b/backend/seeders/20250424-init-menus.js @@ -56,7 +56,7 @@ module.exports = { visible: true, parentId: clubMenuId, featureCode: 'event_management', - requiredSubscriptionTier: 'standard', + createdAt: new Date(), updatedAt: new Date() }, @@ -69,7 +69,7 @@ module.exports = { visible: true, parentId: clubMenuId, featureCode: 'event_calendar', - requiredSubscriptionTier: 'standard', + createdAt: new Date(), updatedAt: new Date() }, @@ -82,7 +82,7 @@ module.exports = { visible: true, parentId: clubMenuId, featureCode: 'statistics', - requiredSubscriptionTier: 'premium', + createdAt: new Date(), updatedAt: new Date() } -- 2.52.0 From 1684fbf5d16aa8a74c89664abd6dd3ed794d23d3 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Wed, 7 May 2025 21:02:50 +0900 Subject: [PATCH 2/4] =?UTF-8?q?public=20=EA=B8=B0=EB=8A=A5=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app.js | 2 + backend/config/database.js | 2 +- backend/controllers/eventController.js | 271 +++++++- backend/controllers/publicController.js | 304 +++++++++ backend/middleware/publicAuth.js | 13 + backend/models/Event.js | 10 + backend/models/EventParticipant.js | 5 + backend/models/Member.js | 2 +- backend/models/index.js | 4 +- backend/routes/eventRoutes.js | 5 + backend/routes/publicEventRoutes.js | 15 + backend/utils/publicJwt.js | 12 + frontend/package-lock.json | 130 +++- frontend/package.json | 8 +- frontend/src/App.vue | 201 +++--- frontend/src/assets/base.css | 3 +- .../components/event/EventDetailsDialog.vue | 28 +- frontend/src/components/event/EventDialog.vue | 125 ++-- .../src/components/event/ScoreManagement.vue | 2 + .../src/components/event/TeamGeneration.vue | 2 +- frontend/src/layouts/AppPublicLayout.vue | 23 + frontend/src/main.js | 35 +- frontend/src/router/index.js | 8 + frontend/src/services/PublicEventService.js | 84 +++ frontend/src/views/club/EventManagement.vue | 60 +- frontend/src/views/event/EventPublicPage.css | 504 +++++++++++++++ frontend/src/views/event/EventPublicPage.vue | 601 ++++++++++++++++++ frontend/src/views/event/JoinDialog.vue | 257 ++++++++ 28 files changed, 2522 insertions(+), 194 deletions(-) create mode 100644 backend/controllers/publicController.js create mode 100644 backend/middleware/publicAuth.js create mode 100644 backend/routes/publicEventRoutes.js create mode 100644 backend/utils/publicJwt.js create mode 100644 frontend/src/layouts/AppPublicLayout.vue create mode 100644 frontend/src/services/PublicEventService.js create mode 100644 frontend/src/views/event/EventPublicPage.css create mode 100644 frontend/src/views/event/EventPublicPage.vue create mode 100644 frontend/src/views/event/JoinDialog.vue 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) }}) -