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
|
||||
|
||||
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,
|
||||
|
|
|
|||
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"
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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.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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user