feat: add full practice update endpoints for LMS and exam prep
PUT /practices/:id/full and PUT /exam-prep/practices/:id/full sync practice shell, question set settings, and questions in one request. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
55df6b8b0b
commit
22464479ae
|
|
@ -1,9 +1,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
// "context"
|
|
||||||
|
|
||||||
// "context"
|
|
||||||
_ "Yimaru-Backend/docs"
|
_ "Yimaru-Backend/docs"
|
||||||
dbgen "Yimaru-Backend/gen/db"
|
dbgen "Yimaru-Backend/gen/db"
|
||||||
"Yimaru-Backend/internal/config"
|
"Yimaru-Backend/internal/config"
|
||||||
|
|
@ -31,6 +28,7 @@ import (
|
||||||
moduleservice "Yimaru-Backend/internal/services/modules"
|
moduleservice "Yimaru-Backend/internal/services/modules"
|
||||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||||
personasservice "Yimaru-Backend/internal/services/personas"
|
personasservice "Yimaru-Backend/internal/services/personas"
|
||||||
|
practicecontentservice "Yimaru-Backend/internal/services/practicecontent"
|
||||||
practicesservice "Yimaru-Backend/internal/services/practices"
|
practicesservice "Yimaru-Backend/internal/services/practices"
|
||||||
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
|
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
|
||||||
programsservice "Yimaru-Backend/internal/services/programs"
|
programsservice "Yimaru-Backend/internal/services/programs"
|
||||||
|
|
@ -305,6 +303,7 @@ func main() {
|
||||||
|
|
||||||
// LMS practices (under course, module, or lesson)
|
// LMS practices (under course, module, or lesson)
|
||||||
practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
|
practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
|
||||||
|
practiceContentSvc := practicecontentservice.NewService(store, store, store, store)
|
||||||
|
|
||||||
// Subscriptions service
|
// Subscriptions service
|
||||||
subscriptionsSvc := subscriptions.NewService(store)
|
subscriptionsSvc := subscriptions.NewService(store)
|
||||||
|
|
@ -370,6 +369,7 @@ func main() {
|
||||||
lessonSvc,
|
lessonSvc,
|
||||||
lmsProgressSvc,
|
lmsProgressSvc,
|
||||||
practiceSvc,
|
practiceSvc,
|
||||||
|
practiceContentSvc,
|
||||||
subscriptionsSvc,
|
subscriptionsSvc,
|
||||||
arifpaySvc,
|
arifpaySvc,
|
||||||
chapaSvc,
|
chapaSvc,
|
||||||
|
|
|
||||||
61
internal/domain/practice_full_update.go
Normal file
61
internal/domain/practice_full_update.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
// FullUpdatePracticeInput updates a practice shell, its linked question set, and set questions in one request.
|
||||||
|
type FullUpdatePracticeInput struct {
|
||||||
|
Practice *FullUpdatePracticeShellInput `json:"practice,omitempty"`
|
||||||
|
QuestionSet *FullUpdateQuestionSetInput `json:"question_set,omitempty"`
|
||||||
|
Questions []FullUpdatePracticeQuestionItem `json:"questions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullUpdatePracticeShellInput updates LMS or exam-prep practice metadata (parent is immutable).
|
||||||
|
type FullUpdatePracticeShellInput struct {
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
StoryDescription *string `json:"story_description,omitempty"`
|
||||||
|
StoryImage *string `json:"story_image,omitempty"`
|
||||||
|
PersonaID *int64 `json:"persona_id,omitempty"`
|
||||||
|
QuestionSetID *int64 `json:"question_set_id,omitempty"`
|
||||||
|
QuickTips *string `json:"quick_tips,omitempty"`
|
||||||
|
PublishStatus *string `json:"publish_status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullUpdateQuestionSetInput updates settings on the practice's linked question set.
|
||||||
|
type FullUpdateQuestionSetInput struct {
|
||||||
|
Title *string `json:"title,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
BannerImage *string `json:"banner_image,omitempty"`
|
||||||
|
Persona *string `json:"persona,omitempty"`
|
||||||
|
TimeLimitMinutes *int32 `json:"time_limit_minutes,omitempty"`
|
||||||
|
PassingScore *int32 `json:"passing_score,omitempty"`
|
||||||
|
ShuffleQuestions *bool `json:"shuffle_questions,omitempty"`
|
||||||
|
Status *string `json:"status,omitempty"`
|
||||||
|
IntroVideoURL *string `json:"intro_video_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullUpdatePracticeQuestionItem creates or updates a question and its membership in the practice question set.
|
||||||
|
// Omit id to create a new question. display_order defaults to the item's position in the questions array (1-based).
|
||||||
|
type FullUpdatePracticeQuestionItem struct {
|
||||||
|
ID *int64 `json:"id,omitempty"`
|
||||||
|
DisplayOrder *int32 `json:"display_order,omitempty"`
|
||||||
|
QuestionText *string `json:"question_text,omitempty"`
|
||||||
|
QuestionType *string `json:"question_type,omitempty"`
|
||||||
|
QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"`
|
||||||
|
DynamicPayload *DynamicQuestionPayload `json:"dynamic_payload,omitempty"`
|
||||||
|
DifficultyLevel *string `json:"difficulty_level,omitempty"`
|
||||||
|
Points *int32 `json:"points,omitempty"`
|
||||||
|
Explanation *string `json:"explanation,omitempty"`
|
||||||
|
Tips *string `json:"tips,omitempty"`
|
||||||
|
VoicePrompt *string `json:"voice_prompt,omitempty"`
|
||||||
|
SampleAnswerVoicePrompt *string `json:"sample_answer_voice_prompt,omitempty"`
|
||||||
|
ImageURL *string `json:"image_url,omitempty"`
|
||||||
|
Status *string `json:"status,omitempty"`
|
||||||
|
Options []CreateQuestionOptionInput `json:"options,omitempty"`
|
||||||
|
ShortAnswers []CreateShortAnswerInput `json:"short_answers,omitempty"`
|
||||||
|
AudioCorrectAnswerText *string `json:"audio_correct_answer_text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullUpdatePracticeResult is returned after a successful full practice update.
|
||||||
|
type FullUpdatePracticeResult struct {
|
||||||
|
Practice interface{} `json:"practice"`
|
||||||
|
QuestionSet QuestionSet `json:"question_set"`
|
||||||
|
Questions []QuestionWithDetails `json:"questions"`
|
||||||
|
}
|
||||||
10
internal/services/practicecontent/errors.go
Normal file
10
internal/services/practicecontent/errors.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
package practicecontent
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPracticeNotFound = errors.New("practice not found")
|
||||||
|
ErrQuestionSetNotFound = errors.New("question set not found")
|
||||||
|
ErrQuestionNotFound = errors.New("question not found")
|
||||||
|
ErrInvalidQuestionItem = errors.New("invalid question item")
|
||||||
|
)
|
||||||
322
internal/services/practicecontent/question_sync.go
Normal file
322
internal/services/practicecontent/question_sync.go
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
package practicecontent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeRuntimeQuestionType(v string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalTrimmedString(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(*s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStoredQuestionText(questionType string, explicit *string, payload *domain.DynamicQuestionPayload, existing string) (string, error) {
|
||||||
|
exp := optionalTrimmedString(explicit)
|
||||||
|
if err := domain.ValidateQuestionTextNotAllowedForDynamic(questionType, exp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if domain.UsesDynamicQuestionPayload(questionType) {
|
||||||
|
return domain.ResolveDynamicStoredQuestionText(payload, existing)
|
||||||
|
}
|
||||||
|
if exp == "" {
|
||||||
|
if strings.TrimSpace(existing) != "" {
|
||||||
|
return strings.TrimSpace(existing), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("question_text is required")
|
||||||
|
}
|
||||||
|
return exp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildQuestionInput(
|
||||||
|
item domain.FullUpdatePracticeQuestionItem,
|
||||||
|
existing *domain.Question,
|
||||||
|
getDefinition func(id int64) (domain.QuestionTypeDefinition, error),
|
||||||
|
) (domain.CreateQuestionInput, error) {
|
||||||
|
var questionType string
|
||||||
|
if existing != nil {
|
||||||
|
questionType = existing.QuestionType
|
||||||
|
}
|
||||||
|
if item.QuestionType != nil {
|
||||||
|
questionType = normalizeRuntimeQuestionType(*item.QuestionType)
|
||||||
|
}
|
||||||
|
if questionType == "" {
|
||||||
|
return domain.CreateQuestionInput{}, fmt.Errorf("%w: question_type is required for new questions", ErrInvalidQuestionItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveDefinitionID := (*int64)(nil)
|
||||||
|
if existing != nil {
|
||||||
|
effectiveDefinitionID = existing.QuestionTypeDefinitionID
|
||||||
|
}
|
||||||
|
if item.QuestionTypeDefinitionID != nil {
|
||||||
|
effectiveDefinitionID = item.QuestionTypeDefinitionID
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveDynamicPayload := (*domain.DynamicQuestionPayload)(nil)
|
||||||
|
if existing != nil {
|
||||||
|
effectiveDynamicPayload = existing.DynamicPayload
|
||||||
|
}
|
||||||
|
if item.DynamicPayload != nil {
|
||||||
|
effectiveDynamicPayload = item.DynamicPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
if effectiveDefinitionID != nil {
|
||||||
|
def, err := getDefinition(*effectiveDefinitionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CreateQuestionInput{}, fmt.Errorf("invalid question_type_definition_id: %w", err)
|
||||||
|
}
|
||||||
|
if questionType == "" {
|
||||||
|
questionType = "DYNAMIC"
|
||||||
|
}
|
||||||
|
if questionType != "DYNAMIC" {
|
||||||
|
return domain.CreateQuestionInput{}, fmt.Errorf("%w: question_type must be DYNAMIC when question_type_definition_id is provided", ErrInvalidQuestionItem)
|
||||||
|
}
|
||||||
|
if effectiveDynamicPayload == nil {
|
||||||
|
return domain.CreateQuestionInput{}, fmt.Errorf("%w: dynamic_payload is required for dynamic questions", ErrInvalidQuestionItem)
|
||||||
|
}
|
||||||
|
if err := domain.ValidateDynamicPayloadAgainstDefinition(*effectiveDynamicPayload, def); err != nil {
|
||||||
|
return domain.CreateQuestionInput{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if effectiveDefinitionID == nil && item.DynamicPayload != nil {
|
||||||
|
return domain.CreateQuestionInput{}, fmt.Errorf("%w: dynamic_payload requires question_type_definition_id", ErrInvalidQuestionItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch questionType {
|
||||||
|
case "MCQ", "TRUE_FALSE", "SHORT_ANSWER", "AUDIO", "DYNAMIC":
|
||||||
|
default:
|
||||||
|
return domain.CreateQuestionInput{}, fmt.Errorf("%w: question_type must be one of MCQ, TRUE_FALSE, SHORT_ANSWER, AUDIO, DYNAMIC", ErrInvalidQuestionItem)
|
||||||
|
}
|
||||||
|
if questionType == "DYNAMIC" && effectiveDefinitionID == nil {
|
||||||
|
return domain.CreateQuestionInput{}, fmt.Errorf("%w: question_type_definition_id is required for DYNAMIC question_type", ErrInvalidQuestionItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingText := ""
|
||||||
|
if existing != nil {
|
||||||
|
existingText = existing.QuestionText
|
||||||
|
}
|
||||||
|
questionText, err := resolveStoredQuestionText(questionType, item.QuestionText, effectiveDynamicPayload, existingText)
|
||||||
|
if err != nil {
|
||||||
|
return domain.CreateQuestionInput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.CreateQuestionInput{
|
||||||
|
QuestionText: questionText,
|
||||||
|
QuestionType: questionType,
|
||||||
|
QuestionTypeDefinitionID: effectiveDefinitionID,
|
||||||
|
DynamicPayload: effectiveDynamicPayload,
|
||||||
|
DifficultyLevel: coalesceStringPtr(item.DifficultyLevel, existingDifficulty(existing)),
|
||||||
|
Points: coalesceInt32Ptr(item.Points, existingPoints(existing)),
|
||||||
|
Explanation: coalesceStringPtr(item.Explanation, existingExplanation(existing)),
|
||||||
|
Tips: coalesceStringPtr(item.Tips, existingTips(existing)),
|
||||||
|
VoicePrompt: coalesceStringPtr(item.VoicePrompt, existingVoicePrompt(existing)),
|
||||||
|
SampleAnswerVoicePrompt: coalesceStringPtr(item.SampleAnswerVoicePrompt, existingSampleVoice(existing)),
|
||||||
|
ImageURL: coalesceStringPtr(item.ImageURL, existingImageURL(existing)),
|
||||||
|
Status: coalesceStringPtr(item.Status, existingStatus(existing)),
|
||||||
|
Options: item.Options,
|
||||||
|
ShortAnswers: item.ShortAnswers,
|
||||||
|
AudioCorrectAnswerText: item.AudioCorrectAnswerText,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func existingDifficulty(q *domain.Question) *string {
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return q.DifficultyLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func existingPoints(q *domain.Question) *int32 {
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p := q.Points
|
||||||
|
return &p
|
||||||
|
}
|
||||||
|
|
||||||
|
func existingExplanation(q *domain.Question) *string {
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return q.Explanation
|
||||||
|
}
|
||||||
|
|
||||||
|
func existingTips(q *domain.Question) *string {
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return q.Tips
|
||||||
|
}
|
||||||
|
|
||||||
|
func existingVoicePrompt(q *domain.Question) *string {
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return q.VoicePrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
func existingSampleVoice(q *domain.Question) *string {
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return q.SampleAnswerVoicePrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
func existingImageURL(q *domain.Question) *string {
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return q.ImageURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func existingStatus(q *domain.Question) *string {
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := q.Status
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func coalesceStringPtr(in *string, existing *string) *string {
|
||||||
|
if in != nil {
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
func coalesceInt32Ptr(in *int32, existing *int32) *int32 {
|
||||||
|
if in != nil {
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayOrderForItem(item domain.FullUpdatePracticeQuestionItem, index int) int32 {
|
||||||
|
if item.DisplayOrder != nil && *item.DisplayOrder > 0 {
|
||||||
|
return *item.DisplayOrder
|
||||||
|
}
|
||||||
|
return int32(index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) replaceQuestionChildren(ctx context.Context, questionID int64, input domain.CreateQuestionInput) error {
|
||||||
|
if err := s.questions.DeleteOptionsByQuestionID(ctx, questionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, opt := range input.Options {
|
||||||
|
if _, err := s.questions.CreateQuestionOption(ctx, questionID, opt.OptionText, opt.OptionOrder, opt.IsCorrect); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.questions.DeleteShortAnswersByQuestionID(ctx, questionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, sa := range input.ShortAnswers {
|
||||||
|
if _, err := s.questions.CreateQuestionShortAnswer(ctx, questionID, sa.AcceptableAnswer, sa.MatchType); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncQuestionsInSet(ctx context.Context, setID int64, items []domain.FullUpdatePracticeQuestionItem) ([]domain.QuestionWithDetails, error) {
|
||||||
|
currentItems, err := s.questions.GetQuestionSetItems(ctx, setID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
for _, it := range currentItems {
|
||||||
|
if err := s.questions.RemoveQuestionFromSet(ctx, setID, it.QuestionID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []domain.QuestionWithDetails{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentInSet := make(map[int64]struct{}, len(currentItems))
|
||||||
|
for _, it := range currentItems {
|
||||||
|
currentInSet[it.QuestionID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefinition := func(id int64) (domain.QuestionTypeDefinition, error) {
|
||||||
|
return s.questions.GetQuestionTypeDefinitionByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
desiredIDs := make(map[int64]struct{}, len(items))
|
||||||
|
orderedIDs := make([]int64, 0, len(items))
|
||||||
|
|
||||||
|
for i, item := range items {
|
||||||
|
order := displayOrderForItem(item, i)
|
||||||
|
|
||||||
|
if item.ID != nil && *item.ID > 0 {
|
||||||
|
questionID := *item.ID
|
||||||
|
existing, err := s.questions.GetQuestionByID(ctx, questionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %d", ErrQuestionNotFound, questionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := buildQuestionInput(item, &existing, getDefinition)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.questions.UpdateQuestion(ctx, questionID, input); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.replaceQuestionChildren(ctx, questionID, input); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := currentInSet[questionID]; !ok {
|
||||||
|
if _, err := s.questions.AddQuestionToSet(ctx, setID, questionID, &order); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if err := s.questions.UpdateQuestionOrder(ctx, setID, questionID, order); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
desiredIDs[questionID] = struct{}{}
|
||||||
|
orderedIDs = append(orderedIDs, questionID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := buildQuestionInput(item, nil, getDefinition)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
created, err := s.questions.CreateQuestion(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.questions.AddQuestionToSet(ctx, setID, created.ID, &order); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
desiredIDs[created.ID] = struct{}{}
|
||||||
|
orderedIDs = append(orderedIDs, created.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, it := range currentItems {
|
||||||
|
if _, keep := desiredIDs[it.QuestionID]; !keep {
|
||||||
|
if err := s.questions.RemoveQuestionFromSet(ctx, setID, it.QuestionID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]domain.QuestionWithDetails, 0, len(orderedIDs))
|
||||||
|
for _, id := range orderedIDs {
|
||||||
|
q, err := s.questions.GetQuestionWithDetails(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
257
internal/services/practicecontent/service.go
Normal file
257
internal/services/practicecontent/service.go
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
package practicecontent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
|
"Yimaru-Backend/internal/ports"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
questions ports.QuestionStore
|
||||||
|
lms ports.LmsPracticeStore
|
||||||
|
examPrep ports.ExamPrepPracticeStore
|
||||||
|
personas ports.LmsPersonaReader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(
|
||||||
|
questions ports.QuestionStore,
|
||||||
|
lms ports.LmsPracticeStore,
|
||||||
|
examPrep ports.ExamPrepPracticeStore,
|
||||||
|
personas ports.LmsPersonaReader,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
questions: questions,
|
||||||
|
lms: lms,
|
||||||
|
examPrep: examPrep,
|
||||||
|
personas: personas,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validatePersona(ctx context.Context, id int64) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return domain.ErrPersonaNotFound
|
||||||
|
}
|
||||||
|
_, err := s.personas.GetLmsPersonaByID(ctx, id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.ErrPersonaNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validateQuestionSet(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.questions.GetQuestionSetByID(ctx, id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrQuestionSetNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeQuestionSetInput(existing domain.QuestionSet, in *domain.FullUpdateQuestionSetInput) domain.CreateQuestionSetInput {
|
||||||
|
out := domain.CreateQuestionSetInput{
|
||||||
|
Title: existing.Title,
|
||||||
|
Description: existing.Description,
|
||||||
|
SetType: existing.SetType,
|
||||||
|
OwnerType: existing.OwnerType,
|
||||||
|
OwnerID: existing.OwnerID,
|
||||||
|
BannerImage: existing.BannerImage,
|
||||||
|
Persona: existing.Persona,
|
||||||
|
TimeLimitMinutes: existing.TimeLimitMinutes,
|
||||||
|
PassingScore: existing.PassingScore,
|
||||||
|
ShuffleQuestions: &existing.ShuffleQuestions,
|
||||||
|
Status: &existing.Status,
|
||||||
|
IntroVideoURL: existing.IntroVideoURL,
|
||||||
|
}
|
||||||
|
if in == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if in.Title != nil {
|
||||||
|
out.Title = *in.Title
|
||||||
|
}
|
||||||
|
if in.Description != nil {
|
||||||
|
out.Description = in.Description
|
||||||
|
}
|
||||||
|
if in.BannerImage != nil {
|
||||||
|
out.BannerImage = in.BannerImage
|
||||||
|
}
|
||||||
|
if in.Persona != nil {
|
||||||
|
out.Persona = in.Persona
|
||||||
|
}
|
||||||
|
if in.TimeLimitMinutes != nil {
|
||||||
|
out.TimeLimitMinutes = in.TimeLimitMinutes
|
||||||
|
}
|
||||||
|
if in.PassingScore != nil {
|
||||||
|
out.PassingScore = in.PassingScore
|
||||||
|
}
|
||||||
|
if in.ShuffleQuestions != nil {
|
||||||
|
out.ShuffleQuestions = in.ShuffleQuestions
|
||||||
|
}
|
||||||
|
if in.Status != nil {
|
||||||
|
out.Status = in.Status
|
||||||
|
}
|
||||||
|
if in.IntroVideoURL != nil {
|
||||||
|
out.IntroVideoURL = in.IntroVideoURL
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) applySharedUpdates(
|
||||||
|
ctx context.Context,
|
||||||
|
questionSetID int64,
|
||||||
|
in domain.FullUpdatePracticeInput,
|
||||||
|
) (domain.QuestionSet, []domain.QuestionWithDetails, error) {
|
||||||
|
if in.QuestionSet != nil {
|
||||||
|
existingSet, err := s.questions.GetQuestionSetByID(ctx, questionSetID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.QuestionSet{}, nil, ErrQuestionSetNotFound
|
||||||
|
}
|
||||||
|
return domain.QuestionSet{}, nil, err
|
||||||
|
}
|
||||||
|
merged := mergeQuestionSetInput(existingSet, in.QuestionSet)
|
||||||
|
if err := s.questions.UpdateQuestionSet(ctx, questionSetID, merged); err != nil {
|
||||||
|
return domain.QuestionSet{}, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var synced []domain.QuestionWithDetails
|
||||||
|
if in.Questions != nil {
|
||||||
|
var err error
|
||||||
|
synced, err = s.syncQuestionsInSet(ctx, questionSetID, in.Questions)
|
||||||
|
if err != nil {
|
||||||
|
return domain.QuestionSet{}, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set, err := s.questions.GetQuestionSetByID(ctx, questionSetID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.QuestionSet{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if synced == nil && in.Questions == nil {
|
||||||
|
items, err := s.questions.GetQuestionSetItems(ctx, questionSetID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.QuestionSet{}, nil, err
|
||||||
|
}
|
||||||
|
synced = make([]domain.QuestionWithDetails, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
q, err := s.questions.GetQuestionWithDetails(ctx, item.QuestionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.QuestionSet{}, nil, err
|
||||||
|
}
|
||||||
|
synced = append(synced, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return set, synced, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLmsPracticeFull updates an LMS practice shell, linked question set, and questions.
|
||||||
|
func (s *Service) UpdateLmsPracticeFull(ctx context.Context, practiceID int64, in domain.FullUpdatePracticeInput) (domain.FullUpdatePracticeResult, error) {
|
||||||
|
practice, err := s.lms.GetLmsPracticeByID(ctx, practiceID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.FullUpdatePracticeResult{}, ErrPracticeNotFound
|
||||||
|
}
|
||||||
|
return domain.FullUpdatePracticeResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
questionSetID := practice.QuestionSetID
|
||||||
|
if in.Practice != nil {
|
||||||
|
if in.Practice.PersonaID != nil {
|
||||||
|
if err := s.validatePersona(ctx, *in.Practice.PersonaID); err != nil {
|
||||||
|
return domain.FullUpdatePracticeResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Practice.QuestionSetID != nil {
|
||||||
|
if err := s.validateQuestionSet(ctx, *in.Practice.QuestionSetID); err != nil {
|
||||||
|
return domain.FullUpdatePracticeResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update := domain.UpdatePracticeInput{
|
||||||
|
Title: in.Practice.Title,
|
||||||
|
StoryDescription: in.Practice.StoryDescription,
|
||||||
|
StoryImage: in.Practice.StoryImage,
|
||||||
|
PersonaID: in.Practice.PersonaID,
|
||||||
|
QuestionSetID: in.Practice.QuestionSetID,
|
||||||
|
QuickTips: in.Practice.QuickTips,
|
||||||
|
PublishStatus: in.Practice.PublishStatus,
|
||||||
|
}
|
||||||
|
practice, err = s.lms.UpdateLmsPractice(ctx, practiceID, update)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.FullUpdatePracticeResult{}, ErrPracticeNotFound
|
||||||
|
}
|
||||||
|
return domain.FullUpdatePracticeResult{}, err
|
||||||
|
}
|
||||||
|
questionSetID = practice.QuestionSetID
|
||||||
|
}
|
||||||
|
|
||||||
|
set, questions, err := s.applySharedUpdates(ctx, questionSetID, in)
|
||||||
|
if err != nil {
|
||||||
|
return domain.FullUpdatePracticeResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.FullUpdatePracticeResult{
|
||||||
|
Practice: practice,
|
||||||
|
QuestionSet: set,
|
||||||
|
Questions: questions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateExamPrepPracticeFull updates an exam-prep practice shell, linked question set, and questions.
|
||||||
|
func (s *Service) UpdateExamPrepPracticeFull(ctx context.Context, practiceID int64, in domain.FullUpdatePracticeInput) (domain.FullUpdatePracticeResult, error) {
|
||||||
|
practice, err := s.examPrep.GetExamPrepLessonPracticeByID(ctx, practiceID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.FullUpdatePracticeResult{}, ErrPracticeNotFound
|
||||||
|
}
|
||||||
|
return domain.FullUpdatePracticeResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
questionSetID := practice.QuestionSetID
|
||||||
|
if in.Practice != nil {
|
||||||
|
if in.Practice.PersonaID != nil {
|
||||||
|
if err := s.validatePersona(ctx, *in.Practice.PersonaID); err != nil {
|
||||||
|
return domain.FullUpdatePracticeResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Practice.QuestionSetID != nil {
|
||||||
|
if err := s.validateQuestionSet(ctx, *in.Practice.QuestionSetID); err != nil {
|
||||||
|
return domain.FullUpdatePracticeResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update := domain.UpdateExamPrepPracticeInput{
|
||||||
|
Title: in.Practice.Title,
|
||||||
|
StoryDescription: in.Practice.StoryDescription,
|
||||||
|
StoryImage: in.Practice.StoryImage,
|
||||||
|
PersonaID: in.Practice.PersonaID,
|
||||||
|
QuestionSetID: in.Practice.QuestionSetID,
|
||||||
|
QuickTips: in.Practice.QuickTips,
|
||||||
|
PublishStatus: in.Practice.PublishStatus,
|
||||||
|
}
|
||||||
|
practice, err = s.examPrep.UpdateExamPrepLessonPractice(ctx, practiceID, update)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.FullUpdatePracticeResult{}, ErrPracticeNotFound
|
||||||
|
}
|
||||||
|
return domain.FullUpdatePracticeResult{}, err
|
||||||
|
}
|
||||||
|
questionSetID = practice.QuestionSetID
|
||||||
|
}
|
||||||
|
|
||||||
|
set, questions, err := s.applySharedUpdates(ctx, questionSetID, in)
|
||||||
|
if err != nil {
|
||||||
|
return domain.FullUpdatePracticeResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.FullUpdatePracticeResult{
|
||||||
|
Practice: practice,
|
||||||
|
QuestionSet: set,
|
||||||
|
Questions: questions,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
44
internal/services/practicecontent/service_test.go
Normal file
44
internal/services/practicecontent/service_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package practicecontent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildQuestionInput_dynamicAudioOnly(t *testing.T) {
|
||||||
|
item := domain.FullUpdatePracticeQuestionItem{
|
||||||
|
QuestionType: strPtr("DYNAMIC"),
|
||||||
|
QuestionTypeDefinitionID: int64Ptr(20),
|
||||||
|
DynamicPayload: &domain.DynamicQuestionPayload{
|
||||||
|
Stimulus: []domain.DynamicElementInstance{
|
||||||
|
{ID: "audio_prompt_1", Kind: "AUDIO_PROMPT", Value: "https://cdn.example.com/prompt.mp3"},
|
||||||
|
},
|
||||||
|
Response: []domain.DynamicElementInstance{
|
||||||
|
{ID: "answer_timer_1", Kind: "ANSWER_TIMER", Value: map[string]interface{}{"seconds": 30}},
|
||||||
|
{ID: "audio_response_1", Kind: "AUDIO_RESPONSE"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def := domain.QuestionTypeDefinition{
|
||||||
|
StimulusComponentKinds: []string{"AUDIO_PROMPT"},
|
||||||
|
ResponseComponentKinds: []string{"ANSWER_TIMER", "AUDIO_RESPONSE"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := buildQuestionInput(item, nil, func(id int64) (domain.QuestionTypeDefinition, error) {
|
||||||
|
return def, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got.QuestionType != "DYNAMIC" {
|
||||||
|
t.Fatalf("expected DYNAMIC, got %q", got.QuestionType)
|
||||||
|
}
|
||||||
|
if got.QuestionText != "" {
|
||||||
|
t.Fatalf("expected empty stored text, got %q", got.QuestionText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string { return &s }
|
||||||
|
|
||||||
|
func int64Ptr(v int64) *int64 { return &v }
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"Yimaru-Backend/internal/services/modules"
|
"Yimaru-Backend/internal/services/modules"
|
||||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||||
"Yimaru-Backend/internal/services/personas"
|
"Yimaru-Backend/internal/services/personas"
|
||||||
|
practicecontentservice "Yimaru-Backend/internal/services/practicecontent"
|
||||||
"Yimaru-Backend/internal/services/practices"
|
"Yimaru-Backend/internal/services/practices"
|
||||||
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
|
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
|
||||||
"Yimaru-Backend/internal/services/programs"
|
"Yimaru-Backend/internal/services/programs"
|
||||||
|
|
@ -65,6 +66,7 @@ type App struct {
|
||||||
lessonSvc *lessons.Service
|
lessonSvc *lessons.Service
|
||||||
lmsProgressSvc *lmsprogress.Service
|
lmsProgressSvc *lmsprogress.Service
|
||||||
practiceSvc *practices.Service
|
practiceSvc *practices.Service
|
||||||
|
practiceContentSvc *practicecontentservice.Service
|
||||||
subscriptionsSvc *subscriptions.Service
|
subscriptionsSvc *subscriptions.Service
|
||||||
arifpaySvc *arifpay.ArifpayService
|
arifpaySvc *arifpay.ArifpayService
|
||||||
chapaSvc *chapa.Service
|
chapaSvc *chapa.Service
|
||||||
|
|
@ -110,6 +112,7 @@ func NewApp(
|
||||||
lessonSvc *lessons.Service,
|
lessonSvc *lessons.Service,
|
||||||
lmsProgressSvc *lmsprogress.Service,
|
lmsProgressSvc *lmsprogress.Service,
|
||||||
practiceSvc *practices.Service,
|
practiceSvc *practices.Service,
|
||||||
|
practiceContentSvc *practicecontentservice.Service,
|
||||||
subscriptionsSvc *subscriptions.Service,
|
subscriptionsSvc *subscriptions.Service,
|
||||||
arifpaySvc *arifpay.ArifpayService,
|
arifpaySvc *arifpay.ArifpayService,
|
||||||
chapaSvc *chapa.Service,
|
chapaSvc *chapa.Service,
|
||||||
|
|
@ -167,6 +170,7 @@ func NewApp(
|
||||||
lessonSvc: lessonSvc,
|
lessonSvc: lessonSvc,
|
||||||
lmsProgressSvc: lmsProgressSvc,
|
lmsProgressSvc: lmsProgressSvc,
|
||||||
practiceSvc: practiceSvc,
|
practiceSvc: practiceSvc,
|
||||||
|
practiceContentSvc: practiceContentSvc,
|
||||||
subscriptionsSvc: subscriptionsSvc,
|
subscriptionsSvc: subscriptionsSvc,
|
||||||
arifpaySvc: arifpaySvc,
|
arifpaySvc: arifpaySvc,
|
||||||
chapaSvc: chapaSvc,
|
chapaSvc: chapaSvc,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import (
|
||||||
"Yimaru-Backend/internal/services/modules"
|
"Yimaru-Backend/internal/services/modules"
|
||||||
notificationservice "Yimaru-Backend/internal/services/notification"
|
notificationservice "Yimaru-Backend/internal/services/notification"
|
||||||
"Yimaru-Backend/internal/services/personas"
|
"Yimaru-Backend/internal/services/personas"
|
||||||
|
practicecontentservice "Yimaru-Backend/internal/services/practicecontent"
|
||||||
"Yimaru-Backend/internal/services/practices"
|
"Yimaru-Backend/internal/services/practices"
|
||||||
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
|
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
|
||||||
"Yimaru-Backend/internal/services/programs"
|
"Yimaru-Backend/internal/services/programs"
|
||||||
|
|
@ -64,6 +65,7 @@ type Handler struct {
|
||||||
lessonSvc *lessons.Service
|
lessonSvc *lessons.Service
|
||||||
lmsProgressSvc *lmsprogress.Service
|
lmsProgressSvc *lmsprogress.Service
|
||||||
practiceSvc *practices.Service
|
practiceSvc *practices.Service
|
||||||
|
practiceContentSvc *practicecontentservice.Service
|
||||||
subscriptionsSvc *subscriptions.Service
|
subscriptionsSvc *subscriptions.Service
|
||||||
arifpaySvc *arifpay.ArifpayService
|
arifpaySvc *arifpay.ArifpayService
|
||||||
chapaSvc *chapa.Service
|
chapaSvc *chapa.Service
|
||||||
|
|
@ -105,6 +107,7 @@ func New(
|
||||||
lessonSvc *lessons.Service,
|
lessonSvc *lessons.Service,
|
||||||
lmsProgressSvc *lmsprogress.Service,
|
lmsProgressSvc *lmsprogress.Service,
|
||||||
practiceSvc *practices.Service,
|
practiceSvc *practices.Service,
|
||||||
|
practiceContentSvc *practicecontentservice.Service,
|
||||||
subscriptionsSvc *subscriptions.Service,
|
subscriptionsSvc *subscriptions.Service,
|
||||||
arifpaySvc *arifpay.ArifpayService,
|
arifpaySvc *arifpay.ArifpayService,
|
||||||
chapaSvc *chapa.Service,
|
chapaSvc *chapa.Service,
|
||||||
|
|
@ -145,6 +148,7 @@ func New(
|
||||||
lessonSvc: lessonSvc,
|
lessonSvc: lessonSvc,
|
||||||
lmsProgressSvc: lmsProgressSvc,
|
lmsProgressSvc: lmsProgressSvc,
|
||||||
practiceSvc: practiceSvc,
|
practiceSvc: practiceSvc,
|
||||||
|
practiceContentSvc: practiceContentSvc,
|
||||||
subscriptionsSvc: subscriptionsSvc,
|
subscriptionsSvc: subscriptionsSvc,
|
||||||
arifpaySvc: arifpaySvc,
|
arifpaySvc: arifpaySvc,
|
||||||
chapaSvc: chapaSvc,
|
chapaSvc: chapaSvc,
|
||||||
|
|
|
||||||
196
internal/web_server/handlers/practice_full_update_handler.go
Normal file
196
internal/web_server/handlers/practice_full_update_handler.go
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"Yimaru-Backend/internal/domain"
|
||||||
|
"Yimaru-Backend/internal/services/practicecontent"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fullUpdatePracticeQuestionResponses(questions []domain.QuestionWithDetails) []questionRes {
|
||||||
|
out := make([]questionRes, 0, len(questions))
|
||||||
|
for _, question := range questions {
|
||||||
|
options := make([]optionRes, 0, len(question.Options))
|
||||||
|
for _, opt := range question.Options {
|
||||||
|
options = append(options, optionRes{
|
||||||
|
ID: opt.ID,
|
||||||
|
OptionText: opt.OptionText,
|
||||||
|
OptionOrder: opt.OptionOrder,
|
||||||
|
IsCorrect: opt.IsCorrect,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
shortAnswers := make([]shortAnswerRes, 0, len(question.ShortAnswers))
|
||||||
|
for _, sa := range question.ShortAnswers {
|
||||||
|
shortAnswers = append(shortAnswers, shortAnswerRes{
|
||||||
|
ID: sa.ID,
|
||||||
|
AcceptableAnswer: sa.AcceptableAnswer,
|
||||||
|
MatchType: sa.MatchType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioCorrectAnswerText *string
|
||||||
|
if question.AudioAnswer != nil {
|
||||||
|
audioCorrectAnswerText = &question.AudioAnswer.CorrectAnswerText
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, questionRes{
|
||||||
|
ID: question.ID,
|
||||||
|
QuestionText: questionTextField(question.QuestionType, question.QuestionText),
|
||||||
|
QuestionType: question.QuestionType,
|
||||||
|
QuestionTypeDefinitionID: question.QuestionTypeDefinitionID,
|
||||||
|
DynamicPayload: question.DynamicPayload,
|
||||||
|
DifficultyLevel: question.DifficultyLevel,
|
||||||
|
Points: question.Points,
|
||||||
|
Explanation: question.Explanation,
|
||||||
|
Tips: question.Tips,
|
||||||
|
VoicePrompt: question.VoicePrompt,
|
||||||
|
SampleAnswerVoicePrompt: question.SampleAnswerVoicePrompt,
|
||||||
|
ImageURL: question.ImageURL,
|
||||||
|
Status: question.Status,
|
||||||
|
CreatedAt: question.CreatedAt.String(),
|
||||||
|
Options: options,
|
||||||
|
ShortAnswers: shortAnswers,
|
||||||
|
AudioCorrectAnswerText: audioCorrectAnswerText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullUpdatePracticeResponse(result domain.FullUpdatePracticeResult) fiber.Map {
|
||||||
|
return fiber.Map{
|
||||||
|
"practice": result.Practice,
|
||||||
|
"question_set": questionSetResFromDomain(result.QuestionSet),
|
||||||
|
"questions": fullUpdatePracticeQuestionResponses(result.Questions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func questionSetResFromDomain(set domain.QuestionSet) questionSetRes {
|
||||||
|
return questionSetRes{
|
||||||
|
ID: set.ID,
|
||||||
|
Title: set.Title,
|
||||||
|
Description: set.Description,
|
||||||
|
SetType: set.SetType,
|
||||||
|
OwnerType: set.OwnerType,
|
||||||
|
OwnerID: set.OwnerID,
|
||||||
|
BannerImage: set.BannerImage,
|
||||||
|
Persona: set.Persona,
|
||||||
|
TimeLimitMinutes: set.TimeLimitMinutes,
|
||||||
|
PassingScore: set.PassingScore,
|
||||||
|
ShuffleQuestions: set.ShuffleQuestions,
|
||||||
|
Status: set.Status,
|
||||||
|
IntroVideoURL: set.IntroVideoURL,
|
||||||
|
CreatedAt: set.CreatedAt.String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapFullUpdatePracticeError(err error) (int, string) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, practicecontent.ErrPracticeNotFound):
|
||||||
|
return fiber.StatusNotFound, "Practice not found"
|
||||||
|
case errors.Is(err, practicecontent.ErrQuestionSetNotFound):
|
||||||
|
return fiber.StatusNotFound, "Question set not found"
|
||||||
|
case errors.Is(err, practicecontent.ErrQuestionNotFound):
|
||||||
|
return fiber.StatusNotFound, "Question not found"
|
||||||
|
case errors.Is(err, domain.ErrPersonaNotFound):
|
||||||
|
return fiber.StatusNotFound, "Persona not found"
|
||||||
|
case errors.Is(err, practicecontent.ErrInvalidQuestionItem):
|
||||||
|
return fiber.StatusBadRequest, "Invalid question item"
|
||||||
|
default:
|
||||||
|
return fiber.StatusBadRequest, "Failed to update practice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLmsPracticeFull godoc
|
||||||
|
// @Summary Full update LMS practice with question set and questions
|
||||||
|
// @Description Updates practice metadata, linked question set settings, and syncs questions in one request. Questions omitted from the questions array are removed from the set (not deleted from the bank).
|
||||||
|
// @Tags practices
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Practice ID"
|
||||||
|
// @Param body body domain.FullUpdatePracticeInput true "Full practice update payload"
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 400 {object} domain.ErrorResponse
|
||||||
|
// @Failure 404 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/practices/{id}/full [put]
|
||||||
|
func (h *Handler) UpdateLmsPracticeFull(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid practice id",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req domain.FullUpdatePracticeInput
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.practiceContentSvc.UpdateLmsPracticeFull(c.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
status, msg := mapFullUpdatePracticeError(err)
|
||||||
|
return c.Status(status).JSON(domain.ErrorResponse{
|
||||||
|
Message: msg,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Practice updated successfully",
|
||||||
|
Data: fullUpdatePracticeResponse(result),
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateExamPrepPracticeFull godoc
|
||||||
|
// @Summary Full update exam-prep practice with question set and questions
|
||||||
|
// @Description Updates exam-prep practice metadata, linked question set settings, and syncs questions in one request.
|
||||||
|
// @Tags exam-prep
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Exam prep practice ID"
|
||||||
|
// @Param body body domain.FullUpdatePracticeInput true "Full practice update payload"
|
||||||
|
// @Success 200 {object} domain.Response
|
||||||
|
// @Failure 400 {object} domain.ErrorResponse
|
||||||
|
// @Failure 404 {object} domain.ErrorResponse
|
||||||
|
// @Router /api/v1/exam-prep/practices/{id}/full [put]
|
||||||
|
func (h *Handler) UpdateExamPrepPracticeFull(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid practice id",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req domain.FullUpdatePracticeInput
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||||
|
Message: "Invalid request body",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.practiceContentSvc.UpdateExamPrepPracticeFull(c.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
status, msg := mapFullUpdatePracticeError(err)
|
||||||
|
return c.Status(status).JSON(domain.ErrorResponse{
|
||||||
|
Message: msg,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(domain.Response{
|
||||||
|
Message: "Practice updated successfully",
|
||||||
|
Data: fullUpdatePracticeResponse(result),
|
||||||
|
Success: true,
|
||||||
|
StatusCode: fiber.StatusOK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ func (a *App) initAppRoutes() {
|
||||||
a.lessonSvc,
|
a.lessonSvc,
|
||||||
a.lmsProgressSvc,
|
a.lmsProgressSvc,
|
||||||
a.practiceSvc,
|
a.practiceSvc,
|
||||||
|
a.practiceContentSvc,
|
||||||
a.subscriptionsSvc,
|
a.subscriptionsSvc,
|
||||||
a.arifpaySvc,
|
a.arifpaySvc,
|
||||||
a.chapaSvc,
|
a.chapaSvc,
|
||||||
|
|
@ -114,6 +115,7 @@ func (a *App) initAppRoutes() {
|
||||||
examPrep.Get("/lessons/:lessonId/practices", a.RequirePermission("exam_prep.practices.list_by_lesson"), h.ListExamPrepPracticesByLesson)
|
examPrep.Get("/lessons/:lessonId/practices", a.RequirePermission("exam_prep.practices.list_by_lesson"), h.ListExamPrepPracticesByLesson)
|
||||||
examPrep.Get("/practices/:id", a.RequirePermission("exam_prep.practices.get"), h.GetExamPrepPracticeByID)
|
examPrep.Get("/practices/:id", a.RequirePermission("exam_prep.practices.get"), h.GetExamPrepPracticeByID)
|
||||||
examPrep.Put("/practices/:id", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPractice)
|
examPrep.Put("/practices/:id", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPractice)
|
||||||
|
examPrep.Put("/practices/:id/full", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPracticeFull)
|
||||||
examPrep.Delete("/practices/:id", a.RequirePermission("exam_prep.practices.delete"), h.DeleteExamPrepPractice)
|
examPrep.Delete("/practices/:id", a.RequirePermission("exam_prep.practices.delete"), h.DeleteExamPrepPractice)
|
||||||
examPrep.Get("/lessons/:id", a.RequirePermission("exam_prep.lessons.get"), h.GetExamPrepLessonByID)
|
examPrep.Get("/lessons/:id", a.RequirePermission("exam_prep.lessons.get"), h.GetExamPrepLessonByID)
|
||||||
examPrep.Put("/lessons/:id", a.RequirePermission("exam_prep.lessons.update"), h.UpdateExamPrepLesson)
|
examPrep.Put("/lessons/:id", a.RequirePermission("exam_prep.lessons.update"), h.UpdateExamPrepLesson)
|
||||||
|
|
@ -157,6 +159,7 @@ func (a *App) initAppRoutes() {
|
||||||
groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice)
|
groupV1.Post("/practices", a.authMiddleware, a.RequirePermission("practices.create"), h.CreatePractice)
|
||||||
groupV1.Get("/practices/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.get"), h.GetPractice)
|
groupV1.Get("/practices/:id", a.authMiddleware, a.RequireSubscriptionCategory(domain.SubscriptionCategoryLearnEnglish), a.RequirePermission("practices.get"), h.GetPractice)
|
||||||
groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice)
|
groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice)
|
||||||
|
groupV1.Put("/practices/:id/full", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdateLmsPracticeFull)
|
||||||
groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
|
groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
|
||||||
|
|
||||||
// LMS personas (catalog referenced by persona_id on practices)
|
// LMS personas (catalog referenced by persona_id on practices)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user