Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ dependencies {
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'

// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.example.Piroin.project.domain.curriculum.dto.CurriculumReqDTO;
import com.example.Piroin.project.domain.curriculum.dto.CurriculumResDTO;
import com.example.Piroin.project.domain.curriculum.service.CurriculumService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
Expand All @@ -26,14 +27,14 @@ public ResponseEntity<List<CurriculumResDTO.CreateDayRes>> getAllDays() {

@PostMapping
public ResponseEntity<CurriculumResDTO.CreateDayRes> createDay(
@RequestBody CurriculumReqDTO.CreateDayReq req) {
@RequestBody @Valid CurriculumReqDTO.CreateDayReq req) {
return ResponseEntity.status(HttpStatus.CREATED).body(curriculumService.createDay(req));
}

@PatchMapping("/{sessionDate}")
public ResponseEntity<CurriculumResDTO.CreateDayRes> updateDay(
@PathVariable LocalDate sessionDate,
@RequestBody CurriculumReqDTO.UpdateDayReq req) {
@RequestBody @Valid CurriculumReqDTO.UpdateDayReq req) {
return ResponseEntity.ok(curriculumService.updateDay(sessionDate, req));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.example.Piroin.project.domain.curriculum.enums.SessionDayPart;
import com.example.Piroin.project.domain.curriculum.enums.SessionStatus;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.NoArgsConstructor;

Expand All @@ -13,18 +16,32 @@ public class CurriculumReqDTO {
@Getter
@NoArgsConstructor
public static class CreateDayReq {
@NotNull(message = "기수를 입력해주세요.")
private Integer generation;

@NotNull(message = "주차를 입력해주세요.")
private Long week;

@NotNull(message = "세션 날짜를 입력해주세요.")
private LocalDate sessionDate;

@NotEmpty(message = "세션 목록을 입력해주세요.")
@Valid
private List<SessionReq> sessions;
}

@Getter
@NoArgsConstructor
public static class SessionReq {
@NotNull(message = "세션 시간대를 입력해주세요.")
private SessionDayPart dayPart;

@NotNull(message = "세션 제목을 입력해주세요.")
private String title;

@NotNull(message = "발표자를 입력해주세요.")
private String hostName;

private String sessionMaterialUrl;
private String sessionMaterialName;
private String recordingUrl;
Expand All @@ -37,16 +54,25 @@ public static class SessionReq {
@Getter
@NoArgsConstructor
public static class UpdateDayReq {
@NotNull(message = "기수를 입력해주세요.")
private Integer generation;

@NotNull(message = "주차를 입력해주세요.")
private Long week;

private LocalDate newSessionDate;

@NotEmpty(message = "세션 목록을 입력해주세요.")
@Valid
private List<UpdateSessionItemReq> sessions;
}

@Getter
@NoArgsConstructor
public static class UpdateSessionItemReq {
@NotNull(message = "세션 시간대를 입력해주세요.")
private SessionDayPart dayPart;

private SessionStatus status;
private String title;
private String hostName;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ public record UnderstandingCheckResponse(
Integer understoodCount,
// 오른쪽 X 뱃지 숫자
Integer notUnderstoodCount,
// 현재 로그인 유저가 누른 선택지. 누르지 않았거나 취소한 상태면 null
UnderstandResChoice selectedChoice,
LocalDateTime createdAt
) {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class Question {
@JoinColumn(name = "user_id", nullable = false)
private User user;

@Column(nullable = false, columnDefinition = "TEXT")
@Column(columnDefinition = "TEXT")
private String content;

@Column(name = "image_url", columnDefinition = "TEXT")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public class QuestionComment {
@JoinColumn(name = "parent_comment_id")
private QuestionComment parentComment;

@Column(nullable = false, columnDefinition = "TEXT")
@Column(columnDefinition = "TEXT")
private String content;

@Column(name = "image_url", columnDefinition = "TEXT")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public QuestionResDTO.QuestionRoomResponse getQuestionRoom(Long sessionId, int u
User loginUser = findLoginUser(userId);
return new QuestionResDTO.QuestionRoomResponse(
toSessionResponse(session),
getUnderstandingSlice(session, understandingIndex),
getUnderstandingSlice(session, understandingIndex, loginUser),
getQuestionGroups(session, loginUser)
);
}
Expand Down Expand Up @@ -136,6 +136,9 @@ public QuestionResDTO.CommentCreateRes createComment(
// 1. 대댓글 여부 확인: parentCommentId가 있으면 부모 댓글 조회
QuestionComment parentComment = resolveParentComment(request.getParentCommentId(), question);

// builder 전에 검증 추가
validateCommentContent(request.getContent(), request.getImageUrl());

// 2. 댓글 엔티티 생성 및 저장
LocalDateTime now = LocalDateTime.now();
QuestionComment comment = QuestionComment.builder()
Expand Down Expand Up @@ -248,6 +251,9 @@ public QuestionResDTO.CreateRes createQuestion(Long sessionId, QuestionReqDTO.Cr
User loginUser = findLoginUser(userId);
StudySession session = findSession(sessionId);

// builder 전에 검증 추가
validateQuestionContent(request.getContent(), request.getImageUrl());

Question question = Question.builder()
.session(session)
.user(loginUser)
Expand Down Expand Up @@ -543,7 +549,11 @@ private QuestionResDTO.SessionResponse toSessionResponse(StudySession session) {
);
}

private QuestionResDTO.UnderstandingSliceResponse getUnderstandingSlice(StudySession session, int understandingIndex) {
private QuestionResDTO.UnderstandingSliceResponse getUnderstandingSlice(
StudySession session,
int understandingIndex,
User loginUser
) {
Page<UnderstandingCheck> understandingPage = understandingCheckRepository
.findBySessionOrderByCreatedAtDesc(session, PageRequest.of(understandingIndex, UNDERSTANDING_PAGE_SIZE));

Expand All @@ -559,17 +569,17 @@ private QuestionResDTO.UnderstandingSliceResponse getUnderstandingSlice(StudySes
// attendanceCount는 프론트 화면의 "13/29" 중 29에 해당한다.
int attendanceCount = attendanceService.countAttendedBySession(session);
return new QuestionResDTO.UnderstandingSliceResponse(
toUnderstandingCheckResponse(current, attendanceCount), understandingIndex, totalCount,
toUnderstandingCheckResponse(current, attendanceCount, loginUser), understandingIndex, totalCount,
understandingIndex < totalCount - 1, understandingIndex > 0
);
}

private QuestionResDTO.UnderstandingCheckResponse toUnderstandingCheckResponse(UnderstandingCheck check) {
return toUnderstandingCheckResponse(check, null);
return toUnderstandingCheckResponse(check, null, null);
}

private QuestionResDTO.UnderstandingCheckResponse toUnderstandingCheckResponse(
UnderstandingCheck check, Integer attendanceCount
UnderstandingCheck check, Integer attendanceCount, User loginUser
) {
// understoodCount/notUnderstoodCount는 오른쪽 O/X 뱃지 숫자로 그대로 사용한다.
int understoodCount = understandingResponseRepository.countByCheckAndChoice(
Expand All @@ -585,10 +595,20 @@ private QuestionResDTO.UnderstandingCheckResponse toUnderstandingCheckResponse(
attendanceCount,
understoodCount,
notUnderstoodCount,
getSelectedChoice(check, loginUser),
check.getCreatedAt()
);
}

private UnderstandResChoice getSelectedChoice(UnderstandingCheck check, User loginUser) {
if (loginUser == null) {
return null;
}
return understandingResponseRepository.findByCheckAndUser(check, loginUser)
.map(UnderstandingResponse::getChoice)
.orElse(null);
}

private QuestionResDTO.QuestionGroupsResponse getQuestionGroups(StudySession session, User loginUser) {
List<Question> questions = questionRepository.findBySessionAndDeletedAtIsNull(session);
QuestionSummaryContext summaryContext = getQuestionSummaryContext(questions, loginUser);
Expand Down Expand Up @@ -793,4 +813,22 @@ private record QuestionSummaryContext(
Set<Long> likedQuestionIds
) {
}

// 질문은 내용 또는 이미지 중 하나는 반드시 있어야 함
private void validateQuestionContent(String content, String imageUrl) {
boolean hasContent = content != null && !content.isBlank();
boolean hasImage = imageUrl != null && !imageUrl.isBlank();
if (!hasContent && !hasImage) {
throw new QuestionException(HttpStatus.BAD_REQUEST, "질문 내용 또는 이미지 중 하나는 필수입니다.");
}
}

// 댓글은 내용 또는 이미지 중 하나는 반드시 있어야 함
private void validateCommentContent(String content, String imageUrl) {
boolean hasContent = content != null && !content.isBlank();
boolean hasImage = imageUrl != null && !imageUrl.isBlank();
if (!hasContent && !hasImage) {
throw new QuestionException(HttpStatus.BAD_REQUEST, "댓글 내용 또는 이미지 중 하나는 필수입니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 이미지만 단독 업로드가 가능하도록 content NOT NULL 제약 해제
ALTER TABLE question ALTER COLUMN content DROP NOT NULL;
ALTER TABLE question_comment ALTER COLUMN content DROP NOT NULL;
4 changes: 2 additions & 2 deletions frontend/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
Expand All @@ -27,7 +27,7 @@
<title>PIROIN</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<noscript>피로그래밍 운영을 위한 통합 플랫폼</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
Expand Down
8 changes: 4 additions & 4 deletions frontend/public/manifest.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "PIROIN",
"name": "PIROIN",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"src": "favicon.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"src": "favicon.png",
"type": "image/png",
"sizes": "512x512"
}
Expand Down
36 changes: 21 additions & 15 deletions frontend/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Assignment from './pages/pirocheck/assignment/Assignment';
import Deposit from './pages/pirocheck/deposit/Deposit';
import StudentList from './pages/pirocheck/students/StudentList';
import StudentDetail from './pages/pirocheck/students/StudentDetail';
import ProtectedRoute from "./routes/ProtectedRoute";

function App() {
return (
Expand All @@ -23,22 +24,27 @@ function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />

{/* 라이트 헤더 페이지 */}
<Route element={<Layout headerType="light" />}>
<Route path="/sessions" element={<QnAMainPage />} />
<Route path="/sessions/:sessionId/questions" element={<QnAListPage />} />
<Route path="/sessions/:sessionId/questions/:questionId" element={<QnADetailPage />} />
<Route path="/curriculum" element={<CurriculumPage />} />
</Route>
{/* 로그인 필요한 페이지들 */}
<Route element={<ProtectedRoute />}>

{/* 라이트 헤더 페이지 */}
<Route element={<Layout headerType="light" />}>
<Route path="/sessions" element={<QnAMainPage />} />
<Route path="/sessions/:sessionId/questions" element={<QnAListPage />} />
<Route path="/sessions/:sessionId/questions/:questionId" element={<QnADetailPage />} />
<Route path="/curriculum" element={<CurriculumPage />} />
</Route>

{/* 다크 헤더 페이지 */}
<Route element={<Layout headerType="dark" />}>
<Route path="/pirocheck" element={<PiroCheckMain />} />
<Route path="/pirocheck/attendance" element={<Attendance />} />
<Route path="/pirocheck/assignment" element={<Assignment />} />
<Route path="/pirocheck/deposit" element={<Deposit />} />
<Route path="/pirocheck/students" element={<StudentList />} />
<Route path="/pirocheck/students/:userId" element={<StudentDetail />} />
</Route>

{/* 다크 헤더 페이지 */}
<Route element={<Layout headerType="dark" />}>
<Route path="/pirocheck" element={<PiroCheckMain />} />
<Route path="/pirocheck/attendance" element={<Attendance />} />
<Route path="/pirocheck/assignment" element={<Assignment />} />
<Route path="/pirocheck/deposit" element={<Deposit />} />
<Route path="/pirocheck/students" element={<StudentList />} />
<Route path="/pirocheck/students/:userId" element={<StudentDetail />} />
</Route>

</Routes>
Expand Down
Loading
Loading