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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import lombok.RequiredArgsConstructor;
import me.thinkcat.opic.practice.dto.request.CompleteAnswerUploadRequest;
import me.thinkcat.opic.practice.dto.request.PrepareAnswerUploadRequest;
import me.thinkcat.opic.practice.dto.request.S3UploadDetectedRequest;
import me.thinkcat.opic.practice.dto.request.UpdateFeedbackRequest;
import me.thinkcat.opic.practice.dto.request.UpdateFeedbackStatusRequest;
import me.thinkcat.opic.practice.dto.request.UpdateTranscriptionRequest;
Expand Down Expand Up @@ -74,6 +75,20 @@ public ResponseEntity<CommonResponse<AnswerResponse>> completeAnswerUpload(
return ResponseEntity.ok(response);
}

@PostMapping("/internal/s3-upload-detected")
public ResponseEntity<CommonResponse<Void>> handleS3UploadDetected(
@Valid @RequestBody S3UploadDetectedRequest request) {

answerService.handleS3UploadDetected(request.getFileKey());

CommonResponse<Void> response = CommonResponse.<Void>builder()
.success(true)
.message("S3 upload detected processed successfully")
.build();

return ResponseEntity.ok(response);
}

@PatchMapping("/internal/transcription")
public ResponseEntity<CommonResponse<Void>> updateTranscription(
@Valid @RequestBody UpdateTranscriptionRequest request) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import me.thinkcat.opic.practice.config.security.annotation.AuthUser;
import me.thinkcat.opic.practice.dto.CommonResponse;
import me.thinkcat.opic.practice.dto.request.PrepareDrillAnswerUploadRequest;
import me.thinkcat.opic.practice.dto.request.S3UploadDetectedRequest;
import me.thinkcat.opic.practice.dto.request.SubmitDrillAnswerRequest;
import me.thinkcat.opic.practice.dto.request.UpdateDrillFeedbackRequest;
import me.thinkcat.opic.practice.dto.request.UpdateDrillTranscriptionRequest;
Expand Down Expand Up @@ -129,6 +130,20 @@ public ResponseEntity<CommonResponse<QuestionPracticeHistoryResponse>> getQuesti
return ResponseEntity.ok(response);
}

@PostMapping("/internal/s3-upload-detected")
public ResponseEntity<CommonResponse<Void>> handleS3UploadDetected(
@Valid @RequestBody S3UploadDetectedRequest request) {

drillAnswerService.handleS3UploadDetected(request.getFileKey());

CommonResponse<Void> response = CommonResponse.<Void>builder()
.success(true)
.message("Drill S3 upload detected processed successfully")
.build();

return ResponseEntity.ok(response);
}

