Add optional gender to LMS personas.

Migration 000065 adds nullable gender text column; persona API and Postman expose it alongside profile_picture.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-20 06:37:21 -07:00
parent 9ff418247f
commit 5399d33af6
12 changed files with 91 additions and 34 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE lms_personas
DROP COLUMN IF EXISTS gender;

View File

@ -0,0 +1,2 @@
ALTER TABLE lms_personas
ADD COLUMN gender TEXT;

View File

@ -1,6 +1,6 @@
-- name: CreateLmsPersona :one
INSERT INTO lms_personas (name, description, profile_picture, is_active)
VALUES ($1, $2, $3, $4)
INSERT INTO lms_personas (name, description, profile_picture, gender, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: GetLmsPersonaByID :one
@ -14,6 +14,7 @@ SET
name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description),
profile_picture = COALESCE(sqlc.narg('profile_picture')::text, profile_picture),
gender = COALESCE(sqlc.narg('gender')::text, gender),
is_active = COALESCE(sqlc.narg('is_active')::boolean, is_active),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
@ -30,6 +31,7 @@ SELECT
p.name,
p.description,
p.profile_picture,
p.gender,
p.is_active,
p.created_at,
p.updated_at

View File

@ -10644,6 +10644,9 @@ const docTemplate = `{
"description": {
"type": "string"
},
"gender": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
@ -11584,6 +11587,9 @@ const docTemplate = `{
"description": {
"type": "string"
},
"gender": {
"type": "string"
},
"is_active": {
"type": "boolean"
},

View File

@ -10636,6 +10636,9 @@
"description": {
"type": "string"
},
"gender": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
@ -11576,6 +11579,9 @@
"description": {
"type": "string"
},
"gender": {
"type": "string"
},
"is_active": {
"type": "boolean"
},

View File

@ -451,6 +451,8 @@ definitions:
properties:
description:
type: string
gender:
type: string
is_active:
type: boolean
name:
@ -1098,6 +1100,8 @@ definitions:
properties:
description:
type: string
gender:
type: string
is_active:
type: boolean
name:

View File

@ -12,15 +12,16 @@ import (
)
const CreateLmsPersona = `-- name: CreateLmsPersona :one
INSERT INTO lms_personas (name, description, profile_picture, is_active)
VALUES ($1, $2, $3, $4)
RETURNING id, name, description, profile_picture, is_active, created_at, updated_at
INSERT INTO lms_personas (name, description, profile_picture, gender, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, description, profile_picture, is_active, created_at, updated_at, gender
`
type CreateLmsPersonaParams struct {
Name string `json:"name"`
Description pgtype.Text `json:"description"`
ProfilePicture pgtype.Text `json:"profile_picture"`
Gender pgtype.Text `json:"gender"`
IsActive bool `json:"is_active"`
}
@ -29,6 +30,7 @@ func (q *Queries) CreateLmsPersona(ctx context.Context, arg CreateLmsPersonaPara
arg.Name,
arg.Description,
arg.ProfilePicture,
arg.Gender,
arg.IsActive,
)
var i LmsPersona
@ -40,6 +42,7 @@ func (q *Queries) CreateLmsPersona(ctx context.Context, arg CreateLmsPersonaPara
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
&i.Gender,
)
return i, err
}
@ -55,7 +58,7 @@ func (q *Queries) DeleteLmsPersona(ctx context.Context, id int64) error {
}
const GetLmsPersonaByID = `-- name: GetLmsPersonaByID :one
SELECT id, name, description, profile_picture, is_active, created_at, updated_at
SELECT id, name, description, profile_picture, is_active, created_at, updated_at, gender
FROM lms_personas
WHERE id = $1
`
@ -71,6 +74,7 @@ func (q *Queries) GetLmsPersonaByID(ctx context.Context, id int64) (LmsPersona,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
&i.Gender,
)
return i, err
}
@ -82,6 +86,7 @@ SELECT
p.name,
p.description,
p.profile_picture,
p.gender,
p.is_active,
p.created_at,
p.updated_at
@ -106,6 +111,7 @@ type ListLmsPersonasRow struct {
Name string `json:"name"`
Description pgtype.Text `json:"description"`
ProfilePicture pgtype.Text `json:"profile_picture"`
Gender pgtype.Text `json:"gender"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
@ -126,6 +132,7 @@ func (q *Queries) ListLmsPersonas(ctx context.Context, arg ListLmsPersonasParams
&i.Name,
&i.Description,
&i.ProfilePicture,
&i.Gender,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
@ -146,16 +153,18 @@ SET
name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description),
profile_picture = COALESCE($3::text, profile_picture),
is_active = COALESCE($4::boolean, is_active),
gender = COALESCE($4::text, gender),
is_active = COALESCE($5::boolean, is_active),
updated_at = CURRENT_TIMESTAMP
WHERE id = $5
RETURNING id, name, description, profile_picture, is_active, created_at, updated_at
WHERE id = $6
RETURNING id, name, description, profile_picture, is_active, created_at, updated_at, gender
`
type UpdateLmsPersonaParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
ProfilePicture pgtype.Text `json:"profile_picture"`
Gender pgtype.Text `json:"gender"`
IsActive pgtype.Bool `json:"is_active"`
ID int64 `json:"id"`
}
@ -165,6 +174,7 @@ func (q *Queries) UpdateLmsPersona(ctx context.Context, arg UpdateLmsPersonaPara
arg.Name,
arg.Description,
arg.ProfilePicture,
arg.Gender,
arg.IsActive,
arg.ID,
)
@ -177,6 +187,7 @@ func (q *Queries) UpdateLmsPersona(ctx context.Context, arg UpdateLmsPersonaPara
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
&i.Gender,
)
return i, err
}

View File

