From fbe9927ac381610381de1a46b8ef9a0836b235cc Mon Sep 17 00:00:00 2001 From: Aleksandr Trushkin Date: Mon, 18 Dec 2023 00:14:07 +0300 Subject: [PATCH] able to update desc for course --- cmd/kuriweb/main.go | 8 +- .../kurious/adapters/ydb_course_repository.go | 142 +++++++++++++++++- internal/kurious/app/app.go | 7 +- .../app/command/updatecoursedescription.go | 35 +++++ internal/kurious/domain/repository.go | 3 + internal/kurious/ports/http/course.go | 62 ++++++++ internal/kurious/ports/http/course_test.go | 60 ++++++++ internal/kurious/ports/http/listtemplate.go | 92 +++++++++--- internal/kurious/service/service.go | 7 +- 9 files changed, 384 insertions(+), 32 deletions(-) create mode 100644 internal/kurious/app/command/updatecoursedescription.go create mode 100644 internal/kurious/ports/http/course_test.go diff --git a/cmd/kuriweb/main.go b/cmd/kuriweb/main.go index f6c34c3..18d5a8f 100644 --- a/cmd/kuriweb/main.go +++ b/cmd/kuriweb/main.go @@ -94,9 +94,13 @@ func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server router := mux.NewRouter() coursesAPI := srv.Courses() + + router.Use(mux.CORSMethodMiddleware(router)) + router.Use(middlewareLogger(log)) + router.HandleFunc("/updatedesc", coursesAPI.UdpateDescription).Methods(http.MethodPost) coursesRouter := router.PathPrefix("/courses").Subrouter() - coursesRouter.Use(middlewareLogger(log)) - coursesRouter.HandleFunc("/", coursesAPI.List) + coursesRouter.HandleFunc("/", coursesAPI.List).Methods(http.MethodGet) + coursesRouter.HandleFunc("/{course_id}", coursesAPI.Get).Methods(http.MethodGet) return &http.Server{ Addr: cfg.ListenAddr, diff --git a/internal/kurious/adapters/ydb_course_repository.go b/internal/kurious/adapters/ydb_course_repository.go index a7088ba..765df71 100644 --- a/internal/kurious/adapters/ydb_course_repository.go +++ b/internal/kurious/adapters/ydb_course_repository.go @@ -151,12 +151,18 @@ func (r *ydbCourseRepository) List( appendTextParam("course_thematic", params.CourseThematic) appendTextParam("learning_type", params.LearningType) + opts = append( + opts, + table.ValueParam("$id", types.TextValue(params.NextPageToken)), + table.ValueParam("$limit", types.Int32Value(int32(params.Limit))), + ) + query, err := qtParams.render() if err != nil { return result, fmt.Errorf("rendering: %w", err) } - xcontext.LogInfo(ctx, r.log, "planning to run query", slog.String("query", query), slog.Any("opts", opts)) + xcontext.LogInfo(ctx, r.log, "planning to run query", slog.String("query", query), slog.String("opts", tableParamOptsToString(opts...))) courses := make([]domain.Course, 0, 1_000) readTx := table.TxControl( @@ -319,10 +325,31 @@ func (r *ydbCourseRepository) GetByExternalID(ctx context.Context, id string) (d return domain.Course{}, nil } -func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value { - st := mapSourceTypeFromDomain(params.SourceType) +type updateCourseParams struct { + domain.CreateCourseParams + + CreatedAt time.Time + DeletedAt nullable.Value[time.Time] +} + +func updateCourseParamsAsStruct(params updateCourseParams) types.Value { + opts := createCourseParamsAsStructValues(params.CreateCourseParams) now := time.Now() return types.StructValue( + append( + opts[:len(opts)-3], + types.StructFieldValue("created_at", types.DatetimeValueFromTime(params.CreatedAt)), + types.StructFieldValue("updated_at", types.DatetimeValueFromTime(now)), + types.StructFieldValue("deleted_at", types.NullableDatetimeValue(nil)), + )..., + ) +} + +func createCourseParamsAsStructValues(params domain.CreateCourseParams) []types.StructValueOption { + st := mapSourceTypeFromDomain(params.SourceType) + now := time.Now() + + return []types.StructValueOption{ types.StructFieldValue("id", types.TextValue(params.ID)), types.StructFieldValue("name", types.TextValue(params.Name)), types.StructFieldValue("source_type", types.TextValue(st)), @@ -341,6 +368,12 @@ func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value { types.StructFieldValue("created_at", types.DatetimeValueFromTime(now)), types.StructFieldValue("updated_at", types.DatetimeValueFromTime(now)), types.StructFieldValue("deleted_at", types.NullableDatetimeValue(nil)), + } +} + +func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value { + return types.StructValue( + createCourseParamsAsStructValues(params)..., ) } @@ -424,6 +457,100 @@ func (r *ydbCourseRepository) Delete(ctx context.Context, id string) error { return nil } +func (r *ydbCourseRepository) UpdateCourseDescription(ctx context.Context, id, description string) error { + course, err := r.Get(ctx, id) + if err != nil { + return fmt.Errorf("getting course: %w", err) + } + + params := updateCourseParams{ + CreateCourseParams: domain.CreateCourseParams{ + ID: course.ID, + ExternalID: course.ExternalID, + Name: course.Name, + SourceType: course.SourceType, + SourceName: course.SourceName, + CourseThematic: course.Thematic, + LearningType: course.LearningType, + OrganizationID: course.OrganizationID, + OriginLink: course.OriginLink, + ImageLink: course.ImageLink, + Description: description, + FullPrice: course.FullPrice, + Discount: course.Discount, + Duration: course.Duration, + StartsAt: course.StartsAt, + }, + CreatedAt: course.CreatedAt, + DeletedAt: course.DeletedAt, + } + + updateStruct := updateCourseParamsAsStruct(params) + + const upsertQuery = `DECLARE $courseData AS List, + name: Text, + source_type: Text, + source_name: Optional, + course_thematic: Text, + learning_type: Text, + organization_id: Text, + origin_link: Text, + image_link: Text, + description: Text, + full_price: Double, + discount: Double, + duration: Interval, + starts_at: Datetime, + created_at: Datetime, + updated_at: Datetime, + deleted_at: Optional>>; + + REPLACE INTO + courses + SELECT + id, + external_id, + name, + source_type, + source_name, + course_thematic, + learning_type, + organization_id, + origin_link, + image_link, + description, + full_price, + discount, + duration, + starts_at, + created_at, + updated_at, + deleted_at + FROM AS_TABLE($courseData);` + + writeTx := table.TxControl( + table.BeginTx( + table.WithSerializableReadWrite(), + ), + table.CommitTx(), + ) + err = r.db.Table().Do(ctx, func(ctx context.Context, s table.Session) error { + queryParams := table.NewQueryParameters( + table.ValueParam("$courseData", types.ListValue(updateStruct)), + ) + _, _, err := s.Execute(ctx, writeTx, upsertQuery, queryParams) + if err != nil { + return fmt.Errorf("executing query: %w", err) + } + + return nil + }) + + return err +} + func (r *ydbCourseRepository) CreateCourseTable(ctx context.Context) error { return r.db.Table().Do(ctx, func(ctx context.Context, s table.Session) error { return s.CreateTable( @@ -595,3 +722,12 @@ WHERE {{ range .Conditions }}{{.}}{{end}} {{.Suffix}}` var querySelect = template.Must(template.New("").Parse(queryTemplateSelect)) + +func tableParamOptsToString(in ...table.ParameterOption) string { + var sb strings.Builder + for _, opt := range in { + sb.WriteString(opt.Name() + "(" + opt.Value().Type().String() + ");") + } + + return sb.String() +} diff --git a/internal/kurious/app/app.go b/internal/kurious/app/app.go index 0e20a99..bd44758 100644 --- a/internal/kurious/app/app.go +++ b/internal/kurious/app/app.go @@ -6,9 +6,10 @@ import ( ) type Commands struct { - InsertCourses command.CreateCoursesHandler - InsertCourse command.CreateCourseHandler - DeleteCourse command.DeleteCourseHandler + InsertCourses command.CreateCoursesHandler + InsertCourse command.CreateCourseHandler + DeleteCourse command.DeleteCourseHandler + UpdateCourseDescription command.UpdateCourseDescriptionHandler } type Queries struct { diff --git a/internal/kurious/app/command/updatecoursedescription.go b/internal/kurious/app/command/updatecoursedescription.go new file mode 100644 index 0000000..8788088 --- /dev/null +++ b/internal/kurious/app/command/updatecoursedescription.go @@ -0,0 +1,35 @@ +package command + +import ( + "context" + "log/slog" + + "git.loyso.art/frx/kurious/internal/common/decorator" + "git.loyso.art/frx/kurious/internal/kurious/domain" +) + +type UpdateCourseDescription struct { + ID string + Description string +} + +type UpdateCourseDescriptionHandler decorator.CommandHandler[UpdateCourseDescription] + +type updateCourseDescriptionHandler struct { + repo domain.CourseRepository +} + +func NewUpdateCourseDescriptionHandler( + repo domain.CourseRepository, + log *slog.Logger, +) UpdateCourseDescriptionHandler { + h := updateCourseDescriptionHandler{ + repo: repo, + } + + return decorator.ApplyCommandDecorators(h, log) +} + +func (h updateCourseDescriptionHandler) Handle(ctx context.Context, cmd UpdateCourseDescription) error { + return h.repo.UpdateCourseDescription(ctx, cmd.ID, cmd.Description) +} diff --git a/internal/kurious/domain/repository.go b/internal/kurious/domain/repository.go index d9cde2e..30a3fd8 100644 --- a/internal/kurious/domain/repository.go +++ b/internal/kurious/domain/repository.go @@ -56,6 +56,9 @@ type CourseRepository interface { Create(context.Context, CreateCourseParams) (Course, error) // Delete course by id. Delete(ctx context.Context, id string) error + + // UpdateCourseDescription is a temporary method to udpate description field + UpdateCourseDescription(ctx context.Context, id string, description string) error } type CreateOrganizationParams struct { diff --git a/internal/kurious/ports/http/course.go b/internal/kurious/ports/http/course.go index 5290443..be63174 100644 --- a/internal/kurious/ports/http/course.go +++ b/internal/kurious/ports/http/course.go @@ -1,15 +1,21 @@ package http import ( + "encoding/json" "log/slog" "net/http" + "sort" "strconv" "git.loyso.art/frx/kurious/internal/common/errors" + "git.loyso.art/frx/kurious/internal/common/xcontext" "git.loyso.art/frx/kurious/internal/common/xslice" + "git.loyso.art/frx/kurious/internal/kurious/app/command" "git.loyso.art/frx/kurious/internal/kurious/app/query" "git.loyso.art/frx/kurious/internal/kurious/domain" "git.loyso.art/frx/kurious/internal/kurious/service" + + "github.com/gorilla/mux" ) type courseServer struct { @@ -108,7 +114,14 @@ func mapDomainCourseToTemplate(in ...domain.Course) listCoursesTemplateParams { outCategory.Subcategories = append(outCategory.Subcategories, outSubCategory) } + sort.Slice(outCategory.Subcategories, func(i, j int) bool { + return outCategory.Subcategories[i].ID < outCategory.Subcategories[j].ID + }) + out.Categories = append(out.Categories, outCategory) } + sort.Slice(out.Categories, func(i, j int) bool { + return out.Categories[i].ID < out.Categories[j].ID + }) return out } @@ -139,3 +152,52 @@ func (c courseServer) List(w http.ResponseWriter, r *http.Request) { return } } + +func (c courseServer) Get(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id := mux.Vars(r)["course_id"] + course, err := c.app.Queries.GetCourse.Handle(ctx, query.GetCourse{ + ID: id, + }) + if handleError(ctx, err, w, c.log, "unable to get course") { + return + } + + payload, err := json.MarshalIndent(course, "", " ") + if handleError(ctx, err, w, c.log, "unable to marshal json") { + return + } + w.Header().Set("content-type", "application/json") + w.Header().Set("content-length", strconv.Itoa(len(payload))) + + _, err = w.Write([]byte(payload)) + if err != nil { + xcontext.LogWithWarnError(ctx, c.log, err, "unable to write a message") + } +} + +func (c courseServer) UdpateDescription(w http.ResponseWriter, r *http.Request) { + type requestModel struct { + ID string `json:"id"` + Text string `json:"text"` + } + + ctx := r.Context() + + var req requestModel + err := json.NewDecoder(r.Body).Decode(&req) + if handleError(ctx, err, w, c.log, "unable to read body") { + return + } + + err = c.app.Commands.UpdateCourseDescription.Handle(ctx, command.UpdateCourseDescription{ + ID: req.ID, + Description: req.Text, + }) + if handleError(ctx, err, w, c.log, "unable to update course description") { + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/internal/kurious/ports/http/course_test.go b/internal/kurious/ports/http/course_test.go new file mode 100644 index 0000000..7f62c6a --- /dev/null +++ b/internal/kurious/ports/http/course_test.go @@ -0,0 +1,60 @@ +package http + +import ( + "strconv" + "strings" + "testing" + "time" + + "git.loyso.art/frx/kurious/internal/kurious/domain" +) + +var courses = func() []domain.Course { + out := make([]domain.Course, 0) + out = append(out, makeBatchCourses("prog", []string{"go", "rust"}, 4)...) + out = append(out, makeBatchCourses("front", []string{"js", "html"}, 4)...) + + return out +}() + +func makeBatchCourses(lt string, cts []string, num int) []domain.Course { + out := make([]domain.Course, 0, len(cts)*num) + for _, ct := range cts { + for i := 0; i < num; i++ { + name := strings.Join([]string{ + lt, ct, + strconv.Itoa(i), + }, ".") + out = append(out, makeCourse(lt, ct, name)) + } + } + + return out +} + +func makeCourse(lt, ct, name string) domain.Course { + return domain.Course{ + LearningType: lt, + Thematic: ct, + Name: name, + ID: lt + ct + name, + FullPrice: 123, + Duration: time.Second * 100, + StartsAt: time.Now(), + } +} + +func TestRenderTemplate(t *testing.T) { + t.SkipNow() + result := mapDomainCourseToTemplate(courses...) + t.Logf("%#v", result) + + var out strings.Builder + err := listTemplateParsed.ExecuteTemplate(&out, "courses", result) + if err != nil { + t.Fatalf("executing: %v", err) + } + + t.Log(out.String()) + t.Fail() +} diff --git a/internal/kurious/ports/http/listtemplate.go b/internal/kurious/ports/http/listtemplate.go index 40c3b25..9c77103 100644 --- a/internal/kurious/ports/http/listtemplate.go +++ b/internal/kurious/ports/http/listtemplate.go @@ -45,13 +45,21 @@ const listTemplate = `{{define "courses"}} p { margin-bottom: 10px; } + .main-course { + background-color: #3B4252; + color: #E5E9F0; + text-align: center; + } + .sub-course { + background-color: #4C566A; + color: #ECEFF4; + } .course-plate { background-color: #f2f2f2; border: 1px solid #ddd; border-radius: 5px; padding: 10px; margin-bottom: 10px; - text-align: center; } .course-plate a { color: #333; @@ -60,6 +68,14 @@ const listTemplate = `{{define "courses"}} .course-plate a:hover { text-decoration: underline; } + .editable-text { + cursor: pointer; + } + .editable-text.editing { + border: 1px solid #000; + padding: 5px; + width: 100%; + } @@ -73,27 +89,61 @@ const listTemplate = `{{define "courses"}} -

Courses

- {{range $category := .Categories}} -

{{$category.Name}}

-

{{$category.Description}}

- {{range $subcategory := .Subcategories}} -

{{$subcategory.Name}}

-

{{$subcategory.Description}}

- {{range $course := $.Courses}} -
-

{{$course.Name}}

-

{{$course.Description}}

-

Full price: {{$course.FullPrice}}

-

Discount: {{$course.Discount}}

-

Thematic: {{$course.Thematic}}

-

Learning type: {{$course.LearningType}}

-

Duration: {{$course.Duration}}

-

Starts at: {{$course.StartsAt}}

-
+ {{range $category := .Categories}} +

Category {{$category.Name}}

+

Course Description: {{$category.Description}}

+ {{range $subcategory := $category.Subcategories}} +
+

Subcategory: {{$subcategory.Name}}

+

Subcategory Description: {{$subcategory.Description}}

+ {{range $course := $subcategory.Courses}} +
+

{{$course.Name}}

+

Description:

{{or $course.Description "..."}}

+

Full price: {{$course.FullPrice}}

+

Discount: {{$course.Discount}}

+

Thematic: {{$course.Thematic}}

+

Learning type: {{$course.LearningType}}

+

Duration: {{$course.Duration}}

+

Starts at: {{$course.StartsAt}}

+
+
+ {{end}} + {{end}} {{end}} - {{end}} - {{end}} + + {{end}}` diff --git a/internal/kurious/service/service.go b/internal/kurious/service/service.go index 87f018a..0b181c0 100644 --- a/internal/kurious/service/service.go +++ b/internal/kurious/service/service.go @@ -36,9 +36,10 @@ func NewApplication(ctx context.Context, cfg ApplicationConfig) (Application, er application := app.Application{ Commands: app.Commands{ - InsertCourses: command.NewCreateCoursesHandler(courseadapter, log), - InsertCourse: command.NewCreateCourseHandler(courseadapter, log), - DeleteCourse: command.NewDeleteCourseHandler(courseadapter, log), + InsertCourses: command.NewCreateCoursesHandler(courseadapter, log), + InsertCourse: command.NewCreateCourseHandler(courseadapter, log), + DeleteCourse: command.NewDeleteCourseHandler(courseadapter, log), + UpdateCourseDescription: command.NewUpdateCourseDescriptionHandler(courseadapter, log), }, Queries: app.Queries{ GetCourse: query.NewGetCourseHandler(courseadapter, log),