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
1 change: 1 addition & 0 deletions backend/src/database/DB.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func MigrateTesting(db *gorm.DB) {
&models.Role{},
&models.User{},
&models.LoginMetrics{},
&models.LoginActivity{},
&models.Facility{},
&models.ProviderPlatform{},
&models.ProviderUserMapping{},
Expand Down
113 changes: 113 additions & 0 deletions backend/src/database/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,19 @@ func (db *DB) GetTotalUsers(args *models.QueryContext, facilityId *uint) (int64,
return totalResidents, nil
}

func (db *DB) GetTotalAdmins(args *models.QueryContext, facilityId *uint) (int64, error) {
var totalAdmins int64
staffRoles := []models.UserRole{models.FacilityAdmin, models.DepartmentAdmin}
tx := db.WithContext(args.Ctx).Model(&models.User{}).Where("role IN ?", staffRoles)
if facilityId != nil {
tx = tx.Where("facility_id = ?", *facilityId)
}
if err := tx.Count(&totalAdmins).Error; err != nil {
return 0, newGetRecordsDBError(err, "users")
}
return totalAdmins, nil
}

func (db *DB) GetLoginActivity(args *models.QueryContext, start, end *time.Time, facilityID *uint) ([]models.LoginActivity, error) {
acitvity := make([]models.LoginActivity, 0, 3)
sql := `SELECT time_interval, total_logins
Expand All @@ -492,6 +505,106 @@ func (db *DB) GetLoginActivity(args *models.QueryContext, start, end *time.Time,
return acitvity, nil
}

func (db *DB) GetDailyLoginActivity(args *models.QueryContext, start, end *time.Time, facilityID *uint) ([]models.DailyLoginCount, error) {
activities := make([]models.LoginActivity, 0)
tx := db.WithContext(args.Ctx).Model(&models.LoginActivity{}).Select("time_interval, total_logins")
if facilityID != nil {
tx = tx.Where("facility_id = ?", *facilityID)
}
if start != nil && end != nil {
tx = tx.Where("time_interval >= ? AND time_interval < ?", *start, *end)
}
if err := tx.Find(&activities).Error; err != nil {
return nil, newGetRecordsDBError(err, "login_activity")
}
return bucketLoginsByDay(activities), nil
}

func bucketLoginsByDay(activities []models.LoginActivity) []models.DailyLoginCount {
totals := make(map[string]int64)
days := make([]string, 0)
for _, activity := range activities {
day := activity.TimeInterval.UTC().Format(time.DateOnly)
if _, seen := totals[day]; !seen {
days = append(days, day)
}
totals[day] += activity.TotalLogins
}
slices.Sort(days)
trend := make([]models.DailyLoginCount, 0, len(days))
for _, day := range days {
trend = append(trend, models.DailyLoginCount{Date: day, TotalLogins: totals[day]})
}
return trend
}

func (db *DB) GetFacilityEngagementComparison(args *models.QueryContext, start, end *time.Time) ([]models.FacilityEngagement, error) {
facilities := make([]models.Facility, 0)
if err := db.WithContext(args.Ctx).Order("name ASC").Find(&facilities).Error; err != nil {
return nil, newGetRecordsDBError(err, "facilities")
}

type facilityCount struct {
FacilityID uint
Count int64
}
toMap := func(rows []facilityCount) map[uint]int64 {
m := make(map[uint]int64, len(rows))
for _, row := range rows {
m[row.FacilityID] = row.Count
}
return m
}

registered := make([]facilityCount, 0, len(facilities))
if err := db.WithContext(args.Ctx).Model(&models.User{}).
Select("facility_id, count(*) as count").
Where("role = ?", models.Student).
Group("facility_id").
Find(&registered).Error; err != nil {
return nil, newGetRecordsDBError(err, "users")
}

active := make([]facilityCount, 0, len(facilities))
activeTx := db.WithContext(args.Ctx).Model(&models.User{}).
Select("users.facility_id, count(*) as count").
Joins("JOIN login_metrics ON login_metrics.user_id = users.id").
Where("users.role = ?", models.Student)
if start != nil && end != nil {
activeTx = activeTx.Where("login_metrics.last_login >= ? AND login_metrics.last_login < ?", *start, *end)
}
if err := activeTx.Group("users.facility_id").Find(&active).Error; err != nil {
return nil, newGetRecordsDBError(err, "users")
}

logins := make([]facilityCount, 0, len(facilities))
loginsTx := db.WithContext(args.Ctx).Model(&models.LoginActivity{}).
Select("facility_id, coalesce(sum(total_logins), 0) as count")
if start != nil && end != nil {
loginsTx = loginsTx.Where("time_interval >= ? AND time_interval < ?", *start, *end)
}
if err := loginsTx.Group("facility_id").Find(&logins).Error; err != nil {
return nil, newGetRecordsDBError(err, "login_activity")
}

registeredByFacility := toMap(registered)
activeByFacility := toMap(active)
loginsByFacility := toMap(logins)

comparison := make([]models.FacilityEngagement, 0, len(facilities))
for i := range facilities {
facilityID := facilities[i].ID
comparison = append(comparison, models.FacilityEngagement{
FacilityID: facilityID,
FacilityName: facilities[i].Name,
Registered: registeredByFacility[facilityID],
Active: activeByFacility[facilityID],
Logins: loginsByFacility[facilityID],
})
}
return comparison, nil
}

func (db *DB) GetUserSessionEngagement(userID int, days int) ([]models.SessionEngagement, error) {
var (
sessionEngagement []models.SessionEngagement
Expand Down
53 changes: 53 additions & 0 deletions backend/src/handlers/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ func (srv *Server) registerDashboardRoutes() []routeDef {
resolver := UserRoleResolver("id")
return []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/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 @@ -220,6 +222,10 @@ func (srv *Server) handleDepartmentMetrics(w http.ResponseWriter, r *http.Reques
if err != nil {
return newDatabaseServiceError(err)
}
totalAdmins, err := srv.Db.GetTotalAdmins(&args, facilityId)
if err != nil {
return newDatabaseServiceError(err)
}
newResidentsAdded, err := srv.Db.NewUsersInTimePeriod(&args, start, end, facilityId)
if err != nil {
return newDatabaseServiceError(err)
Expand All @@ -242,6 +248,7 @@ func (srv *Server) handleDepartmentMetrics(w http.ResponseWriter, r *http.Reques
PercentActive: percentActive,
PercentInactive: percentInactive,
TotalResidents: totalResidents,
TotalAdmins: totalAdmins,
Facility: facilityName,
NewResidentsAdded: newResidentsAdded,
PeakLoginTimes: loginTimes,
Expand Down Expand Up @@ -271,6 +278,52 @@ 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
switch {
case facility == "all" && claims.canSwitchFacility():
facilityID = nil
case facility != "" && facility != "all" && claims.canSwitchFacility():
facilityIdInt, err := strconv.Atoi(facility)
if err != nil {
return newInvalidIdServiceError(err, "facility")
}
ref := uint(facilityIdInt)
facilityID = &ref
default:
facilityID = &args.FacilityID
}
start, end, _, err := parseDateRangeRequest(r)
if err != nil {
return err
}
trend, err := srv.Db.GetDailyLoginActivity(&args, start, end, facilityID)
if err != nil {
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, trend)
}

func (srv *Server) handleFacilityEngagementComparison(w http.ResponseWriter, r *http.Request, log sLog) error {
claims := r.Context().Value(ClaimsKey).(*Claims)
if !claims.canSwitchFacility() {
return newUnauthorizedServiceError()
}
args := srv.getQueryContext(r)
start, end, _, err := parseDateRangeRequest(r)
if err != nil {
return err
}
comparison, err := srv.Db.GetFacilityEngagementComparison(&args, start, end)
if err != nil {
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, comparison)
}

func (srv *Server) handleClassDashboardMetrics(w http.ResponseWriter, r *http.Request, log sLog) error {
args := srv.getQueryContext(r)
facility := r.URL.Query().Get("facility")
Expand Down
13 changes: 13 additions & 0 deletions backend/src/models/logins.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ type LoginActivity struct {

func (LoginActivity) TableName() string { return "login_activity" }

type DailyLoginCount struct {
Date string `json:"date"`
TotalLogins int64 `json:"total_logins"`
}

type FacilityEngagement struct {
FacilityID uint `json:"facility_id"`
FacilityName string `json:"facility_name"`
Registered int64 `json:"registered"`
Active int64 `json:"active"`
Logins int64 `json:"logins"`
}

type UserSessionTracking struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint `gorm:"not null" json:"user_id"`
Expand Down
58 changes: 58 additions & 0 deletions backend/tests/integration/department_metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package integration

import (
"context"
"testing"

"UnlockEdv2/src/models"

"github.com/stretchr/testify/require"
)

// TestGetTotalAdmins verifies the staff-account count that backs the
// "N staff accounts · not counted" sub-label on the Registered Residents card:
// only admin/staff roles are counted, residents are excluded, and the count is
// scoped to the requested facility.
func TestGetTotalAdmins(t *testing.T) {
env := SetupTestEnv(t)
defer env.CleanupTestEnv()

facility, err := env.CreateTestFacility("Staff Count Facility")
require.NoError(t, err)
otherFacility, err := env.CreateTestFacility("Other Facility")
require.NoError(t, err)

// Residents at the facility — must NOT be counted as staff.
_, err = env.CreateTestUser("res1", models.Student, facility.ID, "r1")
require.NoError(t, err)
_, err = env.CreateTestUser("res2", models.Student, facility.ID, "r2")
require.NoError(t, err)

// Staff at the facility — counted.
_, err = env.CreateTestUser("fadmin1", models.FacilityAdmin, facility.ID, "")
require.NoError(t, err)
_, err = env.CreateTestUser("fadmin2", models.FacilityAdmin, facility.ID, "")
require.NoError(t, err)
_, err = env.CreateTestUser("dadmin", models.DepartmentAdmin, facility.ID, "")
require.NoError(t, err)

// System admin at the facility — platform-wide operator, NOT facility staff,
// so it must be excluded from the count.
_, err = env.CreateTestUser("sysadmin", models.SystemAdmin, facility.ID, "")
require.NoError(t, err)

// Staff at a different facility — must be excluded when scoped.
_, err = env.CreateTestUser("otheradmin", models.FacilityAdmin, otherFacility.ID, "")
require.NoError(t, err)

args := &models.QueryContext{Ctx: context.Background()}

scoped, err := env.DB.GetTotalAdmins(args, &facility.ID)
require.NoError(t, err)
require.Equal(t, int64(3), scoped, "should count only the 3 facility/department staff at the scoped facility, excluding residents, system admins, and other-facility staff")

// Residents are still counted as residents, never as staff.
residents, err := env.DB.GetTotalUsers(args, &facility.ID)
require.NoError(t, err)
require.Equal(t, int64(2), residents, "resident total should be unaffected by staff accounts")
}
72 changes: 72 additions & 0 deletions backend/tests/integration/facility_comparison_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package integration

import (
"UnlockEdv2/src/handlers"
"UnlockEdv2/src/models"
"net/http"
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestFacilityEngagementComparison(t *testing.T) {
env := SetupTestEnv(t)
defer env.CleanupTestEnv()

facilityA, err := env.CreateTestFacility("Comparison Facility A")
require.NoError(t, err)
facilityB, err := env.CreateTestFacility("Comparison Facility B")
require.NoError(t, err)

inRange := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC)

// Facility A: 2 registered students, 1 active (logged in during range), 10 logins
a1, err := env.CreateTestUser("aactive", models.Student, facilityA.ID, "")
require.NoError(t, err)
_, err = env.CreateTestUser("ainactive", models.Student, facilityA.ID, "")
require.NoError(t, err)
require.NoError(t, env.DB.Create(&models.LoginMetrics{UserID: a1.ID, Total: 4, LastLogin: inRange}).Error)
require.NoError(t, env.DB.Create(&models.LoginActivity{TimeInterval: inRange, FacilityID: facilityA.ID, TotalLogins: 10}).Error)

// Facility B: 1 registered student, 1 active, 4 logins
b1, err := env.CreateTestUser("bactive", models.Student, facilityB.ID, "")
require.NoError(t, err)
require.NoError(t, env.DB.Create(&models.LoginMetrics{UserID: b1.ID, Total: 2, LastLogin: inRange}).Error)
require.NoError(t, env.DB.Create(&models.LoginActivity{TimeInterval: inRange, FacilityID: facilityB.ID, TotalLogins: 4}).Error)

rows := NewRequest[[]models.FacilityEngagement](env.Client, t, http.MethodGet,
"/api/department-metrics/facility-comparison?start_date=2026-01-15&end_date=2026-01-16", nil).
WithTestClaims(&handlers.Claims{Role: models.SystemAdmin, FacilityID: facilityA.ID}).
Do().
ExpectStatus(http.StatusOK).
GetData()

byID := make(map[uint]models.FacilityEngagement, len(rows))
for _, row := range rows {
byID[row.FacilityID] = row
}

require.Equal(t, models.FacilityEngagement{
FacilityID: facilityA.ID, FacilityName: "Comparison Facility A",
Registered: 2, Active: 1, Logins: 10,
}, byID[facilityA.ID])
require.Equal(t, models.FacilityEngagement{
FacilityID: facilityB.ID, FacilityName: "Comparison Facility B",
Registered: 1, Active: 1, Logins: 4,
}, byID[facilityB.ID])
}

func TestFacilityEngagementComparison_ForbiddenForFacilityAdmin(t *testing.T) {
env := SetupTestEnv(t)
defer env.CleanupTestEnv()

facility, err := env.CreateTestFacility("Solo Facility")
require.NoError(t, err)

NewRequest[[]models.FacilityEngagement](env.Client, t, http.MethodGet,
"/api/department-metrics/facility-comparison?start_date=2026-01-15&end_date=2026-01-16", nil).
WithTestClaims(&handlers.Claims{Role: models.FacilityAdmin, FacilityID: facility.ID}).
Do().
ExpectStatus(http.StatusUnauthorized)
}
Loading
Loading