From bb03ee166803fbd4f77dd02b652d9f8e6aed7d00 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Fri, 5 Jun 2026 04:33:41 -0700 Subject: [PATCH] feat: paginate question type definitions list API Add limit and offset query params to GET /questions/type-definitions with total_count metadata. Update integration docs, Postman collection, and page clamping tests. Co-authored-by: Cursor --- ...QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md | 41 +++++++++++++++++-- internal/ports/questions.go | 2 +- internal/repository/questions.go | 34 ++++++++++----- internal/services/questions/service.go | 20 +++++++-- internal/services/questions/service_test.go | 20 +++++++++ .../handlers/question_type_builder.go | 41 +++++++++++++++++-- ...stion-Type-Builder.postman_collection.json | 2 +- 7 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 internal/services/questions/service_test.go diff --git a/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md b/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md index eb87790..46d05db 100644 --- a/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md +++ b/docs/DYNAMIC_QUESTION_TYPE_BUILDER_ADMIN_INTEGRATION.md @@ -496,10 +496,45 @@ Persists a custom (non-system) definition. Enforces: valid kinds, schema IDs, an |-------|------|---------|-------------| | `status` | `string` | (all) | `ACTIVE` or `INACTIVE` | | `include_system` | `boolean` | `true` | Include seeded system definitions | +| `limit` | `int` | `20` | Page size (max `200`) | +| `offset` | `int` | `0` | Page offset | -**Example:** `GET /questions/type-definitions?include_system=true&status=ACTIVE` +**Example:** `GET /questions/type-definitions?include_system=true&status=ACTIVE&limit=20&offset=0` -**Success `200` — `data`:** array of `QuestionTypeDefinition` (same shape as create response). +**Success `200` — `data`:** + +```json +{ + "question_type_definitions": [ + { + "id": 42, + "key": "dynamic_visual_mcq_v1", + "display_name": "Dynamic Visual MCQ", + "stimulus_component_kinds": ["QUESTION_TEXT", "OPTION"], + "response_component_kinds": ["OPTION"], + "stimulus_schema": [], + "response_schema": [], + "is_system": false, + "status": "ACTIVE", + "created_at": "2026-06-04T10:00:00Z" + } + ], + "total_count": 1, + "limit": 20, + "offset": 0 +} +``` + +Each item in `question_type_definitions` is a full `QuestionTypeDefinition` (same shape as create response). + +**Error `400` (invalid pagination):** + +```json +{ + "message": "Invalid limit", + "error": "strconv.Atoi: parsing \"x\": invalid syntax" +} +``` --- @@ -1064,7 +1099,7 @@ Server does not yet enforce `config.max_rows` / `max_columns`; mirror in UI. | Step | Action | API | |------|--------|-----| | B1 | Choose mode **DYNAMIC** | — | -| B2 | Pick definition | `GET /questions/type-definitions?status=ACTIVE` | +| B2 | Pick definition (paginated list) | `GET /questions/type-definitions?status=ACTIVE&limit=20&offset=0` | | B3 | Load schema; render form using each slot’s **`label`** as field title and **`id`** as state key | `GET /questions/type-definitions/:id` | | B4 | Upload assets (image/pdf/audio) | `POST /files/upload` | | B5 | Build table/options in UI → `dynamic_payload` | — | diff --git a/internal/ports/questions.go b/internal/ports/questions.go index aa582f7..b930c59 100644 --- a/internal/ports/questions.go +++ b/internal/ports/questions.go @@ -9,7 +9,7 @@ type QuestionStore interface { // Question Type Definitions (dynamic builder presets) CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (domain.QuestionTypeDefinition, error) - ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) + ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool, limit, offset int32) ([]domain.QuestionTypeDefinition, int64, error) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error DeleteQuestionTypeDefinition(ctx context.Context, id int64) error diff --git a/internal/repository/questions.go b/internal/repository/questions.go index caddc64..4bebecd 100644 --- a/internal/repository/questions.go +++ b/internal/repository/questions.go @@ -368,20 +368,34 @@ func (s *Store) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (do return questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, status, createdAt, updatedAt), nil } -func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) { - rows, err := s.conn.Query(ctx, ` - SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at +func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool, limit, offset int32) ([]domain.QuestionTypeDefinition, int64, error) { + const baseWhere = ` FROM question_type_definitions WHERE ($1::VARCHAR IS NULL OR status = $1) - AND ($2::BOOLEAN = TRUE OR is_system = FALSE) - ORDER BY is_system DESC, display_name ASC - `, status, includeSystem) + AND ($2::BOOLEAN = TRUE OR is_system = FALSE)` + + var total int64 + if err := s.conn.QueryRow(ctx, `SELECT COUNT(*)`+baseWhere, status, includeSystem).Scan(&total); err != nil { + return nil, 0, err + } + + listSQL := ` + SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at` + baseWhere + ` + ORDER BY is_system DESC, display_name ASC` + + args := []interface{}{status, includeSystem} + if limit > 0 { + listSQL += ` LIMIT $3 OFFSET $4` + args = append(args, limit, offset) + } + + rows, err := s.conn.Query(ctx, listSQL, args...) if err != nil { - return nil, err + return nil, 0, err } defer rows.Close() - var out []domain.QuestionTypeDefinition + out := make([]domain.QuestionTypeDefinition, 0) for rows.Next() { var ( id int64 @@ -398,12 +412,12 @@ func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, updatedAt pgtype.Timestamptz ) if err := rows.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &defStatus, &createdAt, &updatedAt); err != nil { - return nil, err + return nil, 0, err } out = append(out, questionTypeDefinitionToDomain(id, key, displayName, description, stimulus, response, stimulusSch, responseSch, isSystem, defStatus, createdAt, updatedAt)) } - return out, rows.Err() + return out, total, rows.Err() } func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error { diff --git a/internal/services/questions/service.go b/internal/services/questions/service.go index 5ea441f..e5ec71c 100644 --- a/internal/services/questions/service.go +++ b/internal/services/questions/service.go @@ -26,8 +26,22 @@ func (s *Service) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) ( return s.questionStore.GetQuestionTypeDefinitionByID(ctx, id) } -func (s *Service) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) { - return s.questionStore.ListQuestionTypeDefinitions(ctx, status, includeSystem) +func clampQuestionTypeDefinitionPage(limit, offset int32) (int32, int32) { + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + if offset < 0 { + offset = 0 + } + return limit, offset +} + +func (s *Service) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool, limit, offset int32) ([]domain.QuestionTypeDefinition, int64, error) { + limit, offset = clampQuestionTypeDefinitionPage(limit, offset) + return s.questionStore.ListQuestionTypeDefinitions(ctx, status, includeSystem, limit, offset) } func (s *Service) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error { @@ -198,7 +212,7 @@ func (s *Service) GetQuestionTypesInSet(ctx context.Context, setID int64) (domai return domain.QuestionSetQuestionTypesSummary{}, err } active := "ACTIVE" - catalog, err := s.questionStore.ListQuestionTypeDefinitions(ctx, &active, true) + catalog, _, err := s.questionStore.ListQuestionTypeDefinitions(ctx, &active, true, 0, 0) if err != nil { return domain.QuestionSetQuestionTypesSummary{}, err } diff --git a/internal/services/questions/service_test.go b/internal/services/questions/service_test.go new file mode 100644 index 0000000..f998826 --- /dev/null +++ b/internal/services/questions/service_test.go @@ -0,0 +1,20 @@ +package questions + +import "testing" + +func TestClampQuestionTypeDefinitionPage_defaultsAndCaps(t *testing.T) { + limit, offset := clampQuestionTypeDefinitionPage(0, -5) + if limit != 20 || offset != 0 { + t.Fatalf("expected default limit=20 offset=0, got limit=%d offset=%d", limit, offset) + } + + limit, offset = clampQuestionTypeDefinitionPage(500, 10) + if limit != 200 || offset != 10 { + t.Fatalf("expected capped limit=200 offset=10, got limit=%d offset=%d", limit, offset) + } + + limit, offset = clampQuestionTypeDefinitionPage(50, 25) + if limit != 50 || offset != 25 { + t.Fatalf("expected limit=50 offset=25, got limit=%d offset=%d", limit, offset) + } +} diff --git a/internal/web_server/handlers/question_type_builder.go b/internal/web_server/handlers/question_type_builder.go index d1a5af2..5fba02f 100644 --- a/internal/web_server/handlers/question_type_builder.go +++ b/internal/web_server/handlers/question_type_builder.go @@ -16,6 +16,13 @@ type componentCatalogRes struct { ResponseKinds []string `json:"response_component_kinds"` } +type listQuestionTypeDefinitionsData struct { + QuestionTypeDefinitions []domain.QuestionTypeDefinition `json:"question_type_definitions"` + TotalCount int64 `json:"total_count"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + type validateQuestionTypeDefinitionReq struct { StimulusComponentKinds []string `json:"stimulus_component_kinds"` ResponseComponentKinds []string `json:"response_component_kinds"` @@ -167,8 +174,11 @@ func (h *Handler) CreateQuestionTypeDefinition(c *fiber.Ctx) error { // @Tags questions // @Produce json // @Param status query string false "Filter by status (ACTIVE, INACTIVE)" -// @Param include_system query bool false "Include system seeded definitions" +// @Param include_system query bool false "Include system seeded definitions" default(true) +// @Param limit query int false "Page size (default 20, max 200)" default(20) +// @Param offset query int false "Page offset" default(0) // @Success 200 {object} domain.Response +// @Failure 400 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/questions/type-definitions [get] func (h *Handler) ListQuestionTypeDefinitions(c *fiber.Ctx) error { @@ -179,17 +189,42 @@ func (h *Handler) ListQuestionTypeDefinitions(c *fiber.Ctx) error { } includeSystem := strings.EqualFold(c.Query("include_system", "true"), "true") - defs, err := h.questionsSvc.ListQuestionTypeDefinitions(c.Context(), statusPtr, includeSystem) + limit, err := strconv.Atoi(c.Query("limit", "20")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid limit", + Error: err.Error(), + }) + } + offset, err := strconv.Atoi(c.Query("offset", "0")) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ + Message: "Invalid offset", + Error: err.Error(), + }) + } + + defs, total, err := h.questionsSvc.ListQuestionTypeDefinitions(c.Context(), statusPtr, includeSystem, int32(limit), int32(offset)) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ Message: "Failed to list question type definitions", Error: err.Error(), }) } + if defs == nil { + defs = []domain.QuestionTypeDefinition{} + } return c.JSON(domain.Response{ Message: "Question type definitions", - Data: defs, + Data: listQuestionTypeDefinitionsData{ + QuestionTypeDefinitions: defs, + TotalCount: total, + Limit: limit, + Offset: offset, + }, + Success: true, + StatusCode: fiber.StatusOK, }) } diff --git a/postman/Dynamic-Question-Type-Builder.postman_collection.json b/postman/Dynamic-Question-Type-Builder.postman_collection.json index ef44a87..0eb49c3 100644 --- a/postman/Dynamic-Question-Type-Builder.postman_collection.json +++ b/postman/Dynamic-Question-Type-Builder.postman_collection.json @@ -228,7 +228,7 @@ "method": "GET", "header": [], "url": { - "raw": "{{base_url}}/api/v1/questions/type-definitions?include_system=true&status=ACTIVE", + "raw": "{{base_url}}/api/v1/questions/type-definitions?include_system=true&status=ACTIVE&limit=20&offset=0", "host": [ "{{base_url}}" ],