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:
parent
b5b9ef03b5
commit
bb03ee1668
|
|
@ -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` | — |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
20
internal/services/questions/service_test.go
Normal file
20
internal/services/questions/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user