Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/database/DB.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ func MigrateTesting(db *gorm.DB) {
&models.UserCourseActivityTotals{},
&models.ProgramClassesHistory{},
&models.UserAccountHistory{},
&models.Tag{},
&models.OpenContentTag{},
}
logrus.Println("Running up migrations...")
for _, table := range TableList {
Expand Down
196 changes: 196 additions & 0 deletions backend/src/database/knowledge_center.go
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
}
Comment on lines +60 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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) GetKCAvgSessionMinutes(args *models.QueryContext, start, end *time.Time, facilityID *uint) (float64, error) {
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
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/database/knowledge_center.go` around lines 60 - 84,
GetKCAvgSessionMinutes currently materializes all session rows and computes
durations in Go; instead push the aggregation to the DB by using
kcActivityScope(args, start, end, facilityID) to SELECT the average session
length SQL-side (e.g., AVG(EXTRACT(EPOCH FROM stop_ts - request_ts)/60) or
database-equivalent) with WHERE stop_ts IS NOT NULL AND stop_ts > request_ts,
then Scan the resulting avg value into a float64, return 0,nil if NULL, and
preserve error wrapping via newGetRecordsDBError on query failure; update
GetKCAvgSessionMinutes to use that single aggregate query rather than loading
rows and iterating.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter category aggregation through libraries to avoid cross-content miscounts.

Line [116] joins tags directly from open_content_activities. If IDs overlap across content tables for the same provider, non-library activity can be counted in “library views by category”. Join through libraries first to enforce content type.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/database/knowledge_center.go` around lines 112 - 117, In
GetKCLibraryViewsByCategory replace the direct join from open_content_activities
to tags with a join through the libraries table so only library content is
counted: first join libraries (e.g., JOIN libraries l ON l.id = oca.content_id
AND l.open_content_provider_id = oca.open_content_provider_id), then join
open_content_tags using the library id (oct.content_id = l.id AND
oct.open_content_provider_id = l.open_content_provider_id), and finally join
tags on oct.tag_id = t.id; update any selected/filtered references to use the
library join to prevent cross-content ID overlap from inflating category view
counts.

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(&current).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)
}
127 changes: 110 additions & 17 deletions backend/src/handlers/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject non-positive facility values before casting to uint.

Line [291] casts parsed values directly; -1 or 0 should be invalid, but currently become invalid uint IDs (including wraparound for negatives).

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/handlers/dashboard.go` around lines 287 - 292, The parsed
facility ID is cast to uint without rejecting non-positive values, which turns 0
or negatives into invalid/wrapped uints; in the code around
strconv.Atoi(facility) (variables facilityIdInt and ref), add a check after
parsing: if facilityIdInt <= 0 { return nil,
newInvalidIdServiceError(fmt.Errorf("invalid facility id: %d", facilityIdInt),
"facility") } (or use an appropriate error value) so you reject zero and
negative IDs before converting to uint and returning &ref.

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 {
Expand All @@ -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() {
Expand Down
Loading
Loading