Skip to content

Commit 9ba1155

Browse files
Merge pull request #5 from NIPA-AWS-Developer-2nd/feature/meeting-apis
[FEAT] 모임 리스트 조회
2 parents 46d4549 + f7df5e5 commit 9ba1155

22 files changed

Lines changed: 1346 additions & 16 deletions

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"reflect-metadata": "^0.2.2",
6262
"rxjs": "^7.8.1",
6363
"sharp": "^0.34.3",
64+
"starving-orange": "^1.1.5",
6465
"swagger-ui-express": "^5.0.1",
6566
"typeorm": "^0.3.25",
6667
"ulid": "^3.0.1",

public/swagger-custom.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
// dev-token API 호출인지 확인
1919
if (
2020
url &&
21-
url.includes('/api/auth/dev-token') &&
21+
url.includes('/auth/dev-token') &&
2222
options &&
2323
options.method === 'POST'
2424
) {

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { LevelModule } from './modules/level/level.module';
1414
import { UserInterestsModule } from './modules/user-interests/user-interests.module';
1515
import { UserHashtagsModule } from './modules/user-hashtags/user-hashtags.module';
1616
import { MissionModule } from './modules/mission/mission.module';
17+
import { MeetingModule } from './modules/meeting/meeting.module';
1718
import { HealthModule } from './health/health.module';
1819
import { AuthModule } from './auth/auth.module';
1920
import { AwsModule } from './aws/aws.module';
@@ -93,6 +94,7 @@ import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
9394
UserInterestsModule,
9495
UserHashtagsModule,
9596
MissionModule,
97+
MeetingModule,
9698
HealthModule,
9799
AuthModule,
98100
AwsModule,

src/database/seeds/seed-data.ts

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DataSource } from 'typeorm';
22
import * as winston from 'winston';
33
import { ulid } from 'ulid';
4+
import * as starvingOrange from 'starving-orange';
45
import {
56
District,
67
Category,
@@ -11,10 +12,14 @@ import {
1112
UserProfile,
1213
UserRewards,
1314
Mission,
15+
Meeting,
16+
MeetingParticipant,
1417
} from '../../entities';
1518
import { UserStatus } from '../../entities/user.entity';
1619
import { Gender } from '../../entities/user-profile.entity';
1720
import { MissionDifficulty } from '../../entities/mission.entity';
21+
import { MeetingStatus } from '../../entities/meeting.entity';
22+
import { ParticipantStatus } from '../../entities/meeting-participant.entity';
1823

1924
export const seedInitialData = async (dataSource: DataSource) => {
2025
const logger = winston.createLogger({
@@ -37,6 +42,8 @@ export const seedInitialData = async (dataSource: DataSource) => {
3742
const userProfileRepository = dataSource.getRepository(UserProfile);
3843
const userRewardsRepository = dataSource.getRepository(UserRewards);
3944
const missionRepository = dataSource.getRepository(Mission);
45+
const meetingRepository = dataSource.getRepository(Meeting);
46+
const participantRepository = dataSource.getRepository(MeetingParticipant);
4047

4148
// 서울 구 데이터 시딩
4249
const seoulDistricts = [
@@ -337,7 +344,8 @@ export const seedInitialData = async (dataSource: DataSource) => {
337344
{
338345
id: '01JG9H7E2FQMC8GN1VKXR6W3T9',
339346
title: '송파구 맛집 방문하기',
340-
description: '송파구 내 인기 맛집을 방문하고 인증 사진을 업로드하세요.',
347+
description:
348+
'송파구 내 인기 맛집을 방문하고 인증 사진을 업로드해 주세요.',
341349
basePoints: 500,
342350
estimatedDuration: 120,
343351
minParticipants: 4,
@@ -690,5 +698,237 @@ export const seedInitialData = async (dataSource: DataSource) => {
690698
}
691699
}
692700

701+
// 20명의 더미 사용자 생성
702+
logger.info('👥 20명의 더미 사용자 생성 시작...');
703+
704+
const activeSongpaDistrict = await districtRepository.findOne({
705+
where: { districtName: '송파구', isActive: true },
706+
});
707+
708+
const allInterests = await userInterestsRepository.find();
709+
const allHashtags = await userHashtagsRepository.find();
710+
711+
if (
712+
activeSongpaDistrict &&
713+
allInterests.length > 0 &&
714+
allHashtags.length > 0
715+
) {
716+
// 010######## 형식의 랜덤 번호 20개 생성
717+
const phoneNumbers: string[] = [];
718+
const phoneSet = new Set<string>();
719+
while (phoneNumbers.length < 20) {
720+
const randomNumber = Math.floor(Math.random() * 1_0000_0000)
721+
.toString()
722+
.padStart(8, '0');
723+
const phone = `010${randomNumber}`;
724+
if (!phoneSet.has(phone)) {
725+
phoneNumbers.push(phone);
726+
phoneSet.add(phone);
727+
}
728+
}
729+
730+
const genders = [Gender.MALE, Gender.FEMALE, Gender.OTHER, null];
731+
732+
// Type-safe random gender selection helper
733+
const getRandomGender = (): Gender | null => {
734+
const randomIndex = Math.floor(Math.random() * genders.length);
735+
return genders[randomIndex] ?? null;
736+
};
737+
738+
const mbtiTypes = [
739+
'INFP',
740+
'ENFP',
741+
'INFJ',
742+
'ENFJ',
743+
'INTJ',
744+
'ENTJ',
745+
'INTP',
746+
'ENTP',
747+
'ISFP',
748+
'ESFP',
749+
'ISTP',
750+
'ESTP',
751+
'ISFJ',
752+
'ESFJ',
753+
'ISTJ',
754+
'ESTJ',
755+
];
756+
757+
for (let i = 0; i < 20; i++) {
758+
const phoneNumber = phoneNumbers[i];
759+
760+
// 이미 존재하는 사용자인지 확인
761+
const existingUser = await userRepository.findOne({
762+
where: { phoneNumber },
763+
});
764+
765+
if (existingUser) {
766+
continue; // 이미 존재하면 건너뜀
767+
}
768+
769+
const userId = ulid();
770+
const nicknameResult = starvingOrange.generateNickname();
771+
const nickname = nicknameResult.nickname;
772+
773+
// 랜덤한 관심사 2-4개 선택
774+
const interestCount = Math.floor(Math.random() * 3) + 2;
775+
const randomInterests = allInterests
776+
.sort(() => 0.5 - Math.random())
777+
.slice(0, interestCount)
778+
.map((interest) => interest.id);
779+
780+
// 랜덤한 해시태그 1-3개 선택
781+
const hashtagCount = Math.floor(Math.random() * 3) + 1;
782+
const randomHashtags = allHashtags
783+
.sort(() => 0.5 - Math.random())
784+
.slice(0, hashtagCount)
785+
.map((hashtag) => hashtag.id);
786+
787+
// 사용자 생성
788+
const user = userRepository.create({
789+
id: userId,
790+
phoneNumber,
791+
phoneVerifiedAt: new Date(),
792+
onboardingCompletedAt: new Date(),
793+
lastLoginAt: new Date(
794+
Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000,
795+
), // 지난 30일 내 랜덤
796+
status: UserStatus.ACTIVE,
797+
});
798+
await userRepository.save(user);
799+
800+
// 사용자 프로필 생성
801+
const profile = userProfileRepository.create({
802+
userId,
803+
nickname,
804+
profileImageUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${nickname}`,
805+
bio: `안녕하세요! ${nickname}입니다. 새로운 사람들과 함께 재미있는 활동을 해보고 싶어요!`,
806+
birthYear: Math.floor(Math.random() * 25) + 1990, // 1990-2014
807+
gender: getRandomGender(),
808+
mbti: mbtiTypes[Math.floor(Math.random() * mbtiTypes.length)],
809+
interestIds: randomInterests,
810+
hashtagIds: randomHashtags,
811+
districtId: activeSongpaDistrict.id,
812+
points: Math.floor(Math.random() * 2000), // 0-1999 포인트
813+
});
814+
await userProfileRepository.save(profile);
815+
816+
// 사용자 보상 생성
817+
const rewards = userRewardsRepository.create({
818+
userId,
819+
aiMissionTickets: Math.floor(Math.random() * 5), // 0-4 티켓
820+
});
821+
await userRewardsRepository.save(rewards);
822+
}
823+
}
824+
825+
// 이번 주 모임 더미데이터 생성
826+
const allMissions = await missionRepository.find({
827+
where: { isActive: true },
828+
});
829+
const allUsers = await userRepository.find({
830+
relations: ['profile'],
831+
where: { status: UserStatus.ACTIVE },
832+
});
833+
834+
if (allMissions.length > 0 && allUsers.length > 0) {
835+
const now = new Date();
836+
const weekStart = new Date(now);
837+
weekStart.setDate(now.getDate() - now.getDay() + 1); // 이번 주 월요일
838+
weekStart.setHours(0, 0, 0, 0);
839+
840+
// 이번 주 각 날짜별로 1-3개의 모임 생성
841+
for (let day = 0; day < 7; day++) {
842+
const meetingDate = new Date(weekStart);
843+
meetingDate.setDate(weekStart.getDate() + day);
844+
845+
// 과거 날짜는 건너뛰기
846+
if (meetingDate < now) {
847+
continue;
848+
}
849+
850+
const meetingsToday = Math.floor(Math.random() * 3) + 1; // 1-3개
851+
852+
for (let i = 0; i < meetingsToday; i++) {
853+
const mission =
854+
allMissions[Math.floor(Math.random() * allMissions.length)];
855+
const host = allUsers[Math.floor(Math.random() * allUsers.length)];
856+
857+
// 모임 시작 시간 (10:00 ~ 20:00, 30분 단위)
858+
const startHour = Math.floor(Math.random() * 11) + 10; // 10-20시
859+
const startMinute = Math.random() < 0.5 ? 0 : 30; // 0분 또는 30분
860+
const scheduledAt = new Date(meetingDate);
861+
scheduledAt.setHours(startHour, startMinute, 0, 0);
862+
863+
// 모집 마감시간 (모임 시작 2-24시간 전)
864+
const recruitHoursBefore = Math.floor(Math.random() * 23) + 2; // 2-24시간 전
865+
const recruitUntil = new Date(scheduledAt);
866+
recruitUntil.setHours(scheduledAt.getHours() - recruitHoursBefore);
867+
868+
// 모임이 이미 모집 마감되었는지 확인
869+
const isRecruitmentClosed = recruitUntil < now;
870+
871+
const meetingId = ulid();
872+
873+
const meeting = meetingRepository.create({
874+
id: meetingId,
875+
missionId: mission.id,
876+
hostUserId: host.id,
877+
status: isRecruitmentClosed
878+
? MeetingStatus.ACTIVE
879+
: MeetingStatus.RECRUITING,
880+
recruitUntil,
881+
scheduledAt,
882+
qrCodeToken: isRecruitmentClosed
883+
? `qr_${ulid().substring(0, 10)}`
884+
: null,
885+
qrGeneratedAt: isRecruitmentClosed ? new Date() : null,
886+
});
887+
888+
await meetingRepository.save(meeting);
889+
890+
// 호스트를 참가자로 추가
891+
const hostParticipant = participantRepository.create({
892+
meetingId,
893+
userId: host.id,
894+
isHost: true,
895+
status: ParticipantStatus.JOINED,
896+
joinedAt: new Date(
897+
meeting.createdAt.getTime() + Math.random() * 60000,
898+
), // 생성 후 1분 이내
899+
});
900+
await participantRepository.save(hostParticipant);
901+
902+
// 추가 참가자들 (1-3명)
903+
const additionalParticipants = Math.floor(Math.random() * 3) + 1;
904+
const usedUsers = new Set([host.id]);
905+
906+
for (
907+
let j = 0;
908+
j < additionalParticipants && usedUsers.size < allUsers.length;
909+
j++
910+
) {
911+
let randomUser: (typeof allUsers)[0];
912+
do {
913+
randomUser = allUsers[Math.floor(Math.random() * allUsers.length)];
914+
} while (usedUsers.has(randomUser.id));
915+
916+
usedUsers.add(randomUser.id);
917+
918+
const participant = participantRepository.create({
919+
meetingId,
920+
userId: randomUser.id,
921+
isHost: false,
922+
status: ParticipantStatus.JOINED,
923+
joinedAt: new Date(
924+
meeting.createdAt.getTime() + Math.random() * 24 * 60 * 60 * 1000,
925+
), // 24시간 이내
926+
});
927+
await participantRepository.save(participant);
928+
}
929+
}
930+
}
931+
}
932+
693933
logger.info('🌱 Initial data seeding has been completed.');
694934
};

src/entities/auth-token.entity.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
import { User } from './user.entity';
1111

1212
@Entity('auth_tokens')
13+
@Index('IDX_auth_tokens_user_id_active', ['userId'], {
14+
where: 'is_revoked = false',
15+
})
1316
export class AuthToken {
1417
@PrimaryGeneratedColumn()
1518
id: number;
@@ -21,8 +24,7 @@ export class AuthToken {
2124
@Index()
2225
accessToken: string;
2326

24-
@Column({ type: 'varchar', length: 500, unique: true })
25-
@Index()
27+
@Column({ type: 'varchar', length: 500 })
2628
refreshToken: string;
2729

2830
@Column({ type: 'timestamptz' })
@@ -34,7 +36,7 @@ export class AuthToken {
3436
@Column({ type: 'varchar', length: 255, nullable: true })
3537
deviceInfo: string;
3638

37-
@Column({ type: 'boolean', default: false })
39+
@Column({ type: 'boolean', default: false, name: 'is_revoked' })
3840
isRevoked: boolean;
3941

4042
@CreateDateColumn()

src/entities/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,15 @@ export { Level } from './level.entity';
99
export { UserInterests } from './user-interests.entity';
1010
export { UserHashtags } from './user-hashtags.entity';
1111
export { Mission, MissionDifficulty } from './mission.entity';
12+
export { Meeting, MeetingStatus } from './meeting.entity';
13+
export { MeetingProfile } from './meeting-profile.entity';
14+
export {
15+
MeetingProfileTrait,
16+
TraitPreference,
17+
} from './meeting-profile-trait.entity';
18+
export {
19+
MeetingParticipant,
20+
ParticipantStatus,
21+
} from './meeting-participant.entity';
22+
export { UserMission } from './user-mission.entity';
23+
export { MissionReview, VerificationStatus } from './mission-review.entity';

0 commit comments

Comments
 (0)