diff --git a/.gitignore b/.gitignore index 7bcce54..4891b17 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ dist # misc .vscode .cursor +.omx .DS_Store .env.local .env.development.local diff --git a/src/config/container.config.ts b/src/config/container.config.ts index 43cba05..6d68d0c 100644 --- a/src/config/container.config.ts +++ b/src/config/container.config.ts @@ -204,6 +204,8 @@ const lecturesService = new LecturesService( enrollmentsRepo, lectureEnrollmentsRepo, instructorRepo, + studentRepo, + parentChildLinkRepo, permissionService, prisma, ); diff --git a/src/repos/enrollments.repo.ts b/src/repos/enrollments.repo.ts index 6dd000e..4de1989 100644 --- a/src/repos/enrollments.repo.ts +++ b/src/repos/enrollments.repo.ts @@ -374,16 +374,20 @@ export class EnrollmentsRepository { }); } - /** 학생 전화번호 기준 AppParentLinkId 업데이트 (자녀 등록 시 연동) */ + /** 학생/학부모 프로필 기준 AppParentLinkId 업데이트 (자녀 등록 시 연동) */ async updateAppParentLinkIdByStudentPhone( studentPhone: string, + studentName: string, + parentPhoneNumber: string, appParentLinkId: string, tx?: Prisma.TransactionClient, ) { const client = tx ?? this.prisma; return await client.enrollment.updateMany({ where: { - studentPhone: studentPhone, + studentPhone, + studentName, + parentPhone: parentPhoneNumber, appParentLinkId: null, // 아직 연동되지 않은 건들이만 }, data: { diff --git a/src/services/enrollments.service.test.ts b/src/services/enrollments.service.test.ts index c246e85..28777db 100644 --- a/src/services/enrollments.service.test.ts +++ b/src/services/enrollments.service.test.ts @@ -306,6 +306,161 @@ describe('EnrollmentsService - @unit #critical', () => { ); }); + it('기존 Enrollment를 재사용할 때 appStudentId가 비어 있으면 자동 연결을 보정한다', async () => { + const existingEnrollment = { + ...mockEnrollments.active, + appStudentId: null, + studentPhone: createEnrollmentRequests.basic.studentPhone, + studentName: createEnrollmentRequests.basic.studentName, + parentPhone: createEnrollmentRequests.basic.parentPhone, + }; + + mockLecturesRepo.findById.mockResolvedValue(mockLectures.basic); + mockPermissionService.validateInstructorAccess.mockResolvedValue(); + mockEnrollmentsRepo.findManyByInstructorAndPhones.mockResolvedValue([ + existingEnrollment, + ]); + mockStudentRepo.findByPhoneNumberAndProfile.mockResolvedValue( + mockStudents.basic, + ); + mockEnrollmentsRepo.update.mockResolvedValue({ + ...existingEnrollment, + appStudentId: mockStudents.basic.id, + }); + mockLectureEnrollmentsRepo.findByLectureIdAndEnrollmentId.mockResolvedValueOnce( + null, + ); + mockLectureEnrollmentsRepo.create.mockResolvedValue({ + id: 'le-1', + memo: null, + lectureId, + enrollmentId: existingEnrollment.id, + registeredAt: new Date(), + }); + mockLectureEnrollmentsRepo.findByLectureIdAndEnrollmentId.mockResolvedValueOnce( + { + id: 'le-1', + memo: null, + lectureId, + enrollmentId: existingEnrollment.id, + registeredAt: new Date(), + enrollment: { + ...existingEnrollment, + appStudentId: mockStudents.basic.id, + }, + }, + ); + + await enrollmentsService.createEnrollment( + lectureId, + { + ...createEnrollmentRequests.basic, + instructorId, + status: EnrollmentStatus.ACTIVE, + }, + UserType.INSTRUCTOR, + instructorId, + ); + + expect( + mockStudentRepo.findByPhoneNumberAndProfile, + ).toHaveBeenCalledWith( + createEnrollmentRequests.basic.studentPhone, + createEnrollmentRequests.basic.studentName, + createEnrollmentRequests.basic.parentPhone, + mockPrisma, + ); + expect(mockEnrollmentsRepo.update).toHaveBeenCalledWith( + existingEnrollment.id, + { + appStudent: { + connect: { + id: mockStudents.basic.id, + }, + }, + }, + mockPrisma, + ); + expect(mockEnrollmentsRepo.create).not.toHaveBeenCalled(); + }); + + it('기존 Enrollment를 재사용할 때 appParentLinkId가 비어 있으면 자동 연결을 보정한다', async () => { + const existingEnrollment = { + ...mockEnrollments.active, + appParentLinkId: null, + studentPhone: createEnrollmentRequests.basic.studentPhone, + studentName: createEnrollmentRequests.basic.studentName, + parentPhone: createEnrollmentRequests.basic.parentPhone, + }; + + mockLecturesRepo.findById.mockResolvedValue(mockLectures.basic); + mockPermissionService.validateInstructorAccess.mockResolvedValue(); + mockEnrollmentsRepo.findManyByInstructorAndPhones.mockResolvedValue([ + existingEnrollment, + ]); + mockParentsService.findLinkByPhoneNumberAndProfile.mockResolvedValue( + mockParentLinks.active, + ); + mockEnrollmentsRepo.update.mockResolvedValue({ + ...existingEnrollment, + appParentLinkId: mockParentLinks.active.id, + }); + mockLectureEnrollmentsRepo.findByLectureIdAndEnrollmentId.mockResolvedValueOnce( + null, + ); + mockLectureEnrollmentsRepo.create.mockResolvedValue({ + id: 'le-1', + memo: null, + lectureId, + enrollmentId: existingEnrollment.id, + registeredAt: new Date(), + }); + mockLectureEnrollmentsRepo.findByLectureIdAndEnrollmentId.mockResolvedValueOnce( + { + id: 'le-1', + memo: null, + lectureId, + enrollmentId: existingEnrollment.id, + registeredAt: new Date(), + enrollment: { + ...existingEnrollment, + appParentLinkId: mockParentLinks.active.id, + }, + }, + ); + + await enrollmentsService.createEnrollment( + lectureId, + { + ...createEnrollmentRequests.basic, + instructorId, + status: EnrollmentStatus.ACTIVE, + }, + UserType.INSTRUCTOR, + instructorId, + ); + + expect( + mockParentsService.findLinkByPhoneNumberAndProfile, + ).toHaveBeenCalledWith( + createEnrollmentRequests.basic.studentPhone, + createEnrollmentRequests.basic.studentName, + createEnrollmentRequests.basic.parentPhone, + ); + expect(mockEnrollmentsRepo.update).toHaveBeenCalledWith( + existingEnrollment.id, + { + appParentLink: { + connect: { + id: mockParentLinks.active.id, + }, + }, + }, + mockPrisma, + ); + expect(mockEnrollmentsRepo.create).not.toHaveBeenCalled(); + }); + it('수강생 등록 시 학생 전화번호가 학부모-자녀 링크와 일치할 때, ParentLink가 자동으로 연결된다', async () => { mockLecturesRepo.findById.mockResolvedValue(mockLectures.basic); mockPermissionService.validateInstructorAccess.mockResolvedValue(); diff --git a/src/services/enrollments.service.ts b/src/services/enrollments.service.ts index 67c5edb..cfb9925 100644 --- a/src/services/enrollments.service.ts +++ b/src/services/enrollments.service.ts @@ -84,29 +84,38 @@ export class EnrollmentsService { ); return await this.prisma.$transaction(async (tx) => { - // 3. 기존 Enrollment 확인 (같은 강사, 같은 학생 번호) - let enrollmentId: string | null = null; + const resolveStudentId = async () => { + let studentId = data.appStudentId; - if (data.studentPhone) { - const existingEnrollments = - await this.enrollmentsRepository.findManyByInstructorAndPhones( - lecture.instructorId, - [data.studentPhone], - tx, - ); + if (!studentId && data.studentPhone) { + const studentPhone = data.studentPhone as string; + const studentName = data.studentName as string | undefined; + const parentPhone = data.parentPhone as string | undefined; - if (existingEnrollments.length > 0) { - enrollmentId = existingEnrollments[0].id; + if (studentName && parentPhone) { + const student = + await this.studentRepository.findByPhoneNumberAndProfile( + studentPhone, + studentName, + parentPhone, + tx, + ); + if (student) { + studentId = student.id; + } + } } - } - // 4. 없으면 새로 생성 - if (!enrollmentId) { + return studentId; + }; + + const resolveParentLinkId = async () => { let parentLinkId = data.appParentLinkId; + if (!parentLinkId && data.studentPhone) { const studentPhone = data.studentPhone as string; - const studentName = data.studentName as string | undefined; - const parentPhone = data.parentPhone as string | undefined; + const studentName = data.studentName as string; + const parentPhone = data.parentPhone as string; if (studentName && parentPhone) { const link = @@ -121,25 +130,55 @@ export class EnrollmentsService { } } - let studentId = data.appStudentId; - if (!studentId && data.studentPhone) { - const studentPhone = data.studentPhone as string; - const studentName = data.studentName as string | undefined; - const parentPhone = data.parentPhone as string | undefined; + return parentLinkId; + }; - if (studentName && parentPhone) { - const student = - await this.studentRepository.findByPhoneNumberAndProfile( - studentPhone, - studentName, - parentPhone, - tx, - ); - if (student) { - studentId = student.id; + // 3. 기존 Enrollment 확인 (같은 강사, 같은 학생 번호) + let enrollmentId: string | null = null; + + if (data.studentPhone) { + const existingEnrollments = + await this.enrollmentsRepository.findManyByInstructorAndPhones( + lecture.instructorId, + [data.studentPhone], + tx, + ); + + if (existingEnrollments.length > 0) { + const existingEnrollment = existingEnrollments[0]; + enrollmentId = existingEnrollment.id; + const connectionData: Prisma.EnrollmentUpdateInput = {}; + + if (!existingEnrollment.appStudentId) { + const studentId = await resolveStudentId(); + if (studentId) { + connectionData.appStudent = { connect: { id: studentId } }; } } + + if (!existingEnrollment.appParentLinkId) { + const parentLinkId = await resolveParentLinkId(); + if (parentLinkId) { + connectionData.appParentLink = { + connect: { id: parentLinkId }, + }; + } + } + + if (Object.keys(connectionData).length > 0) { + await this.enrollmentsRepository.update( + existingEnrollment.id, + connectionData, + tx, + ); + } } + } + + // 4. 없으면 새로 생성 + if (!enrollmentId) { + const parentLinkId = await resolveParentLinkId(); + const studentId = await resolveStudentId(); const newEnrollment = await this.enrollmentsRepository.create( { diff --git a/src/services/lectures.service.test.ts b/src/services/lectures.service.test.ts index 71a2195..49d9f6b 100644 --- a/src/services/lectures.service.test.ts +++ b/src/services/lectures.service.test.ts @@ -7,6 +7,8 @@ import { createMockLecturesRepository, createMockEnrollmentsRepository, createMockInstructorRepository, + createMockStudentRepository, + createMockParentChildLinkRepository, createMockPermissionService, createMockPrisma, createMockLectureEnrollmentsRepository, @@ -18,6 +20,8 @@ import { createLectureRequests, updateLectureRequests, mockEnrollments, + mockStudents, + mockParentLinks, } from '../test/fixtures/index.js'; import { mockUsers } from '../test/fixtures/user.fixture.js'; @@ -32,6 +36,10 @@ describe('LecturesService - @unit #critical', () => { let mockLecturesRepo: ReturnType; let mockEnrollmentsRepo: ReturnType; let mockInstructorRepo: ReturnType; + let mockStudentRepo: ReturnType; + let mockParentChildLinkRepo: ReturnType< + typeof createMockParentChildLinkRepository + >; let mockLectureEnrollmentsRepo: ReturnType< typeof createMockLectureEnrollmentsRepository >; @@ -49,6 +57,8 @@ describe('LecturesService - @unit #critical', () => { mockLecturesRepo = createMockLecturesRepository(); mockEnrollmentsRepo = createMockEnrollmentsRepository(); mockInstructorRepo = createMockInstructorRepository(); + mockStudentRepo = createMockStudentRepository(); + mockParentChildLinkRepo = createMockParentChildLinkRepository(); mockLectureEnrollmentsRepo = createMockLectureEnrollmentsRepository(); mockPermissionService = createMockPermissionService(); mockPrisma = createMockPrisma() as unknown as PrismaClient; @@ -59,6 +69,8 @@ describe('LecturesService - @unit #critical', () => { mockEnrollmentsRepo, mockLectureEnrollmentsRepo, mockInstructorRepo, + mockStudentRepo, + mockParentChildLinkRepo, mockPermissionService, mockPrisma, ); @@ -156,6 +168,128 @@ describe('LecturesService - @unit #critical', () => { expect.anything(), ); }); + + it('강의 생성 시 새 Enrollment에 앱 학생과 학부모 링크를 자동 연결한다', async () => { + const enrollmentRequest = + createLectureRequests.withEnrollments.enrollments![0]; + + mockInstructorRepo.findById.mockResolvedValue(mockInstructor); + mockLecturesRepo.create.mockResolvedValue({ + ...mockLectures.withEnrollments, + lectureTimes: [], + }); + mockEnrollmentsRepo.findManyByInstructorAndPhones.mockResolvedValue([]); + mockStudentRepo.findByPhoneNumberAndProfile.mockResolvedValue( + mockStudents.basic, + ); + mockParentChildLinkRepo.findByPhoneNumberAndProfile.mockResolvedValue( + mockParentLinks.active, + ); + mockEnrollmentsRepo.createMany.mockResolvedValue([ + mockEnrollments.active, + ]); + mockLectureEnrollmentsRepo.createMany.mockResolvedValue([]); + + (mockPrisma.$transaction as jest.Mock).mockImplementation( + async (fn) => await fn(mockPrisma), + ); + + await lecturesService.createLecture(mockInstructor.id, { + ...createLectureRequests.withEnrollments, + enrollments: [enrollmentRequest], + startAt: new Date(createLectureRequests.withEnrollments.startAt), + endAt: new Date(createLectureRequests.withEnrollments.endAt), + }); + + expect( + mockStudentRepo.findByPhoneNumberAndProfile, + ).toHaveBeenCalledWith( + enrollmentRequest.studentPhone, + enrollmentRequest.studentName, + enrollmentRequest.parentPhone, + mockPrisma, + ); + expect( + mockParentChildLinkRepo.findByPhoneNumberAndProfile, + ).toHaveBeenCalledWith( + enrollmentRequest.studentPhone, + enrollmentRequest.studentName, + enrollmentRequest.parentPhone, + mockPrisma, + ); + expect(mockEnrollmentsRepo.createMany).toHaveBeenCalledWith( + [ + expect.objectContaining({ + appStudentId: mockStudents.basic.id, + appParentLinkId: mockParentLinks.active.id, + }), + ], + mockPrisma, + ); + }); + + it('강의 생성 시 기존 Enrollment를 재사용할 때 비어 있는 앱 연결을 보정한다', async () => { + const enrollmentRequest = + createLectureRequests.withEnrollments.enrollments![0]; + const existingEnrollment = { + ...mockEnrollments.active, + studentPhone: enrollmentRequest.studentPhone, + studentName: enrollmentRequest.studentName, + parentPhone: enrollmentRequest.parentPhone, + appStudentId: null, + appParentLinkId: null, + }; + + mockInstructorRepo.findById.mockResolvedValue(mockInstructor); + mockLecturesRepo.create.mockResolvedValue({ + ...mockLectures.withEnrollments, + lectureTimes: [], + }); + mockEnrollmentsRepo.findManyByInstructorAndPhones.mockResolvedValue([ + existingEnrollment, + ]); + mockStudentRepo.findByPhoneNumberAndProfile.mockResolvedValue( + mockStudents.basic, + ); + mockParentChildLinkRepo.findByPhoneNumberAndProfile.mockResolvedValue( + mockParentLinks.active, + ); + mockEnrollmentsRepo.update.mockResolvedValue({ + ...existingEnrollment, + appStudentId: mockStudents.basic.id, + appParentLinkId: mockParentLinks.active.id, + }); + mockLectureEnrollmentsRepo.createMany.mockResolvedValue([]); + + (mockPrisma.$transaction as jest.Mock).mockImplementation( + async (fn) => await fn(mockPrisma), + ); + + await lecturesService.createLecture(mockInstructor.id, { + ...createLectureRequests.withEnrollments, + enrollments: [enrollmentRequest], + startAt: new Date(createLectureRequests.withEnrollments.startAt), + endAt: new Date(createLectureRequests.withEnrollments.endAt), + }); + + expect(mockEnrollmentsRepo.update).toHaveBeenCalledWith( + existingEnrollment.id, + { + appStudent: { + connect: { + id: mockStudents.basic.id, + }, + }, + appParentLink: { + connect: { + id: mockParentLinks.active.id, + }, + }, + }, + mockPrisma, + ); + expect(mockEnrollmentsRepo.createMany).not.toHaveBeenCalled(); + }); }); describe('LECTURE-02: 강의 생성 실패', () => { diff --git a/src/services/lectures.service.ts b/src/services/lectures.service.ts index 92f642e..feb9070 100644 --- a/src/services/lectures.service.ts +++ b/src/services/lectures.service.ts @@ -8,6 +8,8 @@ import { import { EnrollmentsRepository } from '../repos/enrollments.repo.js'; import { LectureEnrollmentsRepository } from '../repos/lecture-enrollments.repo.js'; import { InstructorRepository } from '../repos/instructor.repo.js'; +import { StudentRepository } from '../repos/student.repo.js'; +import { ParentChildLinkRepository } from '../repos/parent-child-link.repo.js'; import { PermissionService } from './permission.service.js'; import { UserType } from '../constants/auth.constant.js'; import { @@ -26,6 +28,10 @@ export type LectureWithEnrollments = Lecture & { lectureEnrollments?: LectureEnrollment[]; }; +type LectureEnrollmentRequest = NonNullable< + CreateLectureDto['enrollments'] +>[number]; + export type GetLecturesResponse = { lectures: { id: string; @@ -93,6 +99,8 @@ export class LecturesService { private readonly enrollmentsRepository: EnrollmentsRepository, private readonly lectureEnrollmentsRepository: LectureEnrollmentsRepository, private readonly instructorRepository: InstructorRepository, + private readonly studentRepository: StudentRepository, + private readonly parentChildLinkRepository: ParentChildLinkRepository, private readonly permissionService: PermissionService, private readonly prisma: PrismaClient, ) {} @@ -116,6 +124,32 @@ export class LecturesService { // 2. 수강생 처리 (있는 경우) let lectureEnrollments: LectureEnrollment[] = []; if (data.enrollments && data.enrollments.length > 0) { + const resolveStudentId = async ( + enrollmentReq: LectureEnrollmentRequest, + ) => { + const student = + await this.studentRepository.findByPhoneNumberAndProfile( + enrollmentReq.studentPhone, + enrollmentReq.studentName, + enrollmentReq.parentPhone, + tx, + ); + return student?.id; + }; + + const resolveParentLinkId = async ( + enrollmentReq: LectureEnrollmentRequest, + ) => { + const link = + await this.parentChildLinkRepository.findByPhoneNumberAndProfile( + enrollmentReq.studentPhone, + enrollmentReq.studentName, + enrollmentReq.parentPhone, + tx, + ); + return link?.id; + }; + // 2-1. 요청된 학생들의 전화번호 목록 추출 const studentPhones = data.enrollments.map((e) => e.studentPhone); @@ -138,11 +172,42 @@ export class LecturesService { for (const enrollmentReq of data.enrollments) { const existing = existingPhoneMap.get(enrollmentReq.studentPhone); if (existing) { + const connectionData: Prisma.EnrollmentUpdateInput = {}; + + if (!existing.appStudentId) { + const studentId = await resolveStudentId(enrollmentReq); + if (studentId) { + connectionData.appStudent = { connect: { id: studentId } }; + } + } + + if (!existing.appParentLinkId) { + const parentLinkId = await resolveParentLinkId(enrollmentReq); + if (parentLinkId) { + connectionData.appParentLink = { + connect: { id: parentLinkId }, + }; + } + } + + if (Object.keys(connectionData).length > 0) { + await this.enrollmentsRepository.update( + existing.id, + connectionData, + tx, + ); + } + // 이미 존재하는 주소록(Enrollment)이면 ID 사용 // 정보 업데이트가 필요한 경우 여기서 할 수도 있으나, // 현재 요구사항은 "기존 주소록에 있으면 그걸 쓴다"임. finalEnrollmentIds.push(existing.id); } else { + const [studentId, parentLinkId] = await Promise.all([ + resolveStudentId(enrollmentReq), + resolveParentLinkId(enrollmentReq), + ]); + // 없으면 새로 생성할 목록에 추가 newEnrollmentsData.push({ instructorId, @@ -151,6 +216,8 @@ export class LecturesService { school: enrollmentReq.school, schoolYear: enrollmentReq.schoolYear, parentPhone: enrollmentReq.parentPhone, + appStudentId: studentId, + appParentLinkId: parentLinkId, status: EnrollmentStatus.ACTIVE, }); } diff --git a/src/services/parents.service.test.ts b/src/services/parents.service.test.ts index aff6372..ba3155d 100644 --- a/src/services/parents.service.test.ts +++ b/src/services/parents.service.test.ts @@ -75,6 +75,7 @@ describe('ParentsService - @unit #critical', () => { mockParentChildLinkRepo.findByParentIdAndPhoneNumber.mockResolvedValue( null, ); + mockParentRepo.findById.mockResolvedValue(mockParents.basic); mockParentChildLinkRepo.create.mockResolvedValue( mockParentLinks.active, ); @@ -112,6 +113,8 @@ describe('ParentsService - @unit #critical', () => { mockEnrollmentsRepo.updateAppParentLinkIdByStudentPhone, ).toHaveBeenCalledWith( childData.phoneNumber, + childData.name, + mockParents.basic.phoneNumber, mockParentLinks.active.id, expect.anything(), ); @@ -121,6 +124,7 @@ describe('ParentsService - @unit #critical', () => { mockParentChildLinkRepo.findByParentIdAndPhoneNumber.mockResolvedValue( null, ); + mockParentRepo.findById.mockResolvedValue(mockParents.basic); mockParentChildLinkRepo.create.mockResolvedValue( mockParentLinks.active, ); diff --git a/src/services/parents.service.ts b/src/services/parents.service.ts index 51783c0..76fab3a 100644 --- a/src/services/parents.service.ts +++ b/src/services/parents.service.ts @@ -46,6 +46,11 @@ export class ParentsService { } return await this.prisma.$transaction(async (tx) => { + const parent = await this.parentRepository.findById(profileId, tx); + if (!parent) { + throw new NotFoundException('학부모 정보를 찾을 수 없습니다.'); + } + // 2. ParentChildLink 생성 const newLink = await this.parentChildLinkRepository.create( { @@ -59,6 +64,8 @@ export class ParentsService { // 3. 기존 Enrollment 중 해당 자녀 번호로 된 것들을 찾아 자동 연결 (Backfill) await this.enrollmentsRepository.updateAppParentLinkIdByStudentPhone( data.phoneNumber, + data.name, + parent.phoneNumber, newLink.id, tx, );