@ -146,6 +146,7 @@ type LmsPersona struct {
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Gender pgtype.Text `json:"gender"`
}
type LmsPractice struct {

View File

@ -15,6 +15,8 @@ type LmsPersona struct {
Description *string `json:"description,omitempty"`
// ProfilePicture is always serialized (null when not set); clients rely on stable keys in list payloads.
ProfilePicture *string `json:"profile_picture"` // image URL (e.g. MinIO or HTTPS); JSON null when unset
// Gender matches learner-style free text (nullable); always present in JSON for stable list payloads.
Gender *string `json:"gender"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
@ -24,6 +26,7 @@ type CreateLmsPersonaInput struct {
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
ProfilePicture *string `json:"profile_picture,omitempty"`
Gender *string `json:"gender,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
@ -31,5 +34,6 @@ type UpdateLmsPersonaInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
ProfilePicture *string `json:"profile_picture,omitempty"`
Gender *string `json:"gender,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}

View File

@ -19,6 +19,7 @@ func lmsPersonaToDomain(p dbgen.LmsPersona) domain.LmsPersona {
}
out.Description = fromPgText(p.Description)
out.ProfilePicture = fromPgText(p.ProfilePicture)
out.Gender = fromPgText(p.Gender)
out.CreatedAt = p.CreatedAt.Time
if p.UpdatedAt.Valid {
t := p.UpdatedAt.Time
@ -43,6 +44,7 @@ func (s *Store) CreateLmsPersona(ctx context.Context, in domain.CreateLmsPersona
Name: in.Name,
Description: toPgText(in.Description),
ProfilePicture: toPgText(in.ProfilePicture),
Gender: toPgText(in.Gender),
IsActive: active,
})
if err != nil {
@ -68,6 +70,7 @@ func (s *Store) UpdateLmsPersona(ctx context.Context, id int64, in domain.Update
Name: optionalTextUpdate(in.Name),
Description: optionalTextUpdate(in.Description),
ProfilePicture: optionalTextUpdate(in.ProfilePicture),
Gender: optionalTextUpdate(in.Gender),
IsActive: optionalBoolUpdatePB(in.IsActive),
})
if err != nil {
@ -106,6 +109,7 @@ func (s *Store) ListLmsPersonas(ctx context.Context, activeOnly bool, limit, off
Name: r.Name,
Description: r.Description,
ProfilePicture: r.ProfilePicture,
Gender: r.Gender,
IsActive: r.IsActive,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,

View File

@ -46,6 +46,14 @@ func (s *Service) Create(ctx context.Context, in domain.CreateLmsPersonaInput) (
return domain.LmsPersona{}, ErrNameRequired
}
in.Name = name
if in.Gender != nil {
t := strings.TrimSpace(*in.Gender)
if t == "" {
in.Gender = nil
} else {
in.Gender = &t
}
}
return s.store.CreateLmsPersona(ctx, in)
}
@ -68,6 +76,10 @@ func (s *Service) Update(ctx context.Context, id int64, in domain.UpdateLmsPerso
}
in.Name = &t
}
if in.Gender != nil {
t := strings.TrimSpace(*in.Gender)
in.Gender = &t // empty string clears stored gender when client sends gender: ""
}
p, err := s.store.UpdateLmsPersona(ctx, id, in)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {

View File

@ -2,7 +2,7 @@
"info": {
"_postman_id": "c4e8a921-62f3-4c1e-9bad-1107dfd2a701",
"name": "LMS Personas - Catalog CRUD",
"description": "Admin API for LMS persona catalog (`/api/v1/personas`). Requires bearer token with permissions: personas.list, personas.create, personas.get, personas.update, personas.delete. Personas are assigned to practices via `persona_id`; image URL lives in JSON field `profile_picture`.",
"description": "Admin API for LMS persona catalog (`/api/v1/personas`). Requires bearer token with permissions: personas.list, personas.create, personas.get, personas.update, personas.delete. Personas use `persona_id` on practices; payloads include optional `profile_picture` (URL) and optional `gender` (free-form text, nullable in responses).",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"auth": {
@ -66,7 +66,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Postman Coach\",\n \"description\": \"Smoke-test persona from Postman\",\n \"profile_picture\": \"https://cdn.example.com/personas/postman-coach.png\",\n \"is_active\": true\n}"
"raw": "{\n \"name\": \"Postman Coach\",\n \"description\": \"Smoke-test persona from Postman\",\n \"profile_picture\": \"https://cdn.example.com/personas/postman-coach.png\",\n \"gender\": \"female\",\n \"is_active\": true\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/personas",
@ -176,9 +176,11 @@
" pm.response.to.have.status(200);",
"});",
"const body = pm.response.json();",
"pm.test(\"Has profile_picture field shape\", function () {",
"pm.test(\"Stable persona payload keys\", function () {",
" pm.expect(body.data).to.be.an(\"object\");",
" pm.expect(body.data.name).to.be.a(\"string\");",
" pm.expect(body.data).to.have.property(\"profile_picture\");",
" pm.expect(body.data).to.have.property(\"gender\");",
"});"
],
"type": "text/javascript"
@ -215,6 +217,7 @@
"const body = pm.response.json();",
"pm.test(\"Updated data returned\", function () {",
" pm.expect(body.data.profile_picture).to.include(\"alex-v2\");",
" pm.expect(body.data.gender).to.eql(\"neutral\");",
"});"
],
"type": "text/javascript"
@ -232,7 +235,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"Postman Coach (updated)\",\n \"profile_picture\": \"https://cdn.example.com/personas/alex-v2.png\"\n}"
"raw": "{\n \"name\": \"Postman Coach (updated)\",\n \"profile_picture\": \"https://cdn.example.com/personas/alex-v2.png\",\n \"gender\": \"neutral\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/personas/{{persona_id}}",