Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6daf417
#115 chore: 엔드포인트 추가
JiiminHa May 13, 2026
29319c2
#116 fix: Zod 스키마에 .passthrough() 적용하여 API 데이터 유실 방지
JiiminHa May 14, 2026
15988c4
#115 refactor: 강의 생성 시 미가입 학생 등록 실패 알림 로직 추가
JiiminHa May 14, 2026
7437d60
#115 refactor: 강의 수정 시 중복 등록 방지 및 실패 알림 로직 추가
JiiminHa May 14, 2026
5b6460a
#115 feat: 학생 개별 삭제 API 및 벌크 삭제 Mutation 추가
JiiminHa May 14, 2026
22d345a
#115 feat: 학생 테이블의 선택 상태를 상위로 리프팅하여 삭제 기능 기반 마련
JiiminHa May 14, 2026
8213c29
#115 refactor: 강의 생성/수정 뮤테이션 로직을 courseMutations.ts로 분리
JiiminHa May 14, 2026
51701f3
#115 refactor: useCreateCourse 훅이 중앙화된 뮤테이션을 사용하도록 수정
JiiminHa May 14, 2026
950ce82
#115 refactor: useEditCourse 훅이 중앙화된 뮤테이션을 사용하도록 수정
JiiminHa May 14, 2026
a0f6c9a
#115 chore: FileUpload 컴포넌트에 label 프로퍼티 추가
JiiminHa May 14, 2026
4821086
#115 feat: 과제 테스트케이스 일괄 업로드 API 및 Mutation 추가
JiiminHa May 14, 2026
f24dca2
#115 feat: TestcaseRow에 삭제 버튼 및 공개여부 로직 수정
JiiminHa May 14, 2026
985d0c3
#115 refactor: 과제 테스트케이스 관리 로직을 TestcaseField 컴포넌트와 useAssignmentForm…
JiiminHa May 14, 2026
825b69d
#115 refactor: 학생 관리 로직을 useStudentManagement 훅으로 분리 및 선택 삭제 기능 연동
JiiminHa May 14, 2026
d165968
#115 refactor: 강의 생성/수정 폼에서 학생 목록 입력 로직 제거
JiiminHa May 14, 2026
f36727f
#115 fix: courseMutations 및 useCreateCourse의 any 타입 명시화
JiiminHa May 14, 2026
39136f3
#115 chore: 코드리뷰 수정사항 반영
JiiminHa May 14, 2026
66f491c
Merge pull request #121 from 2025-snowCode/feat/115-file-upload
JiiminHa May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/entities/assignment/api/assignmentApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@ export const updateAssignment = async (
return parsed.response;
};

// 테스트케이스 JSON 파일 업로드 API
export const uploadTestcasesBulk = async (
assignmentId: number,
file: File
) => {
const formData = new FormData();
formData.append('file', file);

const response = await privateAxios.post(
ENDPOINTS.TESTCASES.BULK(assignmentId),
formData
);
const parsed = apiResponseSchema(z.unknown()).parse(response.data);
return parsed.response;
};

