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:
Yared Yemane 2026-06-08 02:43:34 -07:00
parent 55df6b8b0b
commit 22464479ae
10 changed files with 908 additions and 7 deletions

View File

@ -1,9 +1,6 @@
package main
import (
// "context"
// "context"
_ "Yimaru-Backend/docs"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/config"
@ -31,6 +28,7 @@ import (
moduleservice "Yimaru-Backend/internal/services/modules"
notificationservice "Yimaru-Backend/internal/services/notification"
personasservice "Yimaru-Backend/internal/services/personas"
practicecontentservice "Yimaru-Backend/internal/services/practicecontent"
practicesservice "Yimaru-Backend/internal/services/practices"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
programsservice "Yimaru-Backend/internal/services/programs"
@ -305,6 +303,7 @@ func main() {
// LMS practices (under course, module, or lesson)
practiceSvc := practicesservice.NewService(store, store, store, store, store, store)
practiceContentSvc := practicecontentservice.NewService(store, store, store, store)
// Subscriptions service
subscriptionsSvc := subscriptions.NewService(store)
@ -370,6 +369,7 @@ func main() {
lessonSvc,
lmsProgressSvc,
practiceSvc,
practiceContentSvc,
subscriptionsSvc,
arifpaySvc,
chapaSvc,

View 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"`
}

View 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")
)

View 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
}

View 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
}

View 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 }

View File

@ -21,6 +21,7 @@ import (
"Yimaru-Backend/internal/services/modules"
notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/personas"
practicecontentservice "Yimaru-Backend/internal/services/practicecontent"
"Yimaru-Backend/internal/services/practices"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
"Yimaru-Backend/internal/services/programs"
@ -64,7 +65,8 @@ type App struct {
moduleSvc *modules.Service
lessonSvc *lessons.Service
lmsProgressSvc *lmsprogress.Service
practiceSvc *practices.Service
practiceSvc *practices.Service
practiceContentSvc *practicecontentservice.Service
subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService
chapaSvc *chapa.Service
@ -110,6 +112,7 @@ func NewApp(
lessonSvc *lessons.Service,
lmsProgressSvc *lmsprogress.Service,
practiceSvc *practices.Service,
practiceContentSvc *practicecontentservice.Service,
subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService,
chapaSvc *chapa.Service,
@ -166,7 +169,8 @@ func NewApp(
moduleSvc: moduleSvc,
lessonSvc: lessonSvc,
lmsProgressSvc: lmsProgressSvc,
practiceSvc: practiceSvc,
practiceSvc: practiceSvc,
practiceContentSvc: practiceContentSvc,
subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc,
chapaSvc: chapaSvc,

View File

@ -24,6 +24,7 @@ import (
"Yimaru-Backend/internal/services/modules"
notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/personas"
practicecontentservice "Yimaru-Backend/internal/services/practicecontent"
"Yimaru-Backend/internal/services/practices"
profilefieldoptions "Yimaru-Backend/internal/services/profilefieldoptions"
"Yimaru-Backend/internal/services/programs"
@ -63,7 +64,8 @@ type Handler struct {
moduleSvc *modules.Service
lessonSvc *lessons.Service
lmsProgressSvc *lmsprogress.Service
practiceSvc *practices.Service
practiceSvc *practices.Service
practiceContentSvc *practicecontentservice.Service
subscriptionsSvc *subscriptions.Service
arifpaySvc *arifpay.ArifpayService
chapaSvc *chapa.Service
@ -105,6 +107,7 @@ func New(
lessonSvc *lessons.Service,
lmsProgressSvc *lmsprogress.Service,
practiceSvc *practices.Service,
practiceContentSvc *practicecontentservice.Service,
subscriptionsSvc *subscriptions.Service,
arifpaySvc *arifpay.ArifpayService,
chapaSvc *chapa.Service,
@ -144,7 +147,8 @@ func New(
moduleSvc: moduleSvc,
lessonSvc: lessonSvc,
lmsProgressSvc: lmsProgressSvc,
practiceSvc: practiceSvc,
practiceSvc: practiceSvc,
practiceContentSvc: practiceContentSvc,
subscriptionsSvc: subscriptionsSvc,
arifpaySvc: arifpaySvc,
chapaSvc: chapaSvc,

View 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,
})
}

View File

@ -27,6 +27,7 @@ func (a *App) initAppRoutes() {
a.lessonSvc,
a.lmsProgressSvc,
a.practiceSvc,
a.practiceContentSvc,
a.subscriptionsSvc,
a.arifpaySvc,
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("/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/full", a.RequirePermission("exam_prep.practices.update"), h.UpdateExamPrepPracticeFull)
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.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.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/full", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdateLmsPracticeFull)
groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
// LMS personas (catalog referenced by persona_id on practices)