-
Notifications
You must be signed in to change notification settings - Fork 23
feat: op insights knowledge center #1167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| package database | ||
|
|
||
| import ( | ||
| "UnlockEdv2/src/models" | ||
| "math" | ||
| "time" | ||
|
|
||
| "gorm.io/gorm" | ||
| ) | ||
|
|
||
| func kcPriorWindow(start, end *time.Time) (*time.Time, *time.Time) { | ||
| if start == nil || end == nil { | ||
| return nil, nil | ||
| } | ||
| duration := end.Sub(*start) | ||
| priorStart := start.Add(-duration) | ||
| priorEnd := *start | ||
| return &priorStart, &priorEnd | ||
| } | ||
|
|
||
| func kcPctChange(current, prior int64) int { | ||
| if prior <= 0 { | ||
| return 0 | ||
| } | ||
| return int(math.Round(float64(current-prior) / float64(prior) * 100)) | ||
| } | ||
|
|
||
| func (db *DB) kcActivityScope(args *models.QueryContext, start, end *time.Time, facilityID *uint) *gorm.DB { | ||
| tx := db.WithContext(args.Ctx).Model(&models.OpenContentActivity{}) | ||
| if facilityID != nil { | ||
| tx = tx.Where("facility_id = ?", *facilityID) | ||
| } | ||
| if start != nil && end != nil { | ||
| tx = tx.Where("request_ts >= ? AND request_ts < ?", *start, *end) | ||
| } | ||
| return tx | ||
| } | ||
|
|
||
| func (db *DB) GetKCInteractionStats(args *models.QueryContext, start, end *time.Time, facilityID *uint) (int64, int64, int, error) { | ||
| var total int64 | ||
| if err := db.kcActivityScope(args, start, end, facilityID).Count(&total).Error; err != nil { | ||
| return 0, 0, 0, newGetRecordsDBError(err, "open_content_activities") | ||
| } | ||
| var unique int64 | ||
| if err := db.kcActivityScope(args, start, end, facilityID). | ||
| Distinct("user_id").Count(&unique).Error; err != nil { | ||
| return 0, 0, 0, newGetRecordsDBError(err, "open_content_activities") | ||
| } | ||
| change := 0 | ||
| if priorStart, priorEnd := kcPriorWindow(start, end); priorStart != nil { | ||
| var priorTotal int64 | ||
| if err := db.kcActivityScope(args, priorStart, priorEnd, facilityID).Count(&priorTotal).Error; err != nil { | ||
| return 0, 0, 0, newGetRecordsDBError(err, "open_content_activities") | ||
| } | ||
| change = kcPctChange(total, priorTotal) | ||
| } | ||
| return total, unique, change, nil | ||
| } | ||
|
|
||
| func (db *DB) GetKCAvgSessionMinutes(args *models.QueryContext, start, end *time.Time, facilityID *uint) (float64, error) { | ||
| type sessionRow struct { | ||
| RequestTS time.Time | ||
| StopTS time.Time | ||
| } | ||
| rows := make([]sessionRow, 0) | ||
| if err := db.kcActivityScope(args, start, end, facilityID). | ||
| Select("request_ts, stop_ts"). | ||
| Where("stop_ts IS NOT NULL"). | ||
| Find(&rows).Error; err != nil { | ||
| return 0, newGetRecordsDBError(err, "open_content_activities") | ||
| } | ||
| var totalMinutes float64 | ||
| var counted int | ||
| for _, row := range rows { | ||
| if row.StopTS.After(row.RequestTS) { | ||
| totalMinutes += row.StopTS.Sub(row.RequestTS).Minutes() | ||
| counted++ | ||
| } | ||
| } | ||
| if counted == 0 { | ||
| return 0, nil | ||
| } | ||
| return totalMinutes / float64(counted), nil | ||
| } | ||
|
|
||
| func (db *DB) GetKCRepeatEngagement(args *models.QueryContext, start, end *time.Time, facilityID *uint) (models.RepeatEngagement, error) { | ||
| type userCount struct { | ||
| UserID uint | ||
| Cnt int64 | ||
| } | ||
| counts := make([]userCount, 0) | ||
| if err := db.kcActivityScope(args, start, end, facilityID). | ||
| Select("user_id, count(*) as cnt"). | ||
| Group("user_id"). | ||
| Find(&counts).Error; err != nil { | ||
| return models.RepeatEngagement{}, newGetRecordsDBError(err, "open_content_activities") | ||
| } | ||
| var engagement models.RepeatEngagement | ||
| for _, row := range counts { | ||
| switch { | ||
| case row.Cnt >= 5: | ||
| engagement.FivePlus++ | ||
| case row.Cnt >= 2: | ||
| engagement.TwoToFour++ | ||
| default: | ||
| engagement.Once++ | ||
| } | ||
| } | ||
| return engagement, nil | ||
| } | ||
|
|
||
| func (db *DB) GetKCLibraryViewsByCategory(args *models.QueryContext, start, end *time.Time, facilityID *uint) ([]models.CategoryViews, error) { | ||
| views := make([]models.CategoryViews, 0) | ||
| tx := db.WithContext(args.Ctx).Table("open_content_activities oca"). | ||
| Select("t.name as category, count(oca.id) as views"). | ||
| Joins("JOIN open_content_tags oct ON oct.content_id = oca.content_id AND oct.open_content_provider_id = oca.open_content_provider_id"). | ||
| Joins("JOIN tags t ON t.id = oct.tag_id") | ||
|
Comment on lines
+112
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Filter category aggregation through Line [116] joins tags directly from Proposed fix tx := db.WithContext(args.Ctx).Table("open_content_activities oca").
Select("t.name as category, count(oca.id) as views").
- Joins("JOIN open_content_tags oct ON oct.content_id = oca.content_id AND oct.open_content_provider_id = oca.open_content_provider_id").
+ Joins("JOIN libraries l ON l.id = oca.content_id AND l.open_content_provider_id = oca.open_content_provider_id AND l.deleted_at IS NULL").
+ Joins("JOIN open_content_tags oct ON oct.content_id = l.id AND oct.open_content_provider_id = l.open_content_provider_id").
Joins("JOIN tags t ON t.id = oct.tag_id")🤖 Prompt for AI Agents |
||
| if facilityID != nil { | ||
| tx = tx.Where("oca.facility_id = ?", *facilityID) | ||
| } | ||
| if start != nil && end != nil { | ||
| tx = tx.Where("oca.request_ts >= ? AND oca.request_ts < ?", *start, *end) | ||
| } | ||
| if err := tx.Group("t.name").Order("views DESC, t.name ASC").Scan(&views).Error; err != nil { | ||
| return nil, newGetRecordsDBError(err, "open_content_tags") | ||
| } | ||
| return views, nil | ||
| } | ||
|
|
||
| func (db *DB) getKCTopContent(args *models.QueryContext, table, alias string, start, end *time.Time, facilityID *uint, limit int) ([]models.KCContentRow, error) { | ||
| type contentRow struct { | ||
| ContentID uint | ||
| Title string | ||
| Visits int64 | ||
| } | ||
| current := make([]contentRow, 0, limit) | ||
| tx := db.WithContext(args.Ctx).Table("open_content_activities oca"). | ||
| Select(alias + ".id as content_id, " + alias + ".title as title, count(oca.id) as visits"). | ||
| Joins("JOIN " + table + " " + alias + " ON " + alias + ".id = oca.content_id AND " + alias + ".open_content_provider_id = oca.open_content_provider_id AND " + alias + ".deleted_at IS NULL") | ||
| if facilityID != nil { | ||
| tx = tx.Where("oca.facility_id = ?", *facilityID) | ||
| } | ||
| if start != nil && end != nil { | ||
| tx = tx.Where("oca.request_ts >= ? AND oca.request_ts < ?", *start, *end) | ||
| } | ||
| if err := tx.Group(alias + ".id, " + alias + ".title"). | ||
| Order("visits DESC, " + alias + ".title ASC"). | ||
| Limit(limit). | ||
| Scan(¤t).Error; err != nil { | ||
| return nil, newGetRecordsDBError(err, table) | ||
| } | ||
| rows := make([]models.KCContentRow, 0, len(current)) | ||
| if len(current) == 0 { | ||
| return rows, nil | ||
| } | ||
|
|
||
| priorVisits := make(map[uint]int64) | ||
| if priorStart, priorEnd := kcPriorWindow(start, end); priorStart != nil { | ||
| ids := make([]uint, 0, len(current)) | ||
| for _, c := range current { | ||
| ids = append(ids, c.ContentID) | ||
| } | ||
| priors := make([]contentRow, 0, len(ids)) | ||
| ptx := db.WithContext(args.Ctx).Table("open_content_activities oca"). | ||
| Select(alias+".id as content_id, count(oca.id) as visits"). | ||
| Joins("JOIN "+table+" "+alias+" ON "+alias+".id = oca.content_id AND "+alias+".open_content_provider_id = oca.open_content_provider_id AND "+alias+".deleted_at IS NULL"). | ||
| Where(alias+".id IN ?", ids). | ||
| Where("oca.request_ts >= ? AND oca.request_ts < ?", *priorStart, *priorEnd) | ||
| if facilityID != nil { | ||
| ptx = ptx.Where("oca.facility_id = ?", *facilityID) | ||
| } | ||
| if err := ptx.Group(alias + ".id").Scan(&priors).Error; err != nil { | ||
| return nil, newGetRecordsDBError(err, table) | ||
| } | ||
| for _, p := range priors { | ||
| priorVisits[p.ContentID] = p.Visits | ||
| } | ||
| } | ||
|
|
||
| for _, c := range current { | ||
| rows = append(rows, models.KCContentRow{ | ||
| Title: c.Title, | ||
| Visits: c.Visits, | ||
| Change: kcPctChange(c.Visits, priorVisits[c.ContentID]), | ||
| }) | ||
| } | ||
| return rows, nil | ||
| } | ||
|
|
||
| func (db *DB) GetKCTopLibraries(args *models.QueryContext, start, end *time.Time, facilityID *uint, limit int) ([]models.KCContentRow, error) { | ||
| return db.getKCTopContent(args, "libraries", "l", start, end, facilityID, limit) | ||
| } | ||
|
|
||
| func (db *DB) GetKCTopVideos(args *models.QueryContext, start, end *time.Time, facilityID *uint, limit int) ([]models.KCContentRow, error) { | ||
| return db.getKCTopContent(args, "videos", "v", start, end, facilityID, limit) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ func (srv *Server) registerDashboardRoutes() []routeDef { | |
| newAdminRoute("GET /api/department-metrics", srv.handleDepartmentMetrics), | ||
| newAdminRoute("GET /api/department-metrics/login-trend", srv.handleDepartmentLoginTrend), | ||
| newAdminRoute("GET /api/department-metrics/facility-comparison", srv.handleFacilityEngagementComparison), | ||
| newAdminRoute("GET /api/department-metrics/knowledge-center", srv.handleKnowledgeCenterMetrics), | ||
| newAdminRoute("GET /api/dashboard/class-metrics", srv.handleClassDashboardMetrics), | ||
| newAdminRoute("GET /api/dashboard/facility-health", srv.handleFacilityHealthSummary), | ||
| newAdminRoute("GET /api/users/{id}/admin-layer2", srv.handleAdminLayer2), | ||
|
|
@@ -124,19 +125,56 @@ func (srv *Server) handleAdminLayer2(w http.ResponseWriter, r *http.Request, log | |
| return writeJsonResponse(w, http.StatusOK, cachedData) | ||
| } | ||
|
|
||
| const metricsDateFormat = "2006-01-02" | ||
|
|
||
| func startOfDay(t time.Time) time.Time { | ||
| return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) | ||
| } | ||
|
|
||
| // resolvePresetRange resolves a preset range key (e.g. "30d") against the | ||
| // server clock, returning the inclusive start day and an exclusive end bound | ||
| // (midnight after today, so all of "today" is included). Resolving presets | ||
| // server-side keeps the window anchored to the same clock that timestamps the | ||
| // data, so a client in a different timezone can't request a window that ends | ||
| // before data recorded "now". | ||
| func resolvePresetRange(key string) (time.Time, time.Time, bool) { | ||
| now := time.Now() | ||
| today := startOfDay(now) | ||
| endExclusive := today.AddDate(0, 0, 1) | ||
| switch key { | ||
| case "7d": | ||
| return today.AddDate(0, 0, -6), endExclusive, true | ||
| case "30d": | ||
| return today.AddDate(0, 0, -29), endExclusive, true | ||
| case "90d": | ||
| return today.AddDate(0, 0, -89), endExclusive, true | ||
| case "ytd": | ||
| return time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()), endExclusive, true | ||
| default: | ||
| return time.Time{}, time.Time{}, false | ||
| } | ||
| } | ||
|
|
||
| func parseDateRangeRequest(r *http.Request) (*time.Time, *time.Time, string, error) { | ||
| q := r.URL.Query() | ||
| if q.Get("all_time") == "true" { | ||
| return nil, nil, "all", nil | ||
| } | ||
| metricsDateFormat := "2006-01-02" | ||
| // Preset ranges are resolved against the server clock. Any start_date/end_date | ||
| // the client also sends are ignored in favor of the preset. | ||
| if rangeKey := strings.ToLower(q.Get("range")); rangeKey != "" && rangeKey != "custom" { | ||
| start, end, ok := resolvePresetRange(rangeKey) | ||
| if !ok { | ||
| return nil, nil, "", newInvalidQueryParamServiceError(fmt.Errorf("unknown range %q", rangeKey), "range") | ||
| } | ||
| return &start, &end, fmt.Sprintf("%s_%s", start.Format(metricsDateFormat), end.Format(metricsDateFormat)), nil | ||
| } | ||
| startStr := q.Get("start_date") | ||
| endStr := q.Get("end_date") | ||
| if startStr == "" && endStr == "" { | ||
| end := time.Now() | ||
| start := end.AddDate(0, 0, -30) | ||
| startDay := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()) | ||
| endDay := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, end.Location()).AddDate(0, 0, 1) | ||
| now := time.Now() | ||
| startDay := startOfDay(now.AddDate(0, 0, -30)) | ||
| endDay := startOfDay(now).AddDate(0, 0, 1) | ||
| return &startDay, &endDay, fmt.Sprintf("%s_%s", startDay.Format(metricsDateFormat), endDay.Format(metricsDateFormat)), nil | ||
| } | ||
| if startStr == "" || endStr == "" { | ||
|
|
@@ -153,10 +191,12 @@ func parseDateRangeRequest(r *http.Request) (*time.Time, *time.Time, string, err | |
| if end.Before(start) { | ||
| return nil, nil, "", newBadRequestServiceError(fmt.Errorf("end date must be on or after start date"), "date range") | ||
| } | ||
| today := time.Now() | ||
| todayMidnight := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location()) | ||
| // Clamp a future end date to the server's current day rather than rejecting | ||
| // it: a client whose clock or timezone runs ahead of the server should still | ||
| // get data instead of a 400. | ||
| todayMidnight := startOfDay(time.Now()) | ||
| if end.After(todayMidnight) { | ||
| return nil, nil, "", newBadRequestServiceError(fmt.Errorf("end date cannot be in the future"), "date range") | ||
| end = todayMidnight | ||
| } | ||
| endExclusive := end.AddDate(0, 0, 1) | ||
| return &start, &endExclusive, fmt.Sprintf("%s_%s", start.Format(metricsDateFormat), end.Format(metricsDateFormat)), nil | ||
|
|
@@ -278,23 +318,28 @@ func (srv *Server) handleDepartmentMetrics(w http.ResponseWriter, r *http.Reques | |
| return writeJsonResponse(w, http.StatusOK, cachedData) | ||
| } | ||
|
|
||
| func (srv *Server) handleDepartmentLoginTrend(w http.ResponseWriter, r *http.Request, log sLog) error { | ||
| args := srv.getQueryContext(r) | ||
| claims := r.Context().Value(ClaimsKey).(*Claims) | ||
| facility := r.URL.Query().Get("facility") | ||
| var facilityID *uint | ||
| func resolveFacilityFilter(claims *Claims, facility string, ownFacilityID uint) (*uint, error) { | ||
| switch { | ||
| case facility == "all" && claims.canSwitchFacility(): | ||
| facilityID = nil | ||
| return nil, nil | ||
| case facility != "" && facility != "all" && claims.canSwitchFacility(): | ||
| facilityIdInt, err := strconv.Atoi(facility) | ||
| if err != nil { | ||
| return newInvalidIdServiceError(err, "facility") | ||
| return nil, newInvalidIdServiceError(err, "facility") | ||
| } | ||
| ref := uint(facilityIdInt) | ||
| facilityID = &ref | ||
| return &ref, nil | ||
|
Comment on lines
326
to
+331
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject non-positive Line [291] casts parsed values directly; Proposed fix facilityIdInt, err := strconv.Atoi(facility)
if err != nil {
return nil, newInvalidIdServiceError(err, "facility")
}
+ if facilityIdInt <= 0 {
+ return nil, newInvalidIdServiceError(fmt.Errorf("facility must be a positive integer"), "facility")
+ }
ref := uint(facilityIdInt)
return &ref, nil🤖 Prompt for AI Agents |
||
| default: | ||
| facilityID = &args.FacilityID | ||
| return &ownFacilityID, nil | ||
| } | ||
| } | ||
|
|
||
| func (srv *Server) handleDepartmentLoginTrend(w http.ResponseWriter, r *http.Request, log sLog) error { | ||
| args := srv.getQueryContext(r) | ||
| claims := r.Context().Value(ClaimsKey).(*Claims) | ||
| facilityID, err := resolveFacilityFilter(claims, r.URL.Query().Get("facility"), args.FacilityID) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| start, end, _, err := parseDateRangeRequest(r) | ||
| if err != nil { | ||
|
|
@@ -307,6 +352,54 @@ func (srv *Server) handleDepartmentLoginTrend(w http.ResponseWriter, r *http.Req | |
| return writeJsonResponse(w, http.StatusOK, trend) | ||
| } | ||
|
|
||
| func (srv *Server) handleKnowledgeCenterMetrics(w http.ResponseWriter, r *http.Request, log sLog) error { | ||
| args := srv.getQueryContext(r) | ||
| claims := r.Context().Value(ClaimsKey).(*Claims) | ||
| facilityID, err := resolveFacilityFilter(claims, r.URL.Query().Get("facility"), args.FacilityID) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| start, end, _, err := parseDateRangeRequest(r) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| total, unique, totalChange, err := srv.Db.GetKCInteractionStats(&args, start, end, facilityID) | ||
| if err != nil { | ||
| return newDatabaseServiceError(err) | ||
| } | ||
| avgSession, err := srv.Db.GetKCAvgSessionMinutes(&args, start, end, facilityID) | ||
| if err != nil { | ||
| return newDatabaseServiceError(err) | ||
| } | ||
| repeat, err := srv.Db.GetKCRepeatEngagement(&args, start, end, facilityID) | ||
| if err != nil { | ||
| return newDatabaseServiceError(err) | ||
| } | ||
| categories, err := srv.Db.GetKCLibraryViewsByCategory(&args, start, end, facilityID) | ||
| if err != nil { | ||
| return newDatabaseServiceError(err) | ||
| } | ||
| topLibraries, err := srv.Db.GetKCTopLibraries(&args, start, end, facilityID, 8) | ||
| if err != nil { | ||
| return newDatabaseServiceError(err) | ||
| } | ||
| topVideos, err := srv.Db.GetKCTopVideos(&args, start, end, facilityID, 8) | ||
| if err != nil { | ||
| return newDatabaseServiceError(err) | ||
| } | ||
| metrics := models.KnowledgeCenterMetrics{ | ||
| TotalInteractions: total, | ||
| TotalInteractionsChange: totalChange, | ||
| UniqueResidents: unique, | ||
| AvgSessionMinutes: avgSession, | ||
| RepeatEngagement: repeat, | ||
| LibraryViewsByCategory: categories, | ||
| TopLibraries: topLibraries, | ||
| TopVideos: topVideos, | ||
| } | ||
| return writeJsonResponse(w, http.StatusOK, metrics) | ||
| } | ||
|
|
||
| func (srv *Server) handleFacilityEngagementComparison(w http.ResponseWriter, r *http.Request, log sLog) error { | ||
| claims := r.Context().Value(ClaimsKey).(*Claims) | ||
| if !claims.canSwitchFacility() { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Compute average session duration in SQL instead of materializing all rows.
Lines [66]-[79] fetch every session row into memory and iterate in Go. For large ranges (especially all-time), this creates avoidable DB->API transfer and memory pressure on a request path.
Suggested direction
func (db *DB) GetKCAvgSessionMinutes(args *models.QueryContext, start, end *time.Time, facilityID *uint) (float64, error) { - type sessionRow struct { - RequestTS time.Time - StopTS time.Time - } - rows := make([]sessionRow, 0) - if err := db.kcActivityScope(args, start, end, facilityID). - Select("request_ts, stop_ts"). - Where("stop_ts IS NOT NULL"). - Find(&rows).Error; err != nil { - return 0, newGetRecordsDBError(err, "open_content_activities") - } - var totalMinutes float64 - var counted int - for _, row := range rows { - if row.StopTS.After(row.RequestTS) { - totalMinutes += row.StopTS.Sub(row.RequestTS).Minutes() - counted++ - } - } - if counted == 0 { - return 0, nil - } - return totalMinutes / float64(counted), nil + type avgRow struct { + AvgMinutes float64 + } + var out avgRow + tx := db.kcActivityScope(args, start, end, facilityID). + Where("stop_ts IS NOT NULL AND stop_ts > request_ts") + + switch db.Dialector.Name() { + case "postgres": + tx = tx.Select("COALESCE(AVG(EXTRACT(EPOCH FROM (stop_ts - request_ts))/60.0), 0) AS avg_minutes") + case "sqlite": + tx = tx.Select("COALESCE(AVG((julianday(stop_ts) - julianday(request_ts)) * 24 * 60), 0) AS avg_minutes") + default: + return 0, nil + } + + if err := tx.Scan(&out).Error; err != nil { + return 0, newGetRecordsDBError(err, "open_content_activities") + } + return out.AvgMinutes, nil }📝 Committable suggestion
🤖 Prompt for AI Agents