- pagination: clamp per_page to [1,100] and page to >=1 in the parser, guard the TotalPages division against per_page=0 (panic), and clamp the current page to [1,totalPages]; preserves cursor (next-token) mode - middleware: add panic-recovery as the outermost middleware so handler panics return a 500 instead of crashing the process; re-panics http.ErrAbortHandler to keep file serving intact - index: bound the index page query (Limit:200) so it no longer drains the entire courses table in 1000-row batches
419 lines
11 KiB
Go
419 lines
11 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"sync"
|
|
|
|
"git.loyso.art/frx/kurious/internal/common/xslices"
|
|
"git.loyso.art/frx/kurious/internal/kurious/app/query"
|
|
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
|
"git.loyso.art/frx/kurious/internal/kurious/ports/http/bootstrap"
|
|
"git.loyso.art/frx/kurious/internal/kurious/service"
|
|
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/codes"
|
|
"go.opentelemetry.io/otel/trace"
|
|
)
|
|
|
|
var (
|
|
paramsAttr = attribute.Key("params")
|
|
|
|
webtracer = otel.Tracer("kuriweb.http")
|
|
)
|
|
|
|
type courseTemplServer struct {
|
|
app service.Application
|
|
log *slog.Logger
|
|
}
|
|
|
|
func makeTemplListCoursesParams(counts map[string]domain.LearningTypeStat, in ...domain.Course) bootstrap.ListCoursesParams {
|
|
coursesBySubcategory := make(map[string][]bootstrap.CourseInfo, len(in))
|
|
subcategoriesByCategories := make(map[string]map[string]struct{}, len(in))
|
|
categoryByID := make(map[string]bootstrap.CategoryBaseInfo, len(in))
|
|
seenCourses := make(map[string]struct{}, len(in))
|
|
|
|
var out bootstrap.ListCoursesParams
|
|
xslices.ForEach(in, func(c domain.Course) {
|
|
courseInfo := bootstrap.CourseInfo{
|
|
ID: c.ID,
|
|
Name: c.Name,
|
|
FullPrice: int(c.FullPrice),
|
|
ImageLink: c.ImageLink,
|
|
OriginLink: c.OriginLink,
|
|
}
|
|
|
|
coursesBySubcategory[c.ThematicID] = append(coursesBySubcategory[c.ThematicID], courseInfo)
|
|
|
|
if _, ok := subcategoriesByCategories[c.LearningTypeID]; !ok {
|
|
subcategoriesByCategories[c.LearningTypeID] = map[string]struct{}{}
|
|
}
|
|
subcategoriesByCategories[c.LearningTypeID][c.ThematicID] = struct{}{}
|
|
|
|
if _, ok := categoryByID[c.LearningTypeID]; !ok {
|
|
categoryByID[c.LearningTypeID] = bootstrap.CategoryBaseInfo{
|
|
ID: c.LearningTypeID,
|
|
Name: c.LearningType,
|
|
}
|
|
}
|
|
if _, ok := categoryByID[c.ThematicID]; !ok {
|
|
categoryByID[c.ThematicID] = bootstrap.CategoryBaseInfo{
|
|
ID: c.ThematicID,
|
|
Name: c.Thematic,
|
|
Count: counts[c.LearningTypeID].CourseThematic[c.ThematicID],
|
|
}
|
|
}
|
|
|
|
if _, ok := seenCourses[c.ExternalID.Value()]; ok && c.ExternalID.Valid() {
|
|
return
|
|
}
|
|
|
|
out.Courses = append(out.Courses, courseInfo)
|
|
seenCourses[c.ExternalID.Value()] = struct{}{}
|
|
})
|
|
|
|
for categoryID, subcategoriesID := range subcategoriesByCategories {
|
|
outCategory := bootstrap.CategoryContainer{
|
|
CategoryBaseInfo: categoryByID[categoryID],
|
|
}
|
|
|
|
for subcategoryID := range subcategoriesID {
|
|
outSubcategory := bootstrap.SubcategoryContainer{
|
|
CategoryBaseInfo: categoryByID[subcategoryID],
|
|
Courses: coursesBySubcategory[subcategoryID],
|
|
}
|
|
|
|
outCategory.Subcategories = append(outCategory.Subcategories, outSubcategory)
|
|
}
|
|
|
|
out.Categories = append(out.Categories, outCategory)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
var span trace.Span
|
|
ctx, span = webtracer.Start(ctx, "http_server.list")
|
|
defer func() {
|
|
span.End()
|
|
}()
|
|
|
|
stats := bootstrap.MakeNewStats(10_240, 2_560_000, 1800)
|
|
|
|
pathParams, err := parseListCoursesParams(r)
|
|
if handleError(ctx, err, w, c.log, "unable to parse list courses params") {
|
|
return
|
|
}
|
|
|
|
jsonParams, _ := json.Marshal(pathParams)
|
|
span.SetAttributes(paramsAttr.String(string(jsonParams)))
|
|
|
|
var offset int
|
|
if pathParams.Page > 0 {
|
|
offset = (pathParams.Page - 1) * pathParams.PerPage
|
|
}
|
|
listCoursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
|
|
CourseThematic: pathParams.CourseThematic,
|
|
LearningType: pathParams.LearningType,
|
|
OrganizationID: pathParams.School,
|
|
OrderBy: orderByListing.getField(pathParams.OrderBy),
|
|
Ascending: pathParams.Ascending,
|
|
Limit: pathParams.PerPage,
|
|
NextPageToken: pathParams.NextPageToken,
|
|
Offset: offset,
|
|
})
|
|
if handleError(ctx, err, w, c.log, "unable to list courses") {
|
|
return
|
|
}
|
|
|
|
statsresult, err := c.app.Queries.ListCourseStatistics.Handle(ctx, query.ListCoursesStats{
|
|
LearningTypeID: pathParams.LearningType,
|
|
CourseThematicsID: pathParams.CourseThematic,
|
|
OrganizationID: pathParams.School,
|
|
})
|
|
if handleError(ctx, err, w, c.log, "unable to load stats") {
|
|
return
|
|
}
|
|
|
|
params := makeTemplListCoursesParams(statsresult.StatsByLearningType, listCoursesResult.Courses...)
|
|
|
|
learningTypeResult, err := c.app.Queries.ListLearningTypes.Handle(ctx, query.ListLearningTypes{})
|
|
if handleError(ctx, err, w, c.log, "unable to list learning types") {
|
|
return
|
|
}
|
|
|
|
params.FilterForm.AvailableLearningTypes = xslices.Map(learningTypeResult.LearningTypes, func(in query.LearningType) bootstrap.Category {
|
|
outcategory := bootstrap.Category{
|
|
ID: in.ID,
|
|
Name: in.Name,
|
|
}
|
|
if in.ID == pathParams.LearningType {
|
|
params.FilterForm.ActiveLearningType = outcategory
|
|
}
|
|
|
|
return outcategory
|
|
})
|
|
|
|
if pathParams.LearningType != "" {
|
|
courseThematicsResult, err := c.app.Queries.ListCourseThematics.Handle(ctx, query.ListCourseThematics{
|
|
LearningTypeID: pathParams.LearningType,
|
|
})
|
|
if handleError(ctx, err, w, c.log, "unable to list course thematics") {
|
|
return
|
|
}
|
|
|
|
params.FilterForm.AvailableCourseThematics = xslices.Map(courseThematicsResult.CourseThematics, func(in query.CourseThematic) bootstrap.Category {
|
|
outcategory := bootstrap.Category{
|
|
ID: in.ID,
|
|
Name: in.Name,
|
|
}
|
|
if pathParams.CourseThematic == in.ID {
|
|
params.FilterForm.ActiveCourseThematic = outcategory
|
|
}
|
|
|
|
return outcategory
|
|
})
|
|
}
|
|
|
|
organizaions, err := c.app.Queries.ListOrganizationsStats.Handle(ctx, query.ListOrganizationsStats{
|
|
LearningTypeID: pathParams.LearningType,
|
|
CourseThematicID: pathParams.CourseThematic,
|
|
})
|
|
if handleError(ctx, err, w, c.log, "unable to list organizations") {
|
|
return
|
|
}
|
|
|
|
organizationStatSortFunc := func(lhs, rhs domain.OrganizationStat) int {
|
|
if lhs.CoursesCount > rhs.CoursesCount {
|
|
return -1
|
|
} else if lhs.CoursesCount < rhs.CoursesCount {
|
|
return 1
|
|
}
|
|
|
|
if lhs.ID > rhs.ID {
|
|
return 1
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
slices.SortFunc(organizaions, organizationStatSortFunc)
|
|
|
|
schools := xslices.Map(organizaions, func(in domain.OrganizationStat) bootstrap.NameIDPair {
|
|
return bootstrap.NameIDPair{
|
|
ID: in.ID,
|
|
Name: fmt.Sprintf("%s (count: %d)", in.Name, in.CoursesCount),
|
|
}
|
|
})
|
|
|
|
totalPages := 0
|
|
if pathParams.PerPage > 0 {
|
|
totalPages = listCoursesResult.Count / pathParams.PerPage
|
|
}
|
|
currentPage := pathParams.Page
|
|
if currentPage > 0 && totalPages > 0 && currentPage > totalPages {
|
|
currentPage = totalPages
|
|
}
|
|
|
|
params = bootstrap.ListCoursesParams{
|
|
FilterForm: bootstrap.FilterFormParams{
|
|
Render: true,
|
|
BreadcrumbsParams: bootstrap.BreadcrumbsParams{
|
|
ActiveLearningType: params.FilterForm.ActiveLearningType,
|
|
ActiveCourseThematic: params.FilterForm.ActiveCourseThematic,
|
|
},
|
|
AvailableLearningTypes: params.FilterForm.AvailableLearningTypes,
|
|
AvailableCourseThematics: params.FilterForm.AvailableCourseThematics,
|
|
Schools: bootstrap.CoursesFilterViewParams{
|
|
SelectedSchoolID: pathParams.School,
|
|
Schools: schools,
|
|
Ascending: pathParams.Ascending,
|
|
OrderBy: pathParams.OrderBy,
|
|
OrderFields: orderByListing.asNameIDPair(),
|
|
},
|
|
},
|
|
Courses: params.Courses,
|
|
Categories: params.Categories,
|
|
Pagination: bootstrap.Pagination{
|
|
Page: currentPage,
|
|
TotalPages: totalPages,
|
|
BaseURL: r.URL.Path,
|
|
},
|
|
}
|
|
|
|
c.log.DebugContext(
|
|
ctx, "params rendered",
|
|
slog.Int("course_thematic", len(params.FilterForm.AvailableCourseThematics)),
|
|
slog.Int("learning_type", len(params.FilterForm.AvailableLearningTypes)),
|
|
slog.Int("items", len(listCoursesResult.Courses)),
|
|
slog.Int("page", params.Pagination.Page),
|
|
slog.Int("total_pages", params.Pagination.TotalPages),
|
|
)
|
|
|
|
slices.SortFunc(params.Categories, func(lhs, rhs bootstrap.CategoryContainer) int {
|
|
if lhs.Count > rhs.Count {
|
|
return 1
|
|
} else if lhs.Count < rhs.Count {
|
|
return -1
|
|
} else {
|
|
return 0
|
|
}
|
|
})
|
|
|
|
span.AddEvent("starting to render")
|
|
if pathParams.CourseThematic == "" {
|
|
params.FilterForm.Render = true
|
|
err = bootstrap.ListCourseThematics(bootstrap.PageCourses, stats, params).Render(ctx, w)
|
|
} else {
|
|
err = bootstrap.ListCourses(bootstrap.PageCourses, stats, params).Render(ctx, w)
|
|
}
|
|
span.AddEvent("render finished")
|
|
|
|
if handleError(ctx, err, w, c.log, "unable to render list courses") {
|
|
return
|
|
}
|
|
|
|
span.SetStatus(codes.Ok, "request completed")
|
|
}
|
|
|
|
func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
var span trace.Span
|
|
ctx, span = webtracer.Start(ctx, "http_server.index")
|
|
defer func() {
|
|
span.End()
|
|
}()
|
|
|
|
stats := bootstrap.MakeNewStats(1, 2, 3)
|
|
|
|
const indexCoursesLimit = 200
|
|
coursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
|
|
Limit: indexCoursesLimit,
|
|
})
|
|
if handleError(ctx, err, w, c.log, "unable to list courses") {
|
|
return
|
|
}
|
|
|
|
params := bootstrap.MainPageParams{
|
|
Categories: []bootstrap.IndexCourseCategoryItem{},
|
|
}
|
|
|
|
coursesByLearningType := make(map[IDNamePair][]domain.Course)
|
|
xslices.ForEach(coursesResult.Courses, func(in domain.Course) {
|
|
pair := IDNamePair{
|
|
ID: in.LearningTypeID,
|
|
Name: in.LearningType,
|
|
}
|
|
coursesByLearningType[pair] = append(coursesByLearningType[pair], in)
|
|
})
|
|
|
|
for learningTypeInfo, courses := range coursesByLearningType {
|
|
category := bootstrap.IndexCourseCategoryItem{
|
|
ID: learningTypeInfo.ID,
|
|
Name: learningTypeInfo.Name,
|
|
Count: len(courses),
|
|
}
|
|
|
|
xslices.Shuffle(courses)
|
|
if len(courses) > 3 {
|
|
courses = courses[:3]
|
|
}
|
|
|
|
names := xslices.Map(courses, func(in domain.Course) string {
|
|
return in.Name
|
|
})
|
|
|
|
category.ExampleThemes = names
|
|
|
|
params.Categories = append(params.Categories, category)
|
|
}
|
|
|
|
slices.SortFunc(params.Categories, func(lhs, rhs bootstrap.IndexCourseCategoryItem) int {
|
|
if lhs.Count < rhs.Count {
|
|
return 1
|
|
} else if lhs.Count > rhs.Count {
|
|
return -1
|
|
}
|
|
|
|
return 0
|
|
})
|
|
|
|
span.AddEvent("starting to render")
|
|
err = bootstrap.MainPage(bootstrap.PageIndex, stats, params).Render(ctx, w)
|
|
span.AddEvent("render finished")
|
|
if handleError(ctx, err, w, c.log, "rendeting template") {
|
|
return
|
|
}
|
|
span.SetStatus(codes.Ok, "request completed")
|
|
}
|
|
|
|
var orderByListing = newOrderableContainer(
|
|
newOrderableUnit("pr", "Price", "full_price"),
|
|
newOrderableUnit("na", "Name", "name"),
|
|
newOrderableUnit("di", "Discount", "discount"),
|
|
newOrderableUnit("du", "Duration", "duration"),
|
|
newOrderableUnit("st", "Starts At", "starts_at"),
|
|
)
|
|
|
|
type orderableUnit struct {
|
|
ID string
|
|
Name string
|
|
Field string
|
|
}
|
|
|
|
func newOrderableUnit(id, name, field string) orderableUnit {
|
|
return orderableUnit{
|
|
ID: id,
|
|
Name: name,
|
|
Field: field,
|
|
}
|
|
}
|
|
|
|
type orderableContainer struct {
|
|
nameByID map[string]string
|
|
fieldByID map[string]string
|
|
cachedNameIDPair []bootstrap.NameIDPair
|
|
makeCache sync.Once
|
|
}
|
|
|
|
func (c *orderableContainer) asNameIDPair() []bootstrap.NameIDPair {
|
|
c.makeCache.Do(func() {
|
|
c.cachedNameIDPair = make([]bootstrap.NameIDPair, 0, len(c.nameByID))
|
|
for id, name := range c.nameByID {
|
|
c.cachedNameIDPair = append(c.cachedNameIDPair, bootstrap.NameIDPair{
|
|
ID: id,
|
|
Name: name,
|
|
})
|
|
}
|
|
})
|
|
|
|
return c.cachedNameIDPair
|
|
}
|
|
|
|
func (c *orderableContainer) getField(id string) string {
|
|
return c.fieldByID[id]
|
|
}
|
|
|
|
func newOrderableContainer(units ...orderableUnit) *orderableContainer {
|
|
nameByID := make(map[string]string, len(units))
|
|
fieldByID := make(map[string]string, len(units))
|
|
|
|
xslices.ForEach(units, func(u orderableUnit) {
|
|
nameByID[u.ID] = u.Name
|
|
fieldByID[u.ID] = u.Field
|
|
})
|
|
|
|
return &orderableContainer{
|
|
nameByID: nameByID,
|
|
fieldByID: fieldByID,
|
|
}
|
|
}
|