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:
parent
71bc09a638
commit
873be1b482
|
|
@ -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,
|
||||
|
|
|
|||
17
db/migrations/000063_lms_personas.down.sql
Normal file
17
db/migrations/000063_lms_personas.down.sql
Normal 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;
|
||||
34
db/migrations/000063_lms_personas.up.sql
Normal file
34
db/migrations/000063_lms_personas.up.sql
Normal 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
42
db/query/lms_personas.sql
Normal 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;
|
||||
154
docs/docs.go
154
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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
182
gen/db/lms_personas.sql.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
34
internal/domain/lms_persona.go
Normal file
34
internal/domain/lms_persona.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
21
internal/ports/lms_persona.go
Normal file
21
internal/ports/lms_persona.go
Normal 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)
|
||||
}
|
||||
115
internal/repository/lms_personas.go
Normal file
115
internal/repository/lms_personas.go
Normal 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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
91
internal/services/personas/service.go
Normal file
91
internal/services/personas/service.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
166
internal/web_server/handlers/lms_persona_handler.go
Normal file
166
internal/web_server/handlers/lms_persona_handler.go
Normal 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})
|
||||
}
|
||||
|
|
@ -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()})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user