@PatchMapping("/internal/feedback-status")
public ResponseEntity<CommonResponse<Void>> updateDrillFeedbackStatus(
@Valid @RequestBody UpdateFeedbackStatusRequest request) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package me.thinkcat.opic.practice.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class S3UploadDetectedRequest {

@NotBlank(message = "File key is required")
@Pattern(regexp = "^uploads/.+", message = "File key must start with 'uploads/'")
private String fileKey;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package me.thinkcat.opic.practice.repository;

import jakarta.persistence.LockModeType;
import me.thinkcat.opic.practice.entity.Answer;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
Expand All @@ -20,6 +22,14 @@ public interface AnswerRepository extends JpaRepository<Answer, Long> {

Optional<Answer> findByAudioUrl(String audioUrl);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Answer a WHERE a.id = :id")
Optional<Answer> findByIdForUpdate(@Param("id") Long id);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Answer a WHERE a.audioUrl = :audioUrl")
Optional<Answer> findByAudioUrlForUpdate(@Param("audioUrl") String audioUrl);

List<Answer> findByUploadStatusCodeAndUpdatedAtBefore(String uploadStatusCode, LocalDateTime threshold);

List<Answer> findByFeedbackStatusCodeAndUpdatedAtBefore(String feedbackStatusCode, LocalDateTime threshold);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package me.thinkcat.opic.practice.repository;

import jakarta.persistence.LockModeType;
import me.thinkcat.opic.practice.entity.DrillAnswer;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
Expand All @@ -15,6 +17,14 @@ public interface DrillAnswerRepository extends JpaRepository<DrillAnswer, Long>

Optional<DrillAnswer> findByAudioUrl(String audioUrl);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT d FROM DrillAnswer d WHERE d.id = :id")
Optional<DrillAnswer> findByIdForUpdate(@Param("id") Long id);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT d FROM DrillAnswer d WHERE d.audioUrl = :audioUrl")
Optional<DrillAnswer> findByAudioUrlForUpdate(@Param("audioUrl") String audioUrl);

List<DrillAnswer> findByUserIdAndQuestionIdAndUploadStatusCodeOrderByCreatedAtDesc(
Long userId, Long questionId, String uploadStatusCode);

Expand Down
66 changes: 58 additions & 8 deletions src/main/java/me/thinkcat/opic/practice/service/AnswerService.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
import me.thinkcat.opic.practice.exception.ResourceNotFoundException;
import me.thinkcat.opic.practice.exception.ValidationException;
import me.thinkcat.opic.practice.entity.Question;
import me.thinkcat.opic.practice.entity.User;
import me.thinkcat.opic.practice.repository.AnswerRepository;
import me.thinkcat.opic.practice.repository.QuestionRepository;
import me.thinkcat.opic.practice.repository.SessionRepository;
import me.thinkcat.opic.practice.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -39,6 +41,7 @@ public class AnswerService {
private final AnswerRepository answerRepository;
private final SessionRepository sessionRepository;
private final QuestionRepository questionRepository;
private final UserRepository userRepository;
private final PresignedUrlService presignedUrlService;
private final FeedbackLambdaService feedbackLambdaService;

Expand Down Expand Up @@ -88,24 +91,26 @@ public AnswerResponse completeAnswerUpload(
Long answerId,
Integer durationMs) {

Answer answer = answerRepository.findById(answerId)
Answer answer = answerRepository.findByIdForUpdate(answerId)
.orElseThrow(() -> new ResourceNotFoundException("Answer not found with id: " + answerId));

sessionRepository.findByIdAndUserId(answer.getSessionId(), userId)
.orElseThrow(() -> new ValidationException("Unauthorized access to answer"));

if (answer.isUploadSuccess()) {
log.warn("event=answer_upload_already_done | who={} | answerId={}", userId, answerId);
return resolveAnswerResponse(answer);
if (answer.isUploadPending()) {
answer.markUploadSuccess();
log.info("event=answer_upload_complete | who={} | answerId={} | audioUrl={}",
userId, answerId, answer.getAudioUrl());
}

answer.markUploadSuccess();
if (durationMs != null) {
if (durationMs != null && answer.getDurationMs() == 0) {
answer.setDurationMs(durationMs);
}

log.info("event=answer_upload_complete | who={} | answerId={} | audioUrl={}",
userId, answerId, answer.getAudioUrl());
if (!answer.isFeedbackNone()) {
log.warn("event=answer_feedback_already_requested | who={} | answerId={}", userId, answerId);
return resolveAnswerResponse(answerRepository.save(answer));
}

if (userRole == UserRole.PAID || userRole == UserRole.ADMIN || featureFlagService.isEnabled("ai-for-free")) {
answer.requestFeedback();
Expand All @@ -123,6 +128,51 @@ public AnswerResponse completeAnswerUpload(
return resolveAnswerResponse(updatedAnswer);
}

@Transactional
public void handleS3UploadDetected(String fileKey) {
Answer answer = answerRepository.findByAudioUrlForUpdate(fileKey).orElse(null);
if (answer == null) {
log.warn("event=s3_upload_detected_not_found | fileKey={}", fileKey);
return;
}

if (answer.isUploadPending()) {
answer.markUploadSuccess();
log.info("event=s3_upload_detected_marked_success | answerId={} | audioUrl={}",
answer.getId(), fileKey);
}

if (!answer.isFeedbackNone()) {
log.warn("event=s3_upload_detected_feedback_already_requested | answerId={} | audioUrl={}",
answer.getId(), fileKey);
answerRepository.save(answer);
return;
}

Long userId = sessionRepository.findById(answer.getSessionId())
.orElseThrow(() -> new ResourceNotFoundException("Session not found for answer: " + answer.getId()))
.getUserId();

UserRole userRole = userRepository.findById(userId)
.map(User::getUserRole)
.orElse(UserRole.FREE);

if (userRole == UserRole.PAID || userRole == UserRole.ADMIN || featureFlagService.isEnabled("ai-for-free")) {
answer.requestFeedback();
answerRepository.save(answer);
log.info("event=s3_upload_detected_feedback_requested | who={} | answerId={} | audioUrl={}",
userId, answer.getId(), fileKey);
String questionText = questionRepository.findById(answer.getQuestionId())
.map(Question::getQuestion)
.orElse(null);
feedbackLambdaService.invokeSessionFeedbackAsync(fileKey, userId, questionText);
return;
}

answerRepository.save(answer);
log.info("event=s3_upload_detected_no_feedback | who={} | answerId={}", userId, answer.getId());
}

@Transactional(readOnly = true)
public List<AnswerResponse> getSessionAnswers(Long sessionId, Long userId) {
sessionRepository.findByIdAndUserId(sessionId, userId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
import me.thinkcat.opic.practice.entity.UserRole;
import me.thinkcat.opic.practice.exception.ResourceNotFoundException;
import me.thinkcat.opic.practice.exception.ValidationException;
import me.thinkcat.opic.practice.entity.User;
import me.thinkcat.opic.practice.repository.CategoryRepository;
import me.thinkcat.opic.practice.repository.DrillAnswerRepository;
import me.thinkcat.opic.practice.repository.QuestionRepository;
import me.thinkcat.opic.practice.repository.RecentDrillQuestionProjection;
import me.thinkcat.opic.practice.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -40,6 +42,7 @@ public class DrillAnswerService {
private final DrillAnswerRepository drillAnswerRepository;
private final QuestionRepository questionRepository;
private final CategoryRepository categoryRepository;
private final UserRepository userRepository;
private final PresignedUrlService presignedUrlService;
private final FeedbackLambdaService feedbackLambdaService;

Expand Down Expand Up @@ -83,25 +86,27 @@ public PrepareDrillAnswerUploadResponse prepareDrillAnswerUpload(

@Transactional
public DrillAnswerResponse submitDrillAnswer(Long userId, UserRole userRole, Long drillAnswerId, Integer durationMs) {
DrillAnswer answer = drillAnswerRepository.findById(drillAnswerId)
DrillAnswer answer = drillAnswerRepository.findByIdForUpdate(drillAnswerId)
.orElseThrow(() -> new ResourceNotFoundException("Drill answer not found with id: " + drillAnswerId));

if (!answer.getUserId().equals(userId)) {
throw new ValidationException("Unauthorized access to drill answer");
}

if (answer.isUploadSuccess()) {
log.warn("event=drill_submit_already_done | who={} | drillAnswerId={}", userId, drillAnswerId);
return resolveDrillAnswerResponse(answer);
if (answer.isUploadPending()) {
answer.markUploadSuccess();
log.info("event=drill_submit | who={} | drillAnswerId={} | audioUrl={}",
userId, drillAnswerId, answer.getAudioUrl());
}

answer.markUploadSuccess();
if (durationMs != null) {
if (durationMs != null && answer.getDurationMs() == 0) {
answer.setDurationMs(durationMs);
}

log.info("event=drill_submit | who={} | drillAnswerId={} | audioUrl={}",
userId, drillAnswerId, answer.getAudioUrl());
if (!answer.isFeedbackNone()) {
log.warn("event=drill_feedback_already_requested | who={} | drillAnswerId={}", userId, drillAnswerId);
return resolveDrillAnswerResponse(drillAnswerRepository.save(answer));
}

if (userRole == UserRole.PAID || userRole == UserRole.ADMIN || featureFlagService.isEnabled("ai-for-free")) {
answer.requestFeedback();
Expand All @@ -119,6 +124,48 @@ public DrillAnswerResponse submitDrillAnswer(Long userId, UserRole userRole, Lon
return resolveDrillAnswerResponse(updatedAnswer);
}

@Transactional
public void handleS3UploadDetected(String fileKey) {
DrillAnswer answer = drillAnswerRepository.findByAudioUrlForUpdate(fileKey).orElse(null);
if (answer == null) {
log.warn("event=drill_s3_upload_detected_not_found | fileKey={}", fileKey);
return;
}

if (answer.isUploadPending()) {
answer.markUploadSuccess();
log.info("event=drill_s3_upload_detected_marked_success | drillAnswerId={} | audioUrl={}",
answer.getId(), fileKey);
}

if (!answer.isFeedbackNone()) {
log.warn("event=drill_s3_upload_detected_feedback_already_requested | drillAnswerId={} | audioUrl={}",
answer.getId(), fileKey);
drillAnswerRepository.save(answer);
return;
}

UserRole userRole = userRepository.findById(answer.getUserId())
.map(User::getUserRole)
.orElse(UserRole.FREE);

if (userRole == UserRole.PAID || userRole == UserRole.ADMIN || featureFlagService.isEnabled("ai-for-free")) {
answer.requestFeedback();
drillAnswerRepository.save(answer);
log.info("event=drill_s3_upload_detected_feedback_requested | who={} | drillAnswerId={} | audioUrl={}",
answer.getUserId(), answer.getId(), fileKey);
String questionText = questionRepository.findById(answer.getQuestionId())
.map(Question::getQuestion)
.orElse(null);
feedbackLambdaService.invokeDrillAnswerFeedbackAsync(fileKey, answer.getUserId(), questionText);
return;
}

drillAnswerRepository.save(answer);
log.info("event=drill_s3_upload_detected_no_feedback | who={} | drillAnswerId={}",
answer.getUserId(), answer.getId());
}

@Transactional(readOnly = true)
public List<RecentDrillQuestionResponse> getRecentlyPracticedQuestions(Long userId) {
List<RecentDrillQuestionProjection> projections = drillAnswerRepository
Expand Down
Loading