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` |
| `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 slots **`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` | — |

View File

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

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

View File

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

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"`
}
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,
})
}

View File

@ -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}}"
],