// 과제 삭제 API
export const deleteAssignment = async (assignmentId: number) => {
const response = await privateAxios.delete(
Expand Down
12 changes: 12 additions & 0 deletions src/entities/assignment/api/assignmentMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
updateAssignment,
deleteAssignment,
submitAssignment,
uploadTestcasesBulk,
} from '@/entities/assignment/api/assignmentApi';

interface UpdateAssignmentVariables {
Expand All @@ -28,6 +29,17 @@ export const assignmentMutations = {
updateAssignment(assignmentId, form),
},

uploadTestcasesBulk: {
mutationKey: ['uploadTestcasesBulk'],
mutationFn: ({
assignmentId,
file,
}: {
assignmentId: number;
file: File;
}) => uploadTestcasesBulk(assignmentId, file),
},

submitAssignment: {
mutationKey: ['submitAssignment'],
mutationFn: ({
Expand Down
32 changes: 17 additions & 15 deletions src/entities/assignment/model/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,23 @@ export const assignmentFormSchema = z.object({
),
});

export const assignmentDetailSchema = z.object({
id: z.number(),
title: z.string(),
score: z.number().optional(),
description: z.string(),
count: z.number(),
testcases: z.array(
z.object({
id: z.number(),
testcase: z.string(),
answer: z.string(),
isPublic: z.boolean(),
})
),
});
export const assignmentDetailSchema = z
.object({
id: z.number(),
title: z.string(),
score: z.number().optional(),
description: z.string(),
count: z.number(),
testcases: z.array(
z.object({
id: z.number(),
testcase: z.string(),
answer: z.string(),
isPublic: z.boolean(),
})
),
})
.passthrough();

export const assignmentSubmissionResultSchema = z.object({
codeId: z.number(),
Expand Down
105 changes: 103 additions & 2 deletions src/entities/course/api/courseMutations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,108 @@
import {deleteCourse} from '@/entities/course/api/courseApi';
import {
createCourse,
deleteCourse,
updateCourse,
} from '@/entities/course/api/courseApi';
import type {TCourseBase} from '@/entities/course/model/schemas';
import {
addEnrollment,
getEnrollments,
} from '@/entities/student/api/studentApi';

export const courseMutations = {
// 강의 삭제 뮤테이션 옵션
// 강의 개설 (학생 등록 확인 로직 포함)
createCourse: {
mutationKey: ['createCourse'],
mutationFn: async (
payload: Parameters<typeof createCourse>[0]
): Promise<{course: TCourseBase; failedIds: string[]}> => {
const course = await createCourse(payload);
const students = payload.students ?? [];

if (students.length === 0) return {course, failedIds: []};

try {
const enrollments = await getEnrollments(course.id, {
page: 0,
pageSize: 1000,
});
const enrolledStudentIds = new Set(
enrollments.response.students.map((s) => String(s.studentId))
);

const missingStudents = students.filter(
({studentId}) => !enrolledStudentIds.has(String(studentId))
);

const results = await Promise.allSettled(
missingStudents.map(({studentId}) =>
addEnrollment(course.id, String(studentId))
)
);

const failedIds = results
.map((result, index) =>
result.status === 'rejected'
? String(missingStudents[index].studentId)
: null
)
.filter((id): id is string => id !== null);

return {course, failedIds};
} catch {
// 등록 확인 실패 시에도 강의 생성 성공은 유지
return {course, failedIds: students.map(({studentId}) => String(studentId))};
}
},
},

// 강의 수정 (학생 등록 확인 로직 포함)
updateCourse: (courseId: number) => ({
mutationKey: ['updateCourse', courseId],
mutationFn: async (
data: Parameters<typeof updateCourse>[1]
): Promise<{course: TCourseBase; failedIds: string[]}> => {
const course = await updateCourse(courseId, data);
const students = data.students ?? [];

if (students.length === 0) return {course, failedIds: []};

try {
const enrollments = await getEnrollments(courseId, {
page: 0,
pageSize: 1000,
});
const enrolledStudentIds = new Set(
enrollments.response.students.map((student) => String(student.studentId))
);

const missingStudents = students.filter(
({studentId}) => !enrolledStudentIds.has(String(studentId))
);

const results = await Promise.allSettled(
missingStudents.map(({studentId}) =>
addEnrollment(courseId, String(studentId))
)
);

const failedIds = results
.map((result, index) =>
result.status === 'rejected'
? String(missingStudents[index].studentId)
: null
)
.filter((id): id is string => id !== null);

return {course, failedIds};
} catch {
// 등록 확인 실패 시에도 강의 수정 성공은 유지
return {course, failedIds: students.map(({studentId}) => String(studentId))};
}
},
}),

// 강의 삭제
deleteCourse: {
mutationKey: ['deleteCourse'],
mutationFn: (courseId: number) => deleteCourse(courseId),
Expand Down
7 changes: 6 additions & 1 deletion src/entities/course/model/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {unitSchema} from '@/entities/unit/model/schemas';
import {semesterCodeSchema} from '@/shared/model/schemas';
import {z} from 'zod';
import {studentSchema} from '@/entities/student/model/schemas';

/** 강의 공통 핵심 필드 */
export const courseCoreSchema = z.object({
Expand All @@ -20,7 +21,10 @@ export const courseBaseSchema = courseCoreSchema.extend({
description: z
.string()
.nullish()
.transform((v) => v ?? ''), // 응답에서는 기본값 처리
.transform((v) => v ?? ''),
studentCount: z.number().optional(),
unitCount: z.number().optional(),
students: z.array(studentSchema).optional(),
});

/** 강의 추가/수정 요청 스키마 */
Expand All @@ -36,6 +40,7 @@ export const courseOverviewSchema = courseBaseSchema.extend({
studentCount: z.number().optional(),
unitCount: z.number(),
units: z.array(unitSchema),
students: z.array(studentSchema).optional(),
chatRoomId: z.number().nullable().optional(),
});

Expand Down
21 changes: 20 additions & 1 deletion src/entities/student/api/studentApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,24 @@ export const getEnrollmentById = async (courseId: number, memberId: number) => {

export const addEnrollment = async (courseId: number, studentId: string) => {
const res = await privateAxios.post(ENDPOINTS.ENROLLMENTS.BY_COURSE(courseId), {studentId});
return apiResponseSchema(z.string()).parse(res.data);
return apiResponseSchema(z.unknown()).parse(res.data);
};

export const addEnrollmentsBulk = async (courseId: number, file: File) => {
const formData = new FormData();
formData.append('file', file);

const res = await privateAxios.post(
ENDPOINTS.ENROLLMENTS.BULK(courseId),
formData
);

return apiResponseSchema(z.unknown()).parse(res.data);
};

export const deleteEnrollment = async (courseId: number, memberId: number) => {
const res = await privateAxios.delete(
ENDPOINTS.ENROLLMENTS.DETAIL(courseId, memberId)
);
return apiResponseSchema(z.unknown()).parse(res.data);
};
25 changes: 24 additions & 1 deletion src/entities/student/api/studentMutations.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import {addEnrollment} from './studentApi';
import {
addEnrollment,
addEnrollmentsBulk,
deleteEnrollment,
} from './studentApi';

export const studentMutations = {
addEnrollment: (courseId: number) => ({
mutationKey: ['addEnrollment', courseId],
mutationFn: (studentId: string) => addEnrollment(courseId, studentId),
}),

addEnrollmentsBulk: (courseId: number) => ({
mutationKey: ['addEnrollmentsBulk', courseId],
mutationFn: (file: File) => addEnrollmentsBulk(courseId, file),
}),
deleteEnrollmentsBulk: (courseId: number) => ({
mutationKey: ['deleteEnrollmentsBulk', courseId],
mutationFn: async (memberIds: number[]) => {
const results = await Promise.allSettled(
memberIds.map((id) => deleteEnrollment(courseId, id))
);
const failed = results
.map((result, index) =>
result.status === 'rejected' ? memberIds[index] : null
)
.filter((id): id is number => id !== null);
return {total: memberIds.length, failed};
},
}),
};
65 changes: 37 additions & 28 deletions src/entities/student/model/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,38 +32,47 @@ export const studentUnitSchema = z.object({
});

// 목록 조회용 학생 스키마
export const studentSchema = z.object({
id: z.number(),
studentId: z.string(),
name: z.string(),
score: z.number(),
totalScore: z.number(),
progress: z.array(studentProgressSchema),
});
export const studentSchema = z
.object({
id: z.number(),
studentId: z.union([z.string(), z.number()]).transform((v) => String(v)),
name: z.string(),
score: z.number(),
totalScore: z.number(),
progress: z.array(studentProgressSchema),
})
.passthrough();

// 개별 학생 조회용 스키마
export const studentDetailSchema = z.object({
id: z.number(),
name: z.string(),
studentId: z.string(),
email: z.string(),
title: z.string(),
score: z.number(),
totalScore: z.number(),
unitCount: z.number(),
progress: z.array(studentProgressSchema),
units: z.array(studentUnitSchema),
});
export const studentDetailSchema = z
.object({
id: z.number(),
name: z.string(),
studentId: z.union([z.string(), z.number()]).transform((v) => String(v)),
email: z.string(),
title: z.string(),
score: z.number(),
totalScore: z.number(),
unitCount: z.number(),
progress: z.array(studentProgressSchema),
units: z.array(studentUnitSchema),
})
.passthrough();

// 목록 조회 response 전체 스키마
export const enrollmentListSchema = z.object({
id: z.number(),
title: z.string(),
section: z.string(),
unitCount: z.number(),
studentCount: z.number(),
students: z.array(studentSchema),
});
export const enrollmentListSchema = z
.object({
id: z.number(),
title: z.string(),
section: z.string(),
unitCount: z.number(),
studentCount: z.number(),
students: z
.array(studentSchema)
.nullish()
.transform((v) => v ?? []),
})
.passthrough();

export type TStudent = z.infer<typeof studentSchema>;
export type TEnrollmentList = z.infer<typeof enrollmentListSchema>;
Loading
Loading