diff --git a/cmd/main.go b/cmd/main.go index 2fba5ca..2590b5a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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, diff --git a/db/migrations/000063_lms_personas.down.sql b/db/migrations/000063_lms_personas.down.sql new file mode 100644 index 0000000..40384d4 --- /dev/null +++ b/db/migrations/000063_lms_personas.down.sql @@ -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; diff --git a/db/migrations/000063_lms_personas.up.sql b/db/migrations/000063_lms_personas.up.sql new file mode 100644 index 0000000..a38060d --- /dev/null +++ b/db/migrations/000063_lms_personas.up.sql @@ -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; diff --git a/db/query/lms_personas.sql b/db/query/lms_personas.sql new file mode 100644 index 0000000..352bfb0 --- /dev/null +++ b/db/query/lms_personas.sql @@ -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; diff --git a/docs/docs.go b/docs/docs.go index 1408e45..77c4854 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index 99f7481..b137b00 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e56b056..4b0c345 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/gen/db/lms_personas.sql.go b/gen/db/lms_personas.sql.go new file mode 100644 index 0000000..b7d0126 --- /dev/null +++ b/gen/db/lms_personas.sql.go @@ -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 +} diff --git a/gen/db/models.go b/gen/db/models.go index a4e6e91..966b23b 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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"` diff --git a/internal/domain/exam_prep_practice.go b/internal/domain/exam_prep_practice.go index 91e335d..b62aaf8 100644 --- a/internal/domain/exam_prep_practice.go +++ b/internal/domain/exam_prep_practice.go @@ -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"` diff --git a/internal/domain/lms_persona.go b/internal/domain/lms_persona.go new file mode 100644 index 0000000..2e1256d --- /dev/null +++ b/internal/domain/lms_persona.go @@ -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"` +} diff --git a/internal/domain/practice.go b/internal/domain/practice.go index 765b50e..e620f42 100644 --- a/internal/domain/practice.go +++ b/internal/domain/practice.go @@ -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"` diff --git a/internal/ports/lms_persona.go b/internal/ports/lms_persona.go new file mode 100644 index 0000000..26a2df8 --- /dev/null +++ b/internal/ports/lms_persona.go @@ -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) +} diff --git a/internal/repository/lms_personas.go b/internal/repository/lms_personas.go new file mode 100644 index 0000000..68ecce6 --- /dev/null +++ b/internal/repository/lms_personas.go @@ -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 +} diff --git a/internal/services/examprep/service.go b/internal/services/examprep/service.go index 61237b9..0ac6ed1 100644 --- a/internal/services/examprep/service.go +++ b/internal/services/examprep/service.go @@ -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) { diff --git a/internal/services/personas/service.go b/internal/services/personas/service.go new file mode 100644 index 0000000..11a8bfb --- /dev/null +++ b/internal/services/personas/service.go @@ -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) +} diff --git a/internal/services/practices/service.go b/internal/services/practices/service.go index db7d985..0551988 100644 --- a/internal/services/practices/service.go +++ b/internal/services/practices/service.go @@ -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 } } diff --git a/internal/services/rbac/seeds.go b/internal/services/rbac/seeds.go index 2f6e79c..497f192 100644 --- a/internal/services/rbac/seeds.go +++ b/internal/services/rbac/seeds.go @@ -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", diff --git a/internal/web_server/app.go b/internal/web_server/app.go index ee7b4c4..b11417d 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -16,7 +16,8 @@ import ( "Yimaru-Backend/internal/services/lmsprogress" minioservice "Yimaru-Backend/internal/services/minio" "Yimaru-Backend/internal/services/modules" - notificationservice "Yimaru-Backend/internal/services/notification" + 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, diff --git a/internal/web_server/handlers/exam_prep_practice_handler.go b/internal/web_server/handlers/exam_prep_practice_handler.go index 34cba6b..f3a57be 100644 --- a/internal/web_server/handlers/exam_prep_practice_handler.go +++ b/internal/web_server/handlers/exam_prep_practice_handler.go @@ -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(), diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 42a1bc6..ad0ac4e 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -19,7 +19,8 @@ import ( "Yimaru-Backend/internal/services/lmsprogress" minioservice "Yimaru-Backend/internal/services/minio" "Yimaru-Backend/internal/services/modules" - notificationservice "Yimaru-Backend/internal/services/notification" + 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, diff --git a/internal/web_server/handlers/lms_persona_handler.go b/internal/web_server/handlers/lms_persona_handler.go new file mode 100644 index 0000000..8800352 --- /dev/null +++ b/internal/web_server/handlers/lms_persona_handler.go @@ -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}) +} diff --git a/internal/web_server/handlers/practice_handler.go b/internal/web_server/handlers/practice_handler.go index 1241ffe..f0c10a9 100644 --- a/internal/web_server/handlers/practice_handler.go +++ b/internal/web_server/handlers/practice_handler.go @@ -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()}) } diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 88c8197..51f3f5b 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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)