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` |
|
| `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 slot’s **`label`** as field title and **`id`** as state key | `GET /questions/type-definitions/:id` |
|
| 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` |
|
| 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` | — |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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"`
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}}"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user