From 2605877f1236570af301c8fcef492165190aa914 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Tue, 2 Jun 2026 06:46:54 -0700 Subject: [PATCH] Resolve question-set question-types to builder definitions. Map legacy runtime types like AUDIO to catalog keys (e.g. audio_conversation_type) so the endpoint matches type-definitions API output. Co-authored-by: Cursor --- db/query/question_set_items.sql | 3 +- gen/db/question_set_items.sql.go | 10 +- internal/domain/question_type_builder.go | 124 ++++++++++++++++++ internal/domain/question_type_builder_test.go | 52 ++++++++ internal/domain/questions.go | 15 ++- internal/ports/questions.go | 2 +- internal/repository/questions.go | 11 +- internal/services/questions/service.go | 8 +- internal/web_server/handlers/questions.go | 2 +- 9 files changed, 211 insertions(+), 16 deletions(-) diff --git a/db/query/question_set_items.sql b/db/query/question_set_items.sql index 0e217cc..4294a11 100644 --- a/db/query/question_set_items.sql +++ b/db/query/question_set_items.sql @@ -104,13 +104,14 @@ WHERE qsi.set_id = $1 -- name: GetQuestionTypeCountsInSet :many SELECT + q.question_type_definition_id, q.question_type, COUNT(*)::bigint AS question_count FROM question_set_items qsi JOIN questions q ON q.id = qsi.question_id WHERE qsi.set_id = $1 AND q.status != 'ARCHIVED' -GROUP BY q.question_type +GROUP BY q.question_type_definition_id, q.question_type ORDER BY q.question_type; -- name: GetQuestionSetsContainingQuestion :many diff --git a/gen/db/question_set_items.sql.go b/gen/db/question_set_items.sql.go index d71aa8d..a9f593c 100644 --- a/gen/db/question_set_items.sql.go +++ b/gen/db/question_set_items.sql.go @@ -364,19 +364,21 @@ func (q *Queries) GetQuestionSetsContainingQuestion(ctx context.Context, questio const GetQuestionTypeCountsInSet = `-- name: GetQuestionTypeCountsInSet :many SELECT + q.question_type_definition_id, q.question_type, COUNT(*)::bigint AS question_count FROM question_set_items qsi JOIN questions q ON q.id = qsi.question_id WHERE qsi.set_id = $1 AND q.status != 'ARCHIVED' -GROUP BY q.question_type +GROUP BY q.question_type_definition_id, q.question_type ORDER BY q.question_type ` type GetQuestionTypeCountsInSetRow struct { - QuestionType string `json:"question_type"` - QuestionCount int64 `json:"question_count"` + QuestionTypeDefinitionID pgtype.Int8 `json:"question_type_definition_id"` + QuestionType string `json:"question_type"` + QuestionCount int64 `json:"question_count"` } func (q *Queries) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]GetQuestionTypeCountsInSetRow, error) { @@ -388,7 +390,7 @@ func (q *Queries) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ( var items []GetQuestionTypeCountsInSetRow for rows.Next() { var i GetQuestionTypeCountsInSetRow - if err := rows.Scan(&i.QuestionType, &i.QuestionCount); err != nil { + if err := rows.Scan(&i.QuestionTypeDefinitionID, &i.QuestionType, &i.QuestionCount); err != nil { return nil, err } items = append(items, i) diff --git a/internal/domain/question_type_builder.go b/internal/domain/question_type_builder.go index fd95582..857b5d7 100644 --- a/internal/domain/question_type_builder.go +++ b/internal/domain/question_type_builder.go @@ -213,6 +213,130 @@ func ValidateDynamicQuestionTypeDefinition(stimulusKinds, responseKinds []string return fmt.Errorf("%s", strings.Join(errs, "; ")) } +var legacyRuntimeToDefinitionKey = map[string]string{ + "MCQ": "multiple_choice", + "TRUE_FALSE": "true_false", + "SHORT_ANSWER": "short_answer", +} + +// ResolveQuestionTypeDefinitionForQuestion maps a stored question row to its builder definition. +// Legacy rows without question_type_definition_id are matched via runtime question_type and the catalog. +func ResolveQuestionTypeDefinitionForQuestion(definitionID *int64, runtimeQuestionType string, catalog []QuestionTypeDefinition) QuestionTypeDefinition { + byID := make(map[int64]QuestionTypeDefinition, len(catalog)) + byKey := make(map[string]QuestionTypeDefinition, len(catalog)) + for _, d := range catalog { + byID[d.ID] = d + byKey[d.Key] = d + } + + if definitionID != nil && *definitionID > 0 { + if d, ok := byID[*definitionID]; ok { + return d + } + } + + runtime := strings.ToUpper(strings.TrimSpace(runtimeQuestionType)) + if key, ok := legacyRuntimeToDefinitionKey[runtime]; ok { + if d, ok := byKey[key]; ok { + return d + } + return QuestionTypeDefinition{Key: key, DisplayName: humanizeDefinitionKey(key)} + } + + switch runtime { + case "AUDIO": + return resolveDefinitionForRuntimeAUDIO(catalog) + case "DYNAMIC": + return QuestionTypeDefinition{Key: "dynamic", DisplayName: "Dynamic"} + default: + if runtime == "" { + return QuestionTypeDefinition{Key: "unknown", DisplayName: "Unknown"} + } + key := strings.ToLower(runtime) + if d, ok := byKey[key]; ok { + return d + } + return QuestionTypeDefinition{Key: key, DisplayName: runtime} + } +} + +func resolveDefinitionForRuntimeAUDIO(catalog []QuestionTypeDefinition) QuestionTypeDefinition { + var candidates []QuestionTypeDefinition + for _, d := range catalog { + if ResolveRuntimeQuestionTypeFromDefinition(d.Key, d.ResponseComponentKinds) == "AUDIO" { + candidates = append(candidates, d) + } + } + if len(candidates) == 0 { + return QuestionTypeDefinition{Key: "audio", DisplayName: "Audio"} + } + for _, d := range candidates { + if d.Key == "audio_conversation_type" { + return d + } + } + if len(candidates) == 1 { + return candidates[0] + } + for _, d := range candidates { + if !d.IsSystem { + return d + } + } + return candidates[0] +} + +func humanizeDefinitionKey(key string) string { + parts := strings.Split(strings.ReplaceAll(key, "-", "_"), "_") + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:]) + } + return strings.Join(parts, " ") +} + +// SummarizeQuestionSetQuestionTypes merges raw DB groups into definition-keyed counts for API responses. +func SummarizeQuestionSetQuestionTypes(groups []QuestionSetQuestionTypeGroup, catalog []QuestionTypeDefinition) []QuestionSetQuestionTypeCount { + merged := make(map[string]*QuestionSetQuestionTypeCount) + for _, g := range groups { + def := ResolveQuestionTypeDefinitionForQuestion(g.QuestionTypeDefinitionID, g.QuestionType, catalog) + entry, ok := merged[def.Key] + if !ok { + var defID *int64 + if def.ID > 0 { + id := def.ID + defID = &id + } + merged[def.Key] = &QuestionSetQuestionTypeCount{ + QuestionTypeDefinitionID: defID, + Key: def.Key, + DisplayName: def.DisplayName, + Count: g.Count, + } + continue + } + entry.Count += g.Count + } + + out := make([]QuestionSetQuestionTypeCount, 0, len(merged)) + for _, v := range merged { + out = append(out, *v) + } + sortQuestionSetQuestionTypeCounts(out) + return out +} + +func sortQuestionSetQuestionTypeCounts(counts []QuestionSetQuestionTypeCount) { + sort.Slice(counts, func(i, j int) bool { + if counts[i].DisplayName == counts[j].DisplayName { + return counts[i].Key < counts[j].Key + } + return counts[i].DisplayName < counts[j].DisplayName + }) +} + // ResolveRuntimeQuestionTypeFromDefinition derives the legacy runtime question_type code used by // existing question execution paths. Empty string means the definition cannot be executed yet. func ResolveRuntimeQuestionTypeFromDefinition(key string, responseKinds []string) string { diff --git a/internal/domain/question_type_builder_test.go b/internal/domain/question_type_builder_test.go index c105433..2259708 100644 --- a/internal/domain/question_type_builder_test.go +++ b/internal/domain/question_type_builder_test.go @@ -139,3 +139,55 @@ func TestValidateDynamicPayloadAgainstDefinition_requiredMissing(t *testing.T) { t.Fatalf("expected required element error, got %v", err) } } + +func testQuestionTypeCatalog() []QuestionTypeDefinition { + return []QuestionTypeDefinition{ + {ID: 1, Key: "multiple_choice", DisplayName: "Multiple Choice", IsSystem: true}, + {ID: 2, Key: "true_false", DisplayName: "True / False", IsSystem: true}, + {ID: 3, Key: "fill_in_the_blank", DisplayName: "Fill In The Blank", IsSystem: true}, + {ID: 4, Key: "short_answer", DisplayName: "Short Answer", IsSystem: true}, + { + ID: 10, + Key: "audio_conversation_type", + DisplayName: "Audio Conversation Type", + ResponseComponentKinds: []string{"AUDIO_RESPONSE", "TEXT_INPUT"}, + }, + } +} + +func TestResolveQuestionTypeDefinitionForQuestion_legacyMCQ(t *testing.T) { + def := ResolveQuestionTypeDefinitionForQuestion(nil, "MCQ", testQuestionTypeCatalog()) + if def.Key != "multiple_choice" { + t.Fatalf("expected multiple_choice, got %q", def.Key) + } +} + +func TestResolveQuestionTypeDefinitionForQuestion_legacyAUDIO(t *testing.T) { + def := ResolveQuestionTypeDefinitionForQuestion(nil, "AUDIO", testQuestionTypeCatalog()) + if def.Key != "audio_conversation_type" { + t.Fatalf("expected audio_conversation_type, got %q", def.Key) + } +} + +func TestResolveQuestionTypeDefinitionForQuestion_linkedDefinition(t *testing.T) { + id := int64(10) + def := ResolveQuestionTypeDefinitionForQuestion(&id, "DYNAMIC", testQuestionTypeCatalog()) + if def.Key != "audio_conversation_type" { + t.Fatalf("expected audio_conversation_type, got %q", def.Key) + } +} + +func TestSummarizeQuestionSetQuestionTypes_mergesLegacyAndLinked(t *testing.T) { + id := int64(10) + summary := SummarizeQuestionSetQuestionTypes([]QuestionSetQuestionTypeGroup{ + {QuestionType: "AUDIO", Count: 7}, + {QuestionTypeDefinitionID: &id, QuestionType: "DYNAMIC", Count: 3}, + }, testQuestionTypeCatalog()) + + if len(summary) != 1 { + t.Fatalf("expected 1 merged entry, got %d", len(summary)) + } + if summary[0].Key != "audio_conversation_type" || summary[0].Count != 10 { + t.Fatalf("unexpected summary: %+v", summary[0]) + } +} diff --git a/internal/domain/questions.go b/internal/domain/questions.go index b14db94..eeff16c 100644 --- a/internal/domain/questions.go +++ b/internal/domain/questions.go @@ -120,10 +120,19 @@ type QuestionSetItem struct { CreatedAt time.Time } -// QuestionSetQuestionTypeCount is one question_type present in a set with how many questions use it. +// QuestionSetQuestionTypeGroup is a raw DB aggregate before resolving builder definitions. +type QuestionSetQuestionTypeGroup struct { + QuestionTypeDefinitionID *int64 + QuestionType string + Count int64 +} + +// QuestionSetQuestionTypeCount is one builder question type present in a set with how many questions use it. type QuestionSetQuestionTypeCount struct { - QuestionType string `json:"question_type"` - Count int64 `json:"count"` + QuestionTypeDefinitionID *int64 `json:"question_type_definition_id,omitempty"` + Key string `json:"key"` + DisplayName string `json:"display_name"` + Count int64 `json:"count"` } // QuestionSetQuestionTypesSummary summarizes distinct question types in a question set (e.g. linked practice). diff --git a/internal/ports/questions.go b/internal/ports/questions.go index 76b0ce3..aa582f7 100644 --- a/internal/ports/questions.go +++ b/internal/ports/questions.go @@ -58,7 +58,7 @@ type QuestionStore interface { RemoveQuestionFromSet(ctx context.Context, setID, questionID int64) error UpdateQuestionOrder(ctx context.Context, setID, questionID int64, displayOrder int32) error CountQuestionsInSet(ctx context.Context, setID int64) (int64, error) - GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeCount, error) + GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeGroup, error) GetQuestionSetsContainingQuestion(ctx context.Context, questionID int64) ([]domain.QuestionSet, error) // User Personas in Question Sets diff --git a/internal/repository/questions.go b/internal/repository/questions.go index 8097a2b..caddc64 100644 --- a/internal/repository/questions.go +++ b/internal/repository/questions.go @@ -1226,16 +1226,17 @@ func (s *Store) CountQuestionsInSet(ctx context.Context, setID int64) (int64, er return s.queries.CountQuestionsInSet(ctx, setID) } -func (s *Store) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeCount, error) { +func (s *Store) GetQuestionTypeCountsInSet(ctx context.Context, setID int64) ([]domain.QuestionSetQuestionTypeGroup, error) { rows, err := s.queries.GetQuestionTypeCountsInSet(ctx, setID) if err != nil { return nil, err } - result := make([]domain.QuestionSetQuestionTypeCount, len(rows)) + result := make([]domain.QuestionSetQuestionTypeGroup, len(rows)) for i, r := range rows { - result[i] = domain.QuestionSetQuestionTypeCount{ - QuestionType: r.QuestionType, - Count: r.QuestionCount, + result[i] = domain.QuestionSetQuestionTypeGroup{ + QuestionTypeDefinitionID: fromPgInt8(r.QuestionTypeDefinitionID), + QuestionType: r.QuestionType, + Count: r.QuestionCount, } } return result, nil diff --git a/internal/services/questions/service.go b/internal/services/questions/service.go index a56356f..5ea441f 100644 --- a/internal/services/questions/service.go +++ b/internal/services/questions/service.go @@ -193,10 +193,16 @@ func (s *Service) CountQuestionsInSet(ctx context.Context, setID int64) (int64, } func (s *Service) GetQuestionTypesInSet(ctx context.Context, setID int64) (domain.QuestionSetQuestionTypesSummary, error) { - counts, err := s.questionStore.GetQuestionTypeCountsInSet(ctx, setID) + groups, err := s.questionStore.GetQuestionTypeCountsInSet(ctx, setID) if err != nil { return domain.QuestionSetQuestionTypesSummary{}, err } + active := "ACTIVE" + catalog, err := s.questionStore.ListQuestionTypeDefinitions(ctx, &active, true) + if err != nil { + return domain.QuestionSetQuestionTypesSummary{}, err + } + counts := domain.SummarizeQuestionSetQuestionTypes(groups, catalog) var total int64 for _, c := range counts { total += c.Count diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 4f0cd50..cbf7f08 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -1324,7 +1324,7 @@ func (h *Handler) AddQuestionToSet(c *fiber.Ctx) error { // GetQuestionTypesInSet godoc // @Summary List question types in a question set -// @Description Returns distinct question_type values (with counts) for non-archived questions linked to the set. Use the question_set_id from a practice to see which types were used to build it. +// @Description Returns distinct question type definitions (key, display_name, counts) for non-archived questions in the set. Legacy stored question_type values (e.g. AUDIO) are resolved to builder definitions when possible. // @Tags question-set-items // @Produce json // @Param setId path int true "Question Set ID"