Add LMS personas catalog and CRUD API.

Introduce lms_personas table, repoint practice persona_id FKs off users, validate persona refs on LMS and exam-prep practice flows, personas.* RBAC permissions, and OpenAPI docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-20 06:06:42 -07:00
parent 71bc09a638
commit 873be1b482
24 changed files with 1210 additions and 19 deletions

View File

@ -27,6 +27,7 @@ import (
minioservice "Yimaru-Backend/internal/services/minio"
moduleservice "Yimaru-Backend/internal/services/modules"
notificationservice "Yimaru-Backend/internal/services/notification"
personasservice "Yimaru-Backend/internal/services/personas"
practicesservice "Yimaru-Backend/internal/services/practices"
programsservice "Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions"
@ -395,6 +396,7 @@ func main() {
// Questions service (unified questions system)
questionsSvc := questions.NewService(store)
faqSvc := faqs.NewService(repository.NewFAQStore(store))
personasSvc := personasservice.NewService(store)
examPrepSvc := examprep.NewService(store)
// LMS programs (top-level hierarchy)
@ -456,6 +458,7 @@ func main() {
assessmentSvc,
questionsSvc,
faqSvc,
personasSvc,
examPrepSvc,
programSvc,
courseSvc,

View File

@ -0,0 +1,17 @@
ALTER TABLE exam_prep.lesson_practices DROP CONSTRAINT IF EXISTS lesson_practices_persona_id_fkey;
UPDATE exam_prep.lesson_practices
SET persona_id = NULL;
ALTER TABLE exam_prep.lesson_practices
ADD CONSTRAINT lesson_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES users (id) ON DELETE SET NULL;
ALTER TABLE lms_practices DROP CONSTRAINT IF EXISTS lms_practices_persona_id_fkey;
UPDATE lms_practices
SET persona_id = NULL;
ALTER TABLE lms_practices
ADD CONSTRAINT lms_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES users (id) ON DELETE SET NULL;
DROP TABLE IF EXISTS lms_personas;

View File

@ -0,0 +1,34 @@
-- Catalog of LMS personas (coach/avatar profiles) referenced by Learn English + exam-prep practices.
CREATE TABLE lms_personas (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
avatar_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ
);
CREATE INDEX idx_lms_personas_is_active ON lms_personas (is_active)
WHERE is_active;
CREATE INDEX idx_lms_personas_created_at ON lms_personas (created_at DESC);
-- persona_id historically referenced users.id; personas are now catalog rows on lms_personas.
ALTER TABLE lms_practices DROP CONSTRAINT IF EXISTS lms_practices_persona_id_fkey;
UPDATE lms_practices
SET persona_id = NULL
WHERE persona_id IS NOT NULL;
ALTER TABLE lms_practices
ADD CONSTRAINT lms_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES lms_personas (id) ON DELETE SET NULL;
ALTER TABLE exam_prep.lesson_practices DROP CONSTRAINT IF EXISTS lesson_practices_persona_id_fkey;
UPDATE exam_prep.lesson_practices
SET persona_id = NULL
WHERE persona_id IS NOT NULL;
ALTER TABLE exam_prep.lesson_practices
ADD CONSTRAINT lesson_practices_persona_id_fkey FOREIGN KEY (persona_id) REFERENCES lms_personas (id) ON DELETE SET NULL;

42
db/query/lms_personas.sql Normal file
View File

@ -0,0 +1,42 @@
-- name: CreateLmsPersona :one
INSERT INTO lms_personas (name, description, avatar_url, is_active)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetLmsPersonaByID :one
SELECT *
FROM lms_personas
WHERE id = $1;
-- name: UpdateLmsPersona :one
UPDATE lms_personas
SET
name = COALESCE(sqlc.narg('name')::varchar, name),
description = COALESCE(sqlc.narg('description')::text, description),
avatar_url = COALESCE(sqlc.narg('avatar_url')::text, avatar_url),
is_active = COALESCE(sqlc.narg('is_active')::boolean, is_active),
updated_at = CURRENT_TIMESTAMP
WHERE id = sqlc.arg('id')
RETURNING *;
-- name: DeleteLmsPersona :exec
DELETE FROM lms_personas
WHERE id = $1;
-- name: ListLmsPersonas :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.name,
p.description,
p.avatar_url,
p.is_active,
p.created_at,
p.updated_at
FROM lms_personas p
WHERE (
sqlc.arg('filter_active')::boolean = FALSE
OR p.is_active = TRUE
)
ORDER BY p.name ASC, p.created_at DESC
LIMIT $1 OFFSET $2;

View File

@ -3963,6 +3963,123 @@ const docTemplate = `{
}
}
},
"/api/v1/personas": {
"get": {
"tags": [
"personas"
],
"summary": "List LMS personas (catalog for practice assignment)",
"parameters": [
{
"type": "boolean",
"default": true,
"description": "When true (default), return only active personas",
"name": "active_only",
"in": "query"
},
{
"type": "integer",
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {}
},
"post": {
"consumes": [
"application/json"
],
"tags": [
"personas"
],
"summary": "Create LMS persona catalog entry",
"parameters": [
{
"description": "Persona",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateLmsPersonaInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/personas/{id}": {
"get": {
"tags": [
"personas"
],
"summary": "Get LMS persona by ID",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
},
"put": {
"tags": [
"personas"
],
"summary": "Update LMS persona",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateLmsPersonaInput"
}
}
],
"responses": {}
},
"delete": {
"tags": [
"personas"
],
"summary": "Delete LMS persona (practices referencing it will have persona_id cleared)",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
}
},
"/api/v1/practices": {
"post": {
"consumes": [
@ -10518,6 +10635,26 @@ const docTemplate = `{
}
}
},
"domain.CreateLmsPersonaInput": {
"type": "object",
"required": [
"name"
],
"properties": {
"avatar_url": {
"type": "string"
},
"description": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
},
"domain.CreateModuleInput": {
"type": "object",
"required": [
@ -11441,6 +11578,23 @@ const docTemplate = `{
}
}
},
"domain.UpdateLmsPersonaInput": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string"
},
"description": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
},
"domain.UpdateModuleInput": {
"type": "object",
"properties": {

View File

@ -3955,6 +3955,123 @@
}
}
},
"/api/v1/personas": {
"get": {
"tags": [
"personas"
],
"summary": "List LMS personas (catalog for practice assignment)",
"parameters": [
{
"type": "boolean",
"default": true,
"description": "When true (default), return only active personas",
"name": "active_only",
"in": "query"
},
{
"type": "integer",
"description": "Page size",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Offset",
"name": "offset",
"in": "query"
}
],
"responses": {}
},
"post": {
"consumes": [
"application/json"
],
"tags": [
"personas"
],
"summary": "Create LMS persona catalog entry",
"parameters": [
{
"description": "Persona",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.CreateLmsPersonaInput"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/domain.Response"
}
}
}
}
},
"/api/v1/personas/{id}": {
"get": {
"tags": [
"personas"
],
"summary": "Get LMS persona by ID",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
},
"put": {
"tags": [
"personas"
],
"summary": "Update LMS persona",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.UpdateLmsPersonaInput"
}
}
],
"responses": {}
},
"delete": {
"tags": [
"personas"
],
"summary": "Delete LMS persona (practices referencing it will have persona_id cleared)",
"parameters": [
{
"type": "integer",
"description": "Persona ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {}
}
},
"/api/v1/practices": {
"post": {
"consumes": [
@ -10510,6 +10627,26 @@
}
}
},
"domain.CreateLmsPersonaInput": {
"type": "object",
"required": [
"name"
],
"properties": {
"avatar_url": {
"type": "string"
},
"description": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
},
"domain.CreateModuleInput": {
"type": "object",
"required": [
@ -11433,6 +11570,23 @@
}
}
},
"domain.UpdateLmsPersonaInput": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string"
},
"description": {
"type": "string"
},
"is_active": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
},
"domain.UpdateModuleInput": {
"type": "object",
"properties": {

View File

@ -447,6 +447,19 @@ definitions:
required:
- title
type: object
domain.CreateLmsPersonaInput:
properties:
avatar_url:
type: string
description:
type: string
is_active:
type: boolean
name:
type: string
required:
- name
type: object
domain.CreateModuleInput:
properties:
description:
@ -1081,6 +1094,17 @@ definitions:
video_url:
type: string
type: object
domain.UpdateLmsPersonaInput:
properties:
avatar_url:
type: string
description:
type: string
is_active:
type: boolean
name:
type: string
type: object
domain.UpdateModuleInput:
properties:
description:
@ -5168,6 +5192,84 @@ paths:
summary: Handle ArifPay webhook
tags:
- payments
/api/v1/personas:
get:
parameters:
- default: true
description: When true (default), return only active personas
in: query
name: active_only
type: boolean
- description: Page size
in: query
name: limit
type: integer
- description: Offset
in: query
name: offset
type: integer
responses: {}
summary: List LMS personas (catalog for practice assignment)
tags:
- personas
post:
consumes:
- application/json
parameters:
- description: Persona
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.CreateLmsPersonaInput'
responses:
"201":
description: Created
schema:
$ref: '#/definitions/domain.Response'
summary: Create LMS persona catalog entry
tags:
- personas
/api/v1/personas/{id}:
delete:
parameters:
- description: Persona ID
in: path
name: id
required: true
type: integer
responses: {}
summary: Delete LMS persona (practices referencing it will have persona_id cleared)
tags:
- personas
get:
parameters:
- description: Persona ID
in: path
name: id
required: true
type: integer
responses: {}
summary: Get LMS persona by ID
tags:
- personas
put:
parameters:
- description: Persona ID
in: path
name: id
required: true
type: integer
- description: Fields to update
in: body
name: body
required: true
schema:
$ref: '#/definitions/domain.UpdateLmsPersonaInput'
responses: {}
summary: Update LMS persona
tags:
- personas
/api/v1/practices:
post:
consumes:

182
gen/db/lms_personas.sql.go Normal file
View File

@ -0,0 +1,182 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: lms_personas.sql
package dbgen
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const CreateLmsPersona = `-- name: CreateLmsPersona :one
INSERT INTO lms_personas (name, description, avatar_url, is_active)
VALUES ($1, $2, $3, $4)
RETURNING id, name, description, avatar_url, is_active, created_at, updated_at
`
type CreateLmsPersonaParams struct {
Name string `json:"name"`
Description pgtype.Text `json:"description"`
AvatarUrl pgtype.Text `json:"avatar_url"`
IsActive bool `json:"is_active"`
}
func (q *Queries) CreateLmsPersona(ctx context.Context, arg CreateLmsPersonaParams) (LmsPersona, error) {
row := q.db.QueryRow(ctx, CreateLmsPersona,
arg.Name,
arg.Description,
arg.AvatarUrl,
arg.IsActive,
)
var i LmsPersona
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.AvatarUrl,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const DeleteLmsPersona = `-- name: DeleteLmsPersona :exec
DELETE FROM lms_personas
WHERE id = $1
`
func (q *Queries) DeleteLmsPersona(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, DeleteLmsPersona, id)
return err
}
const GetLmsPersonaByID = `-- name: GetLmsPersonaByID :one
SELECT id, name, description, avatar_url, is_active, created_at, updated_at
FROM lms_personas
WHERE id = $1
`
func (q *Queries) GetLmsPersonaByID(ctx context.Context, id int64) (LmsPersona, error) {
row := q.db.QueryRow(ctx, GetLmsPersonaByID, id)
var i LmsPersona
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.AvatarUrl,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const ListLmsPersonas = `-- name: ListLmsPersonas :many
SELECT
COUNT(*) OVER () AS total_count,
p.id,
p.name,
p.description,
p.avatar_url,
p.is_active,
p.created_at,
p.updated_at
FROM lms_personas p
WHERE (
$3::boolean = FALSE
OR p.is_active = TRUE
)
ORDER BY p.name ASC, p.created_at DESC
LIMIT $1 OFFSET $2
`
type ListLmsPersonasParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
FilterActive bool `json:"filter_active"`
}
type ListLmsPersonasRow struct {
TotalCount int64 `json:"total_count"`
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
AvatarUrl pgtype.Text `json:"avatar_url"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListLmsPersonas(ctx context.Context, arg ListLmsPersonasParams) ([]ListLmsPersonasRow, error) {
rows, err := q.db.Query(ctx, ListLmsPersonas, arg.Limit, arg.Offset, arg.FilterActive)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLmsPersonasRow
for rows.Next() {
var i ListLmsPersonasRow
if err := rows.Scan(
&i.TotalCount,
&i.ID,
&i.Name,
&i.Description,
&i.AvatarUrl,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const UpdateLmsPersona = `-- name: UpdateLmsPersona :one
UPDATE lms_personas
SET
name = COALESCE($1::varchar, name),
description = COALESCE($2::text, description),
avatar_url = COALESCE($3::text, avatar_url),
is_active = COALESCE($4::boolean, is_active),
updated_at = CURRENT_TIMESTAMP
WHERE id = $5
RETURNING id, name, description, avatar_url, is_active, created_at, updated_at
`
type UpdateLmsPersonaParams struct {
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
AvatarUrl pgtype.Text `json:"avatar_url"`
IsActive pgtype.Bool `json:"is_active"`
ID int64 `json:"id"`
}
func (q *Queries) UpdateLmsPersona(ctx context.Context, arg UpdateLmsPersonaParams) (LmsPersona, error) {
row := q.db.QueryRow(ctx, UpdateLmsPersona,
arg.Name,
arg.Description,
arg.AvatarUrl,
arg.IsActive,
arg.ID,
)
var i LmsPersona
err := row.Scan(
&i.ID,
&i.Name,
&i.Description,
&i.AvatarUrl,
&i.IsActive,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -138,6 +138,16 @@ type LevelToSubCourse struct {
SubCourseID int64 `json:"sub_course_id"`
}
type LmsPersona struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description pgtype.Text `json:"description"`
AvatarUrl pgtype.Text `json:"avatar_url"`
IsActive bool `json:"is_active"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type LmsPractice struct {
ID int64 `json:"id"`
CourseID pgtype.Int8 `json:"course_id"`

View File

@ -9,7 +9,7 @@ type ExamPrepPractice struct {
Title string `json:"title"`
StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"` // lms_personas.id when set
QuestionSetID int64 `json:"question_set_id"`
PublishStatus PracticePublishStatus `json:"publish_status"`
QuickTips *string `json:"quick_tips,omitempty"`

View File

@ -0,0 +1,34 @@
package domain
import (
"errors"
"time"
)
// ErrPersonaNotFound is returned when an lms_personas row does not exist.
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"`
AvatarURL *string `json:"avatar_url,omitempty"`
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"`
AvatarURL *string `json:"avatar_url,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
type UpdateLmsPersonaInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}

View File

@ -47,7 +47,7 @@ type Practice struct {
Title string `json:"title"`
StoryDescription *string `json:"story_description,omitempty"`
StoryImage *string `json:"story_image,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"`
PersonaID *int64 `json:"persona_id,omitempty"` // lms_personas.id when set
QuestionSetID int64 `json:"question_set_id"`
PublishStatus PracticePublishStatus `json:"publish_status"`
QuickTips *string `json:"quick_tips,omitempty"`

View File

@ -0,0 +1,21 @@
package ports
import (
"context"
"Yimaru-Backend/internal/domain"
)
// LmsPersonaReader resolves catalog personas referenced by LMS / exam-prep practices.
type LmsPersonaReader interface {
GetLmsPersonaByID(ctx context.Context, id int64) (domain.LmsPersona, error)
}
// LmsPersonaStore is full CRUD for lms_personas.
type LmsPersonaStore interface {
LmsPersonaReader
CreateLmsPersona(ctx context.Context, in domain.CreateLmsPersonaInput) (domain.LmsPersona, error)
UpdateLmsPersona(ctx context.Context, id int64, in domain.UpdateLmsPersonaInput) (domain.LmsPersona, error)
DeleteLmsPersona(ctx context.Context, id int64) error
ListLmsPersonas(ctx context.Context, activeOnly bool, limit, offset int32) ([]domain.LmsPersona, int64, error)
}

View File

@ -0,0 +1,115 @@
package repository
import (
"context"
"errors"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
func lmsPersonaToDomain(p dbgen.LmsPersona) domain.LmsPersona {
out := domain.LmsPersona{
ID: p.ID,
Name: p.Name,
IsActive: p.IsActive,
}
out.Description = fromPgText(p.Description)
out.AvatarURL = fromPgText(p.AvatarUrl)
out.CreatedAt = p.CreatedAt.Time
if p.UpdatedAt.Valid {
t := p.UpdatedAt.Time
out.UpdatedAt = &t
}
return out
}
func optionalBoolUpdatePB(v *bool) pgtype.Bool {
if v == nil {
return pgtype.Bool{Valid: false}
}
return pgtype.Bool{Bool: *v, Valid: true}
}
func (s *Store) CreateLmsPersona(ctx context.Context, in domain.CreateLmsPersonaInput) (domain.LmsPersona, error) {
active := true
if in.IsActive != nil {
active = *in.IsActive
}
p, err := s.queries.CreateLmsPersona(ctx, dbgen.CreateLmsPersonaParams{
Name: in.Name,
Description: toPgText(in.Description),
AvatarUrl: toPgText(in.AvatarURL),
IsActive: active,
})
if err != nil {
return domain.LmsPersona{}, err
}
return lmsPersonaToDomain(p), nil
}
func (s *Store) GetLmsPersonaByID(ctx context.Context, id int64) (domain.LmsPersona, error) {
p, err := s.queries.GetLmsPersonaByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.LmsPersona{}, pgx.ErrNoRows
}
return domain.LmsPersona{}, err
}
return lmsPersonaToDomain(p), nil
}
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),
AvatarUrl: optionalTextUpdate(in.AvatarURL),
IsActive: optionalBoolUpdatePB(in.IsActive),
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.LmsPersona{}, pgx.ErrNoRows
}
return domain.LmsPersona{}, err
}
return lmsPersonaToDomain(p), nil
}
func (s *Store) DeleteLmsPersona(ctx context.Context, id int64) error {
return s.queries.DeleteLmsPersona(ctx, id)
}
func (s *Store) ListLmsPersonas(ctx context.Context, activeOnly bool, limit, offset int32) ([]domain.LmsPersona, int64, error) {
rows, err := s.queries.ListLmsPersonas(ctx, dbgen.ListLmsPersonasParams{
Limit: limit,
Offset: offset,
FilterActive: activeOnly,
})
if err != nil {
return nil, 0, err
}
if len(rows) == 0 {
return []domain.LmsPersona{}, 0, nil
}
var total int64
out := make([]domain.LmsPersona, 0, len(rows))
for i, r := range rows {
if i == 0 {
total = r.TotalCount
}
out = append(out, lmsPersonaToDomain(dbgen.LmsPersona{
ID: r.ID,
Name: r.Name,
Description: r.Description,
AvatarUrl: r.AvatarUrl,
IsActive: r.IsActive,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}))
}
return out, total, nil
}

View File

@ -15,13 +15,14 @@ var ErrModuleNotFound = errors.New("exam prep module not found")
var ErrLessonNotFound = errors.New("exam prep lesson not found")
var ErrPracticeNotFound = errors.New("exam prep practice not found")
// examPrepStore is implemented by *repository.Store (catalog courses, units, modules, lessons, practices).
// examPrepStore is implemented by *repository.Store (catalog courses, units, modules, lessons, practices, personas).
type examPrepStore interface {
ports.ExamPrepCatalogCourseStore
ports.ExamPrepUnitStore
ports.ExamPrepModuleStore
ports.ExamPrepLessonStore
ports.ExamPrepPracticeStore
ports.LmsPersonaReader
}
type Service struct {
@ -32,6 +33,17 @@ func NewService(store examPrepStore) *Service {
return &Service{store: store}
}
func (s *Service) ensurePersonaRef(ctx context.Context, id int64) error {
if id <= 0 {
return domain.ErrPersonaNotFound
}
_, err := s.store.GetLmsPersonaByID(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrPersonaNotFound
}
return err
}
func (s *Service) CreateCatalogCourse(ctx context.Context, input domain.CreateExamPrepCatalogCourseInput) (domain.ExamPrepCatalogCourse, error) {
return s.store.CreateExamPrepCatalogCourse(ctx, input)
}
@ -355,6 +367,11 @@ func (s *Service) CreateExamPrepPractice(ctx context.Context, lessonID int64, in
if err := s.ensureLesson(ctx, lessonID); err != nil {
return domain.ExamPrepPractice{}, err
}
if input.PersonaID != nil {
if err := s.ensurePersonaRef(ctx, *input.PersonaID); err != nil {
return domain.ExamPrepPractice{}, err
}
}
return s.store.CreateExamPrepLessonPractice(ctx, lessonID, input)
}
@ -390,6 +407,11 @@ func (s *Service) TryGetExamPrepPracticeByQuestionSetID(ctx context.Context, que
}
func (s *Service) UpdateExamPrepPractice(ctx context.Context, id int64, input domain.UpdateExamPrepPracticeInput) (domain.ExamPrepPractice, error) {
if input.PersonaID != nil {
if err := s.ensurePersonaRef(ctx, *input.PersonaID); err != nil {
return domain.ExamPrepPractice{}, err
}
}
p, err := s.store.UpdateExamPrepLessonPractice(ctx, id, input)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {

View File

@ -0,0 +1,91 @@
package personas
import (
"context"
"errors"
"strings"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
"github.com/jackc/pgx/v5"
)
var ErrPersonaNotFound = domain.ErrPersonaNotFound
// ErrNameRequired indicates a missing trim-empty name on create.
var ErrNameRequired = errors.New("name is required")
// ErrNameEmptyUpdate indicates an update attempted to clear the persona name.
var ErrNameEmptyUpdate = errors.New("name cannot be empty")
type Service struct {
store ports.LmsPersonaStore
}
func NewService(store ports.LmsPersonaStore) *Service {
return &Service{store: store}
}
func clampPage(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) Create(ctx context.Context, in domain.CreateLmsPersonaInput) (domain.LmsPersona, error) {
name := strings.TrimSpace(in.Name)
if name == "" {
return domain.LmsPersona{}, ErrNameRequired
}
in.Name = name
return s.store.CreateLmsPersona(ctx, in)
}
func (s *Service) GetByID(ctx context.Context, id int64) (domain.LmsPersona, error) {
p, err := s.store.GetLmsPersonaByID(ctx, id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.LmsPersona{}, ErrPersonaNotFound
}
return domain.LmsPersona{}, err
}
return p, nil
}
func (s *Service) Update(ctx context.Context, id int64, in domain.UpdateLmsPersonaInput) (domain.LmsPersona, error) {
if in.Name != nil {
t := strings.TrimSpace(*in.Name)
if t == "" {
return domain.LmsPersona{}, ErrNameEmptyUpdate
}
in.Name = &t
}
p, err := s.store.UpdateLmsPersona(ctx, id, in)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.LmsPersona{}, ErrPersonaNotFound
}
return domain.LmsPersona{}, err
}
return p, nil
}
func (s *Service) Delete(ctx context.Context, id int64) error {
if err := s.store.DeleteLmsPersona(ctx, id); err != nil {
return err
}
return nil
}
func (s *Service) List(ctx context.Context, activeOnly bool, limit, offset int32) ([]domain.LmsPersona, int64, error) {
limit, offset = clampPage(limit, offset)
return s.store.ListLmsPersonas(ctx, activeOnly, limit, offset)
}

View File

@ -24,7 +24,7 @@ type Service struct {
modules ports.ModuleStore
lessons ports.LessonStore
qs ports.QuestionSetByID
users ports.UserByID
personas ports.LmsPersonaReader
}
func NewService(
@ -33,7 +33,7 @@ func NewService(
modules ports.ModuleStore,
lessons ports.LessonStore,
qs ports.QuestionSetByID,
users ports.UserByID,
personas ports.LmsPersonaReader,
) *Service {
return &Service{
practices: practices,
@ -41,7 +41,7 @@ func NewService(
modules: modules,
lessons: lessons,
qs: qs,
users: users,
personas: personas,
}
}
@ -56,17 +56,19 @@ func (s *Service) validateQuestionSet(ctx context.Context, id int64) error {
return nil
}
func (s *Service) validatePersonaUser(ctx context.Context, id int64) error {
_, err := s.users.GetUserByID(ctx, id)
func (s *Service) validatePersonaCatalog(ctx context.Context, id int64) error {
if id <= 0 {
return domain.ErrPersonaNotFound
}
_, err := s.personas.GetLmsPersonaByID(ctx, id)
if err != nil {
if errors.Is(err, domain.ErrUserNotFound) {
return domain.ErrUserNotFound
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrPersonaNotFound
}
return err
}
return nil
}
func (s *Service) resolveParent(ctx context.Context, in domain.CreatePracticeInput) (courseID, moduleID, lessonID *int64, err error) {
pid := in.ParentID
switch in.ParentKind {
@ -104,7 +106,7 @@ func (s *Service) Create(ctx context.Context, in domain.CreatePracticeInput) (do
return domain.Practice{}, err
}
if in.PersonaID != nil {
if err := s.validatePersonaUser(ctx, *in.PersonaID); err != nil {
if err := s.validatePersonaCatalog(ctx, *in.PersonaID); err != nil {
return domain.Practice{}, err
}
}
@ -183,7 +185,7 @@ func (s *Service) Update(ctx context.Context, id int64, input domain.UpdatePract
}
}
if input.PersonaID != nil {
if err := s.validatePersonaUser(ctx, *input.PersonaID); err != nil {
if err := s.validatePersonaCatalog(ctx, *input.PersonaID); err != nil {
return domain.Practice{}, err
}
}

View File

@ -86,6 +86,13 @@ var AllPermissions = []domain.PermissionSeed{
{Key: "practices.update", Name: "Update Practice", Description: "Update a practice", GroupName: "Practices"},
{Key: "practices.delete", Name: "Delete Practice", Description: "Delete a practice", GroupName: "Practices"},
// LMS personas (catalog for coach/character profiles linked on practices)
{Key: "personas.create", Name: "Create Persona", Description: "Create an LMS persona profile", GroupName: "Personas"},
{Key: "personas.list", Name: "List Personas", Description: "List LMS persona profiles", GroupName: "Personas"},
{Key: "personas.get", Name: "Get Persona", Description: "Get an LMS persona by ID", GroupName: "Personas"},
{Key: "personas.update", Name: "Update Persona", Description: "Update an LMS persona", GroupName: "Personas"},
{Key: "personas.delete", Name: "Delete Persona", Description: "Delete an LMS persona", GroupName: "Personas"},
// Course Management - Sub-courses
{Key: "subcourses.create", Name: "Create Sub-course", Description: "Create a new sub-course", GroupName: "Sub-courses"},
{Key: "subcourses.get", Name: "Get Sub-course", Description: "Get a sub-course by ID", GroupName: "Sub-courses"},
@ -396,6 +403,9 @@ var DefaultRolePermissions = map[string][]string{
// Practices
"practices.create", "practices.get", "practices.list", "practices.update", "practices.delete",
// LMS personas catalog
"personas.create", "personas.list", "personas.get", "personas.update", "personas.delete",
// Questions (full access)
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete",
"question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete",
@ -493,6 +503,8 @@ var DefaultRolePermissions = map[string][]string{
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
"lms.get_my_progress",
"personas.create", "personas.list", "personas.get", "personas.update", "personas.delete",
// Questions (full — instructors create content)
"questions.create", "questions.list", "questions.search", "questions.get", "questions.update", "questions.delete",
"question_sets.create", "question_sets.list", "question_sets.list_by_owner", "question_sets.get", "question_sets.update", "question_sets.delete",
@ -551,6 +563,8 @@ var DefaultRolePermissions = map[string][]string{
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
"personas.list", "personas.get",
// Questions (read)
"questions.list", "questions.search", "questions.get",
"question_sets.list", "question_sets.list_by_owner", "question_sets.get",

View File

@ -17,6 +17,7 @@ import (
minioservice "Yimaru-Backend/internal/services/minio"
"Yimaru-Backend/internal/services/modules"
notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/personas"
"Yimaru-Backend/internal/services/practices"
"Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions"
@ -48,6 +49,7 @@ type App struct {
assessmentSvc *assessment.Service
questionsSvc *questions.Service
faqSvc *faqs.Service
personaSvc *personas.Service
examPrepSvc *examprep.Service
programSvc *programs.Service
courseSvc *courses.Service
@ -87,6 +89,7 @@ func NewApp(
assessmentSvc *assessment.Service,
questionsSvc *questions.Service,
faqSvc *faqs.Service,
personaSvc *personas.Service,
examPrepSvc *examprep.Service,
programSvc *programs.Service,
courseSvc *courses.Service,
@ -138,6 +141,7 @@ func NewApp(
assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc,
faqSvc: faqSvc,
personaSvc: personaSvc,
examPrepSvc: examPrepSvc,
programSvc: programSvc,
courseSvc: courseSvc,

View File

@ -44,6 +44,12 @@ func (h *Handler) CreateExamPrepPractice(c *fiber.Ctx) error {
Error: err.Error(),
})
}
if errors.Is(err, domain.ErrPersonaNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Persona not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to create practice",
Error: err.Error(),
@ -167,6 +173,12 @@ func (h *Handler) UpdateExamPrepPractice(c *fiber.Ctx) error {
Error: err.Error(),
})
}
if errors.Is(err, domain.ErrPersonaNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{
Message: "Persona not found",
Error: err.Error(),
})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to update practice",
Error: err.Error(),

View File

@ -20,6 +20,7 @@ import (
minioservice "Yimaru-Backend/internal/services/minio"
"Yimaru-Backend/internal/services/modules"
notificationservice "Yimaru-Backend/internal/services/notification"
"Yimaru-Backend/internal/services/personas"
"Yimaru-Backend/internal/services/practices"
"Yimaru-Backend/internal/services/programs"
"Yimaru-Backend/internal/services/questions"
@ -47,6 +48,7 @@ type Handler struct {
assessmentSvc *assessment.Service
questionsSvc *questions.Service
faqSvc *faqs.Service
personaSvc *personas.Service
examPrepSvc *examprep.Service
programSvc *programs.Service
courseSvc *courses.Service
@ -82,6 +84,7 @@ func New(
assessmentSvc *assessment.Service,
questionsSvc *questions.Service,
faqSvc *faqs.Service,
personaSvc *personas.Service,
examPrepSvc *examprep.Service,
programSvc *programs.Service,
courseSvc *courses.Service,
@ -116,6 +119,7 @@ func New(
assessmentSvc: assessmentSvc,
questionsSvc: questionsSvc,
faqSvc: faqSvc,
personaSvc: personaSvc,
examPrepSvc: examPrepSvc,
programSvc: programSvc,
courseSvc: courseSvc,

View File

@ -0,0 +1,166 @@
package handlers
import (
"Yimaru-Backend/internal/domain"
"errors"
personasservice "Yimaru-Backend/internal/services/personas"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
)
type listPersonasData struct {
Personas []domain.LmsPersona `json:"personas"`
TotalCount int64 `json:"total_count"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// CreatePersona godoc
// @Summary Create LMS persona catalog entry
// @Tags personas
// @Accept json
// @Param body body domain.CreateLmsPersonaInput true "Persona"
// @Success 201 {object} domain.Response
// @Router /api/v1/personas [post]
func (h *Handler) CreatePersona(c *fiber.Ctx) error {
var req domain.CreateLmsPersonaInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if valErrs, ok := h.validator.Validate(c, req); !ok {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Validation failed",
Error: firstValidationError(valErrs),
})
}
p, err := h.personaSvc.Create(c.Context(), req)
if err != nil {
if errors.Is(err, personasservice.ErrNameRequired) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Validation failed", Error: err.Error()})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to create persona", Error: err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Persona created successfully",
Data: p,
Success: true,
StatusCode: fiber.StatusCreated,
})
}
// ListPersonas godoc
// @Summary List LMS personas (catalog for practice assignment)
// @Tags personas
// @Param active_only query bool false "When true (default), return only active personas" default(true)
// @Param limit query int false "Page size"
// @Param offset query int false "Offset"
// @Router /api/v1/personas [get]
func (h *Handler) ListPersonas(c *fiber.Ctx) error {
activeOnlyStr := strings.ToLower(strings.TrimSpace(c.Query("active_only", "true")))
activeOnly := activeOnlyStr != "false"
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()})
}
items, total, err := h.personaSvc.List(c.Context(), activeOnly, int32(limit), int32(offset))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to list personas", Error: err.Error()})
}
return c.JSON(domain.Response{
Message: "Personas retrieved successfully",
Data: listPersonasData{
Personas: items,
TotalCount: total,
Limit: limit,
Offset: offset,
},
Success: true,
StatusCode: fiber.StatusOK,
})
}
// GetPersona godoc
// @Summary Get LMS persona by ID
// @Tags personas
// @Param id path int true "Persona ID"
// @Router /api/v1/personas/{id} [get]
func (h *Handler) GetPersona(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
msg := ""
if err != nil {
msg = err.Error()
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid persona id", Error: msg})
}
p, err := h.personaSvc.GetByID(c.Context(), id)
if err != nil {
if errors.Is(err, domain.ErrPersonaNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona not found", Error: err.Error()})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to load persona", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Persona retrieved successfully", Data: p, Success: true, StatusCode: fiber.StatusOK})
}
// UpdatePersona godoc
// @Summary Update LMS persona
// @Tags personas
// @Param id path int true "Persona ID"
// @Param body body domain.UpdateLmsPersonaInput true "Fields to update"
// @Router /api/v1/personas/{id} [put]
func (h *Handler) UpdatePersona(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
msg := ""
if err != nil {
msg = err.Error()
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid persona id", Error: msg})
}
var req domain.UpdateLmsPersonaInput
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid request body", Error: err.Error()})
}
p, err := h.personaSvc.Update(c.Context(), id, req)
if err != nil {
if errors.Is(err, domain.ErrPersonaNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona not found", Error: err.Error()})
}
if errors.Is(err, personasservice.ErrNameEmptyUpdate) {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Validation failed", Error: err.Error()})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update persona", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Persona updated successfully", Data: p, Success: true, StatusCode: fiber.StatusOK})
}
// DeletePersona godoc
// @Summary Delete LMS persona (practices referencing it will have persona_id cleared)
// @Tags personas
// @Param id path int true "Persona ID"
// @Router /api/v1/personas/{id} [delete]
func (h *Handler) DeletePersona(c *fiber.Ctx) error {
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
if err != nil || id <= 0 {
msg := ""
if err != nil {
msg = err.Error()
}
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid persona id", Error: msg})
}
if err := h.personaSvc.Delete(c.Context(), id); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to delete persona", Error: err.Error()})
}
return c.JSON(domain.Response{Message: "Persona deleted successfully", Success: true, StatusCode: fiber.StatusOK})
}

View File

@ -43,8 +43,8 @@ func (h *Handler) CreatePractice(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Lesson not found", Error: err.Error()})
case errors.Is(err, practices.ErrQuestionSetNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Question set not found", Error: err.Error()})
case errors.Is(err, domain.ErrUserNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona user not found", Error: err.Error()})
case errors.Is(err, domain.ErrPersonaNotFound):
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona not found", Error: err.Error()})
case errors.Is(err, practices.ErrInvalidPracticeParent):
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{Message: "Invalid parent", Error: err.Error()})
}
@ -205,8 +205,8 @@ func (h *Handler) UpdatePractice(c *fiber.Ctx) error {
if errors.Is(err, practices.ErrQuestionSetNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Question set not found", Error: err.Error()})
}
if errors.Is(err, domain.ErrUserNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona user not found", Error: err.Error()})
if errors.Is(err, domain.ErrPersonaNotFound) {
return c.Status(fiber.StatusNotFound).JSON(domain.ErrorResponse{Message: "Persona not found", Error: err.Error()})
}
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{Message: "Failed to update practice", Error: err.Error()})
}

View File

@ -16,6 +16,7 @@ func (a *App) initAppRoutes() {
a.assessmentSvc,
a.questionsSvc,
a.faqSvc,
a.personaSvc,
a.examPrepSvc,
a.programSvc,
a.courseSvc,
@ -151,6 +152,13 @@ func (a *App) initAppRoutes() {
groupV1.Put("/practices/:id", a.authMiddleware, a.RequirePermission("practices.update"), h.UpdatePractice)
groupV1.Delete("/practices/:id", a.authMiddleware, a.RequirePermission("practices.delete"), h.DeletePractice)
// LMS personas (catalog referenced by persona_id on practices)
groupV1.Get("/personas", a.authMiddleware, a.RequirePermission("personas.list"), h.ListPersonas)
groupV1.Post("/personas", a.authMiddleware, a.RequirePermission("personas.create"), h.CreatePersona)
groupV1.Get("/personas/:id", a.authMiddleware, a.RequirePermission("personas.get"), h.GetPersona)
groupV1.Put("/personas/:id", a.authMiddleware, a.RequirePermission("personas.update"), h.UpdatePersona)
groupV1.Delete("/personas/:id", a.authMiddleware, a.RequirePermission("personas.delete"), h.DeletePersona)
// File storage (MinIO)
groupV1.Get("/files/url", a.authMiddleware, h.GetFileURL)
groupV1.Post("/files/refresh-url", a.authMiddleware, h.RefreshFileURL)