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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,26 +10,30 @@ var ErrPersonaNotFound = errors.New("persona not found")
// LmsPersona is a coach / character profile stored in lms_personas and referenced by practice shells. // LmsPersona is a coach / character profile stored in lms_personas and referenced by practice shells.
type LmsPersona struct { type LmsPersona struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
// ProfilePicture is always serialized (null when not set); clients rely on stable keys in list payloads. // 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 ProfilePicture *string `json:"profile_picture"` // image URL (e.g. MinIO or HTTPS); JSON null when unset
IsActive bool `json:"is_active"` // Gender matches learner-style free text (nullable); always present in JSON for stable list payloads.
CreatedAt time.Time `json:"created_at"` Gender *string `json:"gender"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
} }
type CreateLmsPersonaInput struct { type CreateLmsPersonaInput struct {
Name string `json:"name" validate:"required"` Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
ProfilePicture *string `json:"profile_picture,omitempty"` ProfilePicture *string `json:"profile_picture,omitempty"`
IsActive *bool `json:"is_active,omitempty"` Gender *string `json:"gender,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
} }
type UpdateLmsPersonaInput struct { type UpdateLmsPersonaInput struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
ProfilePicture *string `json:"profile_picture,omitempty"` ProfilePicture *string `json:"profile_picture,omitempty"`
IsActive *bool `json:"is_active,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.Description = fromPgText(p.Description)
out.ProfilePicture = fromPgText(p.ProfilePicture) out.ProfilePicture = fromPgText(p.ProfilePicture)
out.Gender = fromPgText(p.Gender)
out.CreatedAt = p.CreatedAt.Time out.CreatedAt = p.CreatedAt.Time
if p.UpdatedAt.Valid { if p.UpdatedAt.Valid {
t := p.UpdatedAt.Time t := p.UpdatedAt.Time
@ -40,10 +41,11 @@ func (s *Store) CreateLmsPersona(ctx context.Context, in domain.CreateLmsPersona
active = *in.IsActive active = *in.IsActive
} }
p, err := s.queries.CreateLmsPersona(ctx, dbgen.CreateLmsPersonaParams{ p, err := s.queries.CreateLmsPersona(ctx, dbgen.CreateLmsPersonaParams{
Name: in.Name, Name: in.Name,
Description: toPgText(in.Description), Description: toPgText(in.Description),
ProfilePicture: toPgText(in.ProfilePicture), ProfilePicture: toPgText(in.ProfilePicture),
IsActive: active, Gender: toPgText(in.Gender),
IsActive: active,
}) })
if err != nil { if err != nil {
return domain.LmsPersona{}, err return domain.LmsPersona{}, err
@ -64,11 +66,12 @@ func (s *Store) GetLmsPersonaByID(ctx context.Context, id int64) (domain.LmsPers
func (s *Store) UpdateLmsPersona(ctx context.Context, id int64, in domain.UpdateLmsPersonaInput) (domain.LmsPersona, error) { func (s *Store) UpdateLmsPersona(ctx context.Context, id int64, in domain.UpdateLmsPersonaInput) (domain.LmsPersona, error) {
p, err := s.queries.UpdateLmsPersona(ctx, dbgen.UpdateLmsPersonaParams{ p, err := s.queries.UpdateLmsPersona(ctx, dbgen.UpdateLmsPersonaParams{
ID: id, ID: id,
Name: optionalTextUpdate(in.Name), Name: optionalTextUpdate(in.Name),
Description: optionalTextUpdate(in.Description), Description: optionalTextUpdate(in.Description),
ProfilePicture: optionalTextUpdate(in.ProfilePicture), ProfilePicture: optionalTextUpdate(in.ProfilePicture),
IsActive: optionalBoolUpdatePB(in.IsActive), Gender: optionalTextUpdate(in.Gender),
IsActive: optionalBoolUpdatePB(in.IsActive),
}) })
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@ -106,6 +109,7 @@ func (s *Store) ListLmsPersonas(ctx context.Context, activeOnly bool, limit, off
Name: r.Name, Name: r.Name,
Description: r.Description, Description: r.Description,
ProfilePicture: r.ProfilePicture, ProfilePicture: r.ProfilePicture,
Gender: r.Gender,
IsActive: r.IsActive, IsActive: r.IsActive,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,

View File

@ -46,6 +46,14 @@ func (s *Service) Create(ctx context.Context, in domain.CreateLmsPersonaInput) (
return domain.LmsPersona{}, ErrNameRequired return domain.LmsPersona{}, ErrNameRequired
} }
in.Name = name 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) 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 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) p, err := s.store.UpdateLmsPersona(ctx, id, in)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View File

@ -2,7 +2,7 @@
"info": { "info": {
"_postman_id": "c4e8a921-62f3-4c1e-9bad-1107dfd2a701", "_postman_id": "c4e8a921-62f3-4c1e-9bad-1107dfd2a701",
"name": "LMS Personas - Catalog CRUD", "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" "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
}, },
"auth": { "auth": {
@ -66,7 +66,7 @@
], ],
"body": { "body": {
"mode": "raw", "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": { "url": {
"raw": "{{base_url}}/api/v1/personas", "raw": "{{base_url}}/api/v1/personas",
@ -176,9 +176,11 @@
" pm.response.to.have.status(200);", " pm.response.to.have.status(200);",
"});", "});",
"const body = pm.response.json();", "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).to.be.an(\"object\");",
" pm.expect(body.data.name).to.be.a(\"string\");", " 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" "type": "text/javascript"
@ -215,6 +217,7 @@
"const body = pm.response.json();", "const body = pm.response.json();",
"pm.test(\"Updated data returned\", function () {", "pm.test(\"Updated data returned\", function () {",
" pm.expect(body.data.profile_picture).to.include(\"alex-v2\");", " pm.expect(body.data.profile_picture).to.include(\"alex-v2\");",
" pm.expect(body.data.gender).to.eql(\"neutral\");",
"});" "});"
], ],
"type": "text/javascript" "type": "text/javascript"
@ -232,7 +235,7 @@
], ],
"body": { "body": {
"mode": "raw", "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": { "url": {
"raw": "{{base_url}}/api/v1/personas/{{persona_id}}", "raw": "{{base_url}}/api/v1/personas/{{persona_id}}",