From 5399d33af6a6e08f9ccdeffeb9f5524777801706 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Wed, 20 May 2026 06:37:21 -0700 Subject: [PATCH] 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 --- .../000065_lms_personas_gender.down.sql | 2 ++ .../000065_lms_personas_gender.up.sql | 2 ++ db/query/lms_personas.sql | 6 ++-- docs/docs.go | 6 ++++ docs/swagger.json | 6 ++++ docs/swagger.yaml | 4 +++ gen/db/lms_personas.sql.go | 25 +++++++++++---- gen/db/models.go | 1 + internal/domain/lms_persona.go | 32 +++++++++++-------- internal/repository/lms_personas.go | 18 +++++++---- internal/services/personas/service.go | 12 +++++++ postman/LMS-Personas.postman_collection.json | 11 ++++--- 12 files changed, 91 insertions(+), 34 deletions(-) create mode 100644 db/migrations/000065_lms_personas_gender.down.sql create mode 100644 db/migrations/000065_lms_personas_gender.up.sql diff --git a/db/migrations/000065_lms_personas_gender.down.sql b/db/migrations/000065_lms_personas_gender.down.sql new file mode 100644 index 0000000..4f54d4b --- /dev/null +++ b/db/migrations/000065_lms_personas_gender.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE lms_personas + DROP COLUMN IF EXISTS gender; diff --git a/db/migrations/000065_lms_personas_gender.up.sql b/db/migrations/000065_lms_personas_gender.up.sql new file mode 100644 index 0000000..ea05b50 --- /dev/null +++ b/db/migrations/000065_lms_personas_gender.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE lms_personas + ADD COLUMN gender TEXT; diff --git a/db/query/lms_personas.sql b/db/query/lms_personas.sql index c1e6693..03fd904 100644 --- a/db/query/lms_personas.sql +++ b/db/query/lms_personas.sql @@ -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 diff --git a/docs/docs.go b/docs/docs.go index 23b742f..3411d8f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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" }, diff --git a/docs/swagger.json b/docs/swagger.json index a772f1d..67402dd 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 98ee719..5a87ce0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/gen/db/lms_personas.sql.go b/gen/db/lms_personas.sql.go index ac43b8c..a26e99e 100644 --- a/gen/db/lms_personas.sql.go +++ b/gen/db/lms_personas.sql.go @@ -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 } diff --git a/gen/db/models.go b/gen/db/models.go index 452a196..a5217f0 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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 { diff --git a/internal/domain/lms_persona.go b/internal/domain/lms_persona.go index 3273be0..624a7de 100644 --- a/internal/domain/lms_persona.go +++ b/internal/domain/lms_persona.go @@ -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. type LmsPersona struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` + ID int64 `json:"id"` + Name string `json:"name"` + 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 - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + // 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"` } type CreateLmsPersonaInput struct { - Name string `json:"name" validate:"required"` - Description *string `json:"description,omitempty"` - ProfilePicture *string `json:"profile_picture,omitempty"` - IsActive *bool `json:"is_active,omitempty"` + 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"` } type UpdateLmsPersonaInput struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - ProfilePicture *string `json:"profile_picture,omitempty"` - IsActive *bool `json:"is_active,omitempty"` + 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"` } diff --git a/internal/repository/lms_personas.go b/internal/repository/lms_personas.go index bbe0d2a..3b9ca57 100644 --- a/internal/repository/lms_personas.go +++ b/internal/repository/lms_personas.go @@ -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 @@ -40,10 +41,11 @@ func (s *Store) CreateLmsPersona(ctx context.Context, in domain.CreateLmsPersona active = *in.IsActive } p, err := s.queries.CreateLmsPersona(ctx, dbgen.CreateLmsPersonaParams{ - Name: in.Name, - Description: toPgText(in.Description), + Name: in.Name, + Description: toPgText(in.Description), ProfilePicture: toPgText(in.ProfilePicture), - IsActive: active, + Gender: toPgText(in.Gender), + IsActive: active, }) if err != nil { 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) { p, err := s.queries.UpdateLmsPersona(ctx, dbgen.UpdateLmsPersonaParams{ - ID: id, - Name: optionalTextUpdate(in.Name), - Description: optionalTextUpdate(in.Description), + ID: id, + Name: optionalTextUpdate(in.Name), + Description: optionalTextUpdate(in.Description), ProfilePicture: optionalTextUpdate(in.ProfilePicture), - IsActive: optionalBoolUpdatePB(in.IsActive), + Gender: optionalTextUpdate(in.Gender), + IsActive: optionalBoolUpdatePB(in.IsActive), }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -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, diff --git a/internal/services/personas/service.go b/internal/services/personas/service.go index 11a8bfb..e0244e0 100644 --- a/internal/services/personas/service.go +++ b/internal/services/personas/service.go @@ -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) { diff --git a/postman/LMS-Personas.postman_collection.json b/postman/LMS-Personas.postman_collection.json index b78d490..455e036 100644 --- a/postman/LMS-Personas.postman_collection.json +++ b/postman/LMS-Personas.postman_collection.json @@ -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}}",