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 <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-05 04:33:41 -07:00
parent b5b9ef03b5
commit bb03ee1668
7 changed files with 139 additions and 21 deletions

View File

@ -496,10 +496,45 @@ Persists a custom (non-system) definition. Enforces: valid kinds, schema IDs, an
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `status` | `string` | (all) | `ACTIVE` or `INACTIVE` | | `status` | `string` | (all) | `ACTIVE` or `INACTIVE` |
| `include_system` | `boolean` | `true` | Include seeded system definitions | | `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 | | Step | Action | API |
|------|--------|-----| |------|--------|-----|
| B1 | Choose mode **DYNAMIC** | — | | 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 slots **`label`** as field title and **`id`** as state key | `GET /questions/type-definitions/:id` | | B3 | Load schema; render form using each slots **`label`** as field title and **`id`** as state key | `GET /questions/type-definitions/:id` |
| B4 | Upload assets (image/pdf/audio) | `POST /files/upload` | | B4 | Upload assets (image/pdf/audio) | `POST /files/upload` |
| B5 | Build table/options in UI → `dynamic_payload` | — | | B5 | Build table/options in UI → `dynamic_payload` | — |

View File

@ -9,7 +9,7 @@ type QuestionStore interface {
// Question Type Definitions (dynamic builder presets) // Question Type Definitions (dynamic builder presets)
CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error) CreateQuestionTypeDefinition(ctx context.Context, input domain.CreateQuestionTypeDefinitionInput) (domain.QuestionTypeDefinition, error)
GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (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 UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error
DeleteQuestionTypeDefinition(ctx context.Context, id int64) error DeleteQuestionTypeDefinition(ctx context.Context, id int64) error

View File

@ -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 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) { func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool, limit, offset int32) ([]domain.QuestionTypeDefinition, int64, error) {
rows, err := s.conn.Query(ctx, ` const baseWhere = `
SELECT id, key, display_name, description, stimulus_component_kinds, response_component_kinds, stimulus_schema, response_schema, is_system, status, created_at, updated_at
FROM question_type_definitions FROM question_type_definitions
WHERE ($1::VARCHAR IS NULL OR status = $1) WHERE ($1::VARCHAR IS NULL OR status = $1)
AND ($2::BOOLEAN = TRUE OR is_system = FALSE) AND ($2::BOOLEAN = TRUE OR is_system = FALSE)`
ORDER BY is_system DESC, display_name ASC
`, status, includeSystem) 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 { if err != nil {
return nil, err return nil, 0, err
} }
defer rows.Close() defer rows.Close()
var out []domain.QuestionTypeDefinition out := make([]domain.QuestionTypeDefinition, 0)
for rows.Next() { for rows.Next() {
var ( var (
id int64 id int64
@ -398,12 +412,12 @@ func (s *Store) ListQuestionTypeDefinitions(ctx context.Context, status *string,
updatedAt pgtype.Timestamptz updatedAt pgtype.Timestamptz
) )
if err := rows.Scan(&id, &key, &displayName, &description, &stimulus, &response, &stimulusSch, &responseSch, &isSystem, &defStatus, &createdAt, &updatedAt); err != nil { 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)) 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 { func (s *Store) UpdateQuestionTypeDefinition(ctx context.Context, id int64, input domain.UpdateQuestionTypeDefinitionInput) error {

View File

@ -26,8 +26,22 @@ func (s *Service) GetQuestionTypeDefinitionByID(ctx context.Context, id int64) (
return s.questionStore.GetQuestionTypeDefinitionByID(ctx, id) return s.questionStore.GetQuestionTypeDefinitionByID(ctx, id)
} }
func (s *Service) ListQuestionTypeDefinitions(ctx context.Context, status *string, includeSystem bool) ([]domain.QuestionTypeDefinition, error) { func clampQuestionTypeDefinitionPage(limit, offset int32) (int32, int32) {
return s.questionStore.ListQuestionTypeDefinitions(ctx, status, includeSystem) 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 { 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 return domain.QuestionSetQuestionTypesSummary{}, err
} }
active := "ACTIVE" active := "ACTIVE"
catalog, err := s.questionStore.ListQuestionTypeDefinitions(ctx, &active, true) catalog, _, err := s.questionStore.ListQuestionTypeDefinitions(ctx, &active, true, 0, 0)
if err != nil { if err != nil {
return domain.QuestionSetQuestionTypesSummary{}, err return domain.QuestionSetQuestionTypesSummary{}, err
} }

View File

@ -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)
}
}

View File

@ -16,6 +16,13 @@ type componentCatalogRes struct {
ResponseKinds []string `json:"response_component_kinds"` 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 { type validateQuestionTypeDefinitionReq struct {
StimulusComponentKinds []string `json:"stimulus_component_kinds"` StimulusComponentKinds []string `json:"stimulus_component_kinds"`
ResponseComponentKinds []string `json:"response_component_kinds"` ResponseComponentKinds []string `json:"response_component_kinds"`
@ -167,8 +174,11 @@ func (h *Handler) CreateQuestionTypeDefinition(c *fiber.Ctx) error {
// @Tags questions // @Tags questions
// @Produce json // @Produce json
// @Param status query string false "Filter by status (ACTIVE, INACTIVE)" // @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 // @Success 200 {object} domain.Response
// @Failure 400 {object} domain.ErrorResponse
// @Failure 500 {object} domain.ErrorResponse // @Failure 500 {object} domain.ErrorResponse
// @Router /api/v1/questions/type-definitions [get] // @Router /api/v1/questions/type-definitions [get]
func (h *Handler) ListQuestionTypeDefinitions(c *fiber.Ctx) error { 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") 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 { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to list question type definitions", Message: "Failed to list question type definitions",
Error: err.Error(), Error: err.Error(),
}) })
} }
if defs == nil {
defs = []domain.QuestionTypeDefinition{}
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Question type definitions", Message: "Question type definitions",
Data: defs, Data: listQuestionTypeDefinitionsData{
QuestionTypeDefinitions: defs,
TotalCount: total,
Limit: limit,
Offset: offset,
},
Success: true,
StatusCode: fiber.StatusOK,
}) })
} }

View File

@ -228,7 +228,7 @@
"method": "GET", "method": "GET",
"header": [], "header": [],
"url": { "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": [ "host": [
"{{base_url}}" "{{base_url}}"
], ],