diff --git a/backend/migrations/00020_remove_course_favorites_create_program_favorites.sql b/backend/migrations/00020_remove_course_favorites_create_program_favorites.sql new file mode 100644 index 00000000..12123fe2 --- /dev/null +++ b/backend/migrations/00020_remove_course_favorites_create_program_favorites.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- +goose StatementBegin +DROP TABLE IF EXISTS favorites CASCADE; +CREATE TABLE public.program_favorites ( + id SERIAL PRIMARY KEY, + program_id INT NOT NULL, + user_id INT NOT NULL, + FOREIGN KEY (program_id) REFERENCES programs(id), + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE INDEX program_favorites_program_id_index ON program_favorites USING btree (program_id); +CREATE INDEX program_favorites_user_id_index ON program_favorites USING btree (user_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +CREATE TABLE public.favorites ( + id SERIAL NOT NULL PRIMARY KEY, + user_id integer, + course_id integer, + FOREIGN KEY (user_id) REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE, + FOREIGN KEY (course_id) REFERENCES public.courses(id) ON UPDATE CASCADE ON DELETE CASCADE +); +DROP TABLE IF EXISTS program_favorites CASCADE; +-- +goose StatementEnd diff --git a/backend/src/database/DB.go b/backend/src/database/DB.go index 4d58a74d..8522615b 100644 --- a/backend/src/database/DB.go +++ b/backend/src/database/DB.go @@ -113,7 +113,7 @@ func MigrateTesting(db *gorm.DB) { &models.Outcome{}, &models.Activity{}, &models.OidcClient{}, - &models.UserFavorite{}, + &models.ProgramFavorite{}, &models.Facility{}, &models.OpenContentProvider{}, &models.CronJob{}, diff --git a/backend/src/database/programs.go b/backend/src/database/programs.go index 4ba5f189..8243a6bf 100644 --- a/backend/src/database/programs.go +++ b/backend/src/database/programs.go @@ -12,7 +12,7 @@ func (db *DB) GetProgramByID(id int) (*models.Program, error) { return content, nil } -func (db *DB) GetProgram(page, perPage int, tags []string, search string) (int64, []models.Program, error) { +func (db *DB) GetProgram(page, perPage int, tags []string, search string, userId uint) (int64, []models.Program, error) { content := []models.Program{} total := int64(0) if len(tags) > 0 { @@ -26,23 +26,39 @@ func (db *DB) GetProgram(page, perPage int, tags []string, search string) (int64 } } - tx := db.Model(&models.Program{}).Preload("Tags").Preload("Facilities").Find(&content, "id IN (?)", query).Count(&total) + tx := db.Model(&models.Program{}).Preload("Tags").Preload("Facilities").Preload("Favorites", "user_id = ?", userId).Find(&content, "id IN (?)", query) if search != "" { tx = tx.Where("name LIKE ?", "%"+search+"%") } + if err := tx.Count(&total).Error; err != nil { + return 0, nil, newGetRecordsDBError(err, "programs") + } if err := tx.Limit(perPage).Offset((page - 1) * perPage).Error; err != nil { return 0, nil, newGetRecordsDBError(err, "programs") } } else { - tx := db.Model(&models.Program{}).Preload("Tags").Preload("Facilities") + tx := db.Model(&models.Program{}). + Preload("Tags"). + Preload("Facilities"). + Preload("Favorites", "user_id = ?", userId) if search != "" { tx = tx.Where("name LIKE ?", "%"+search+"%").Count(&total) } - if err := tx.Find(&content).Limit(perPage).Offset((page - 1) * perPage).Error; err != nil { + if err := tx.Count(&total).Error; err != nil { + return 0, nil, newGetRecordsDBError(err, "programs") + } + if err := tx.Limit(perPage).Offset((page - 1) * perPage).Find(&content).Error; err != nil { return 0, nil, newGetRecordsDBError(err, "programs") } } - return total, content, nil + programs := iterMap(func(prog models.Program) models.Program { + if len(prog.Favorites) > 0 { + prog.IsFavorited = true + return prog + } + return prog + }, content) + return total, programs, nil } func (db *DB) CreateProgram(content *models.Program) error { diff --git a/backend/src/database/user_catalogue.go b/backend/src/database/user_catalogue.go index 7d99eab0..a7bd4d63 100644 --- a/backend/src/database/user_catalogue.go +++ b/backend/src/database/user_catalogue.go @@ -15,16 +15,14 @@ type UserCatalogueJoin struct { ExternalURL string `json:"external_url"` CourseType string `json:"course_type"` Description string `json:"description"` - IsFavorited bool `json:"is_favorited"` OutcomeTypes string `json:"outcome_types"` } func (db *DB) GetUserCatalogue(userId int, tags []string, search, order string) ([]UserCatalogueJoin, error) { catalogue := []UserCatalogueJoin{} tx := db.Table("courses c"). - Select("c.id as course_id, c.thumbnail_url, c.name as course_name, pp.name as provider_name, c.external_url, c.type as course_type, c.description, c.outcome_types, f.user_id IS NOT NULL as is_favorited"). + Select("c.id as course_id, c.thumbnail_url, c.name as course_name, pp.name as provider_name, c.external_url, c.type as course_type, c.description, c.outcome_types"). Joins("LEFT JOIN provider_platforms pp ON c.provider_platform_id = pp.id"). - Joins("LEFT JOIN favorites f ON f.course_id = c.id AND f.user_id = ?", userId). Where("c.deleted_at IS NULL"). Where("pp.deleted_at IS NULL") for i, tag := range tags { @@ -53,7 +51,6 @@ type UserCourses struct { ProviderName string `json:"provider_platform_name"` ExternalURL string `json:"external_url"` CourseProgress float64 `json:"course_progress"` - IsFavorited bool `json:"is_favorited"` TotalTime uint `json:"total_time"` } @@ -70,7 +67,6 @@ func (db *DB) GetUserCourses(userId uint, order string, orderBy string, search s "course_name": "c.name", "provider_name": "pp.name", "course_progress": "course_progress", - "is_favorited": "is_favorited", "total_time": "a.total_time", } dbField, ok := fieldMap[orderBy] @@ -82,7 +78,6 @@ func (db *DB) GetUserCourses(userId uint, order string, orderBy string, search s tx := db.Table("courses c"). Select(`c.id, c.thumbnail_url, c.name as course_name, pp.name as provider_name, c.external_url, - f.user_id IS NOT NULL as is_favorited, CASE WHEN EXISTS (SELECT 1 FROM outcomes o WHERE o.course_id = c.id AND o.user_id = ?) THEN 100 WHEN c.total_progress_milestones = 0 THEN 0 @@ -100,7 +95,6 @@ func (db *DB) GetUserCourses(userId uint, order string, orderBy string, search s ) a on a.course_id = c.id and a.user_id = m.user_id and a.RN = 1`). - Joins("LEFT JOIN favorites f ON f.course_id = c.id AND f.user_id = m.user_id"). Joins("LEFT JOIN outcomes o ON o.course_id = c.id AND o.user_id = m.user_id"). Where("c.deleted_at IS NULL"). Where("pp.deleted_at IS NULL") @@ -113,8 +107,6 @@ func (db *DB) GetUserCourses(userId uint, order string, orderBy string, search s for i, tag := range tags { var query string switch tag { - case "is_favorited": - query = "f.user_id IS NOT NULL" case "completed": query = "o.type IS NOT NULL" case "in_progress": @@ -127,7 +119,7 @@ func (db *DB) GetUserCourses(userId uint, order string, orderBy string, search s } } - tx.Group("c.id, c.name, c.thumbnail_url, pp.name, c.external_url, f.user_id, a.total_time") + tx.Group("c.id, c.name, c.thumbnail_url, pp.name, c.external_url, a.total_time") err := tx.Scan(&courses).Error if err != nil { return nil, 0, 0, NewDBError(err, "error getting user programs") diff --git a/backend/src/database/users.go b/backend/src/database/users.go index 478d23aa..979b25cd 100644 --- a/backend/src/database/users.go +++ b/backend/src/database/users.go @@ -187,16 +187,16 @@ func (db *DB) UpdateUser(user *models.User) error { return nil } -func (db *DB) ToggleUserFavorite(user_id uint, id uint) (bool, error) { +func (db *DB) ToggleProgramFavorite(user_id uint, id uint) (bool, error) { var favRemoved bool - var favorite models.UserFavorite - if db.First(&favorite, "user_id = ? AND course_id = ?", user_id, id).Error == nil { - if err := db.Delete(&favorite).Error; err != nil { + var favorite models.ProgramFavorite + if db.First(&favorite, "user_id = ? AND program_id = ?", user_id, id).Error == nil { + if err := db.Unscoped().Delete(&favorite).Error; err != nil { return favRemoved, newDeleteDBError(err, "favorites") } favRemoved = true } else { - favorite = models.UserFavorite{UserID: user_id, CourseID: id} + favorite = models.ProgramFavorite{UserID: user_id, ProgramID: id} if err := db.Create(&favorite).Error; err != nil { return favRemoved, newCreateDBError(err, "error creating favorites") } diff --git a/backend/src/handlers/courses_handler.go b/backend/src/handlers/courses_handler.go index a0be4ff9..f69417d1 100644 --- a/backend/src/handlers/courses_handler.go +++ b/backend/src/handlers/courses_handler.go @@ -11,7 +11,6 @@ func (srv *Server) registerCoursesRoutes() []routeDef { return []routeDef{ {"GET /api/courses", srv.handleIndexCourses, true, axx}, {"GET /api/courses/{id}", srv.handleShowCourse, false, axx}, - {"PUT /api/courses/{id}/save", srv.handleFavoriteCourse, true, axx}, } } @@ -51,23 +50,3 @@ func (srv *Server) handleShowCourse(w http.ResponseWriter, r *http.Request, log } return writeJsonResponse(w, http.StatusOK, course) } - -func (srv *Server) handleFavoriteCourse(w http.ResponseWriter, r *http.Request, log sLog) error { - id, err := strconv.Atoi(r.PathValue("id")) - if err != nil { - return newInvalidIdServiceError(err, "course ID") - } - user_id := srv.userIdFromRequest(r) - favoriteRemoved, err := srv.Db.ToggleUserFavorite(user_id, uint(id)) - if err != nil { - log.add("course_id", id) - log.add("user_id", user_id) - return newDatabaseServiceError(err) - } - log.debugf("Favorite removed: %v", favoriteRemoved) - if favoriteRemoved { - w.WriteHeader(http.StatusNoContent) - return nil - } - return writeJsonResponse(w, http.StatusOK, "Favorite updated successfully") -} diff --git a/backend/src/handlers/courses_handler_test.go b/backend/src/handlers/courses_handler_test.go index 883b0af8..54f63fe8 100644 --- a/backend/src/handlers/courses_handler_test.go +++ b/backend/src/handlers/courses_handler_test.go @@ -6,7 +6,6 @@ import ( "fmt" "math/rand" "net/http" - "net/http/httptest" "slices" "strconv" "testing" @@ -81,41 +80,6 @@ func TestHandleShowCourse(t *testing.T) { } } -func TestHandleFavoriteCourse(t *testing.T) { - httpTests := []httpTest{ - {"TestUserCanToggleFavoriteCourseOnOff", "student", map[string]any{"id": "4", "message": "Favorite updated successfully"}, http.StatusOK, ""}, - {"TestAdminCanToggleFavoriteCourseOnOff", "admin", map[string]any{"id": "4", "message": "Favorite updated successfully"}, http.StatusOK, ""}, - } - for _, test := range httpTests { - t.Run(test.testName, func(t *testing.T) { - req, err := http.NewRequest(http.MethodPut, "/api/courses/{id}/save", nil) - if err != nil { - t.Fatalf("unable to create new request, error is %v", err) - } - req.SetPathValue("id", test.mapKeyValues["id"].(string)) - handler := getHandlerByRole(server.handleFavoriteCourse, test.role) - rr := executeRequest(t, req, handler, test) - if test.expectedStatusCode == http.StatusOK { - received := rr.Body.String() - data := models.Resource[struct{}]{} - if err := json.Unmarshal([]byte(received), &data); err != nil { - t.Errorf("failed to unmarshal response error is %v", err) - } - if data.Message != test.mapKeyValues["message"] { - t.Errorf("handler returned wrong body: got %v want %v", data.Message, test.mapKeyValues["message"]) - } - //special case here, execute another request to toggle the user's favorite - req.SetPathValue("id", test.mapKeyValues["id"].(string)) - rr = httptest.NewRecorder() - handler.ServeHTTP(rr, req) - if status := rr.Code; status != http.StatusNoContent { - t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusNoContent) - } - } - }) - } -} - func getCourseSearch(search string) map[string]any { form := make(map[string]any) _, courses, err := server.Db.GetCourse(1, 10, search) diff --git a/backend/src/handlers/dashboard_test.go b/backend/src/handlers/dashboard_test.go index b119124f..679cce95 100644 --- a/backend/src/handlers/dashboard_test.go +++ b/backend/src/handlers/dashboard_test.go @@ -148,7 +148,6 @@ func TestHandleUserCourses(t *testing.T) { {"TestGetUserCoursesWithTagsAndOrderByProvNmDescAsUser", "student", getUserCoursesSearch(4, "asc", "provider_name", "", []string{"certificate", "grade", "progress_completion"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=asc&order_by=provider_name"}, {"TestGetUserCoursesWithTagsAndOrderByCoursePgrDescAsUser", "student", getUserCoursesSearch(4, "desc", "course_progress", "", []string{"certificate", "grade", "progress_completion"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=desc&order_by=course_progress"}, {"TestGetUserCoursesWithTagsAndOrderByProvNmDescAsUser", "student", getUserCoursesSearch(4, "asc", "provider_name", "", []string{"certificate", "grade", "progress_completion"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=asc&order_by=provider_name"}, - {"TestGetUserCoursesWithTagsAndOrderByIsFavdDescAsUser", "student", getUserCoursesSearch(4, "desc", "is_favorited", "", []string{"certificate", "grade", "progress_completion"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=desc&order_by=is_favorited"}, {"TestGetUserCoursesWithTagsAndOrderByTotalTmDescAsUser", "student", getUserCoursesSearch(4, "desc", "total_time", "", []string{"certificate", "grade", "progress_completion"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=desc&order_by=total_time"}, {"TestUserCoursesWithSearchAscAsUser", "student", getUserCoursesSearch(4, "asc", "", "of", []string{"certificate", "grade", "progress_completion", "pathway_completion", "college_credit"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=asc&search=of"}, {"TestUserCoursesWithSearchDescAsUser", "student", getUserCoursesSearch(4, "desc", "", "Intro", []string{"certificate", "grade", "progress_completion", "pathway_completion", "college_credit"}), http.StatusOK, "?tags=certificate,grade,progress_completion,pathway_completion,college_credit&order=desc&search=Intro"}, diff --git a/backend/src/handlers/milestones_handler_test.go b/backend/src/handlers/milestones_handler_test.go index ac4107ba..2a9b8520 100644 --- a/backend/src/handlers/milestones_handler_test.go +++ b/backend/src/handlers/milestones_handler_test.go @@ -241,7 +241,7 @@ func getNewMilestoneModelAndForm() (models.Milestone, map[string]any, error) { func getNewMilestoneForm() map[string]any { form := make(map[string]any) - _, courses, err := server.Db.GetProgram(1, 10, nil, "") + _, courses, err := server.Db.GetCourse(1, 10, "") if err != nil { form["err"] = err } diff --git a/backend/src/handlers/programs_handler.go b/backend/src/handlers/programs_handler.go index 73552949..65bc60ef 100644 --- a/backend/src/handlers/programs_handler.go +++ b/backend/src/handlers/programs_handler.go @@ -30,13 +30,14 @@ func (srv *Server) registerProgramsRoutes() []routeDef { */ func (srv *Server) handleIndexPrograms(w http.ResponseWriter, r *http.Request, log sLog) error { page, perPage := srv.getPaginationInfo(r) + userId := srv.userIdFromRequest(r) search := r.URL.Query().Get("search") tags := r.URL.Query()["tags"] var tagsSplit []string if len(tags) > 0 { tagsSplit = strings.Split(tags[0], ",") } - total, programs, err := srv.Db.GetProgram(page, perPage, tagsSplit, search) + total, programs, err := srv.Db.GetProgram(page, perPage, tagsSplit, search, userId) if err != nil { log.add("search", search) return newDatabaseServiceError(err) @@ -150,7 +151,7 @@ func (srv *Server) handleFavoriteProgram(w http.ResponseWriter, r *http.Request, return newInvalidIdServiceError(err, "program ID") } user_id := srv.userIdFromRequest(r) - favoriteRemoved, err := srv.Db.ToggleUserFavorite(user_id, uint(id)) + favoriteRemoved, err := srv.Db.ToggleProgramFavorite(user_id, uint(id)) if err != nil { log.add("programId", id) log.add("userId", user_id) diff --git a/backend/src/handlers/programs_handler_test.go b/backend/src/handlers/programs_handler_test.go index 58c1f9b5..6072a647 100644 --- a/backend/src/handlers/programs_handler_test.go +++ b/backend/src/handlers/programs_handler_test.go @@ -254,7 +254,7 @@ func TestHandleFavoriteProgram(t *testing.T) { } func getAllPrograms() map[string]any { - total, programs, err := server.Db.GetProgram(1, 10, nil, "") + total, programs, err := server.Db.GetProgram(1, 10, nil, "", 1) form := make(map[string]any) form["programs"] = programs form["err"] = err @@ -263,7 +263,7 @@ func getAllPrograms() map[string]any { } func getProgramsBySearch(tags string, search string) map[string]any { - total, programs, err := server.Db.GetProgram(1, 10, strings.Split(tags, ","), search) + total, programs, err := server.Db.GetProgram(1, 10, strings.Split(tags, ","), search, 1) form := make(map[string]any) form["programs"] = programs form["err"] = err diff --git a/backend/src/handlers/sections_handler_test.go b/backend/src/handlers/sections_handler_test.go index d5f2b91b..cec7ee38 100644 --- a/backend/src/handlers/sections_handler_test.go +++ b/backend/src/handlers/sections_handler_test.go @@ -274,7 +274,7 @@ func TestHandleDeleteSection(t *testing.T) { func getProgramSection(facilityId uint) map[string]any { form := make(map[string]any) - _, programs, err := server.Db.GetProgram(1, 10, nil, "") + _, programs, err := server.Db.GetProgram(1, 10, nil, "", 1) if err != nil { form["err"] = err } @@ -286,7 +286,7 @@ func getProgramSection(facilityId uint) map[string]any { } func getProgramId() map[string]any { form := make(map[string]any) - _, programs, err := server.Db.GetProgram(1, 10, nil, "") + _, programs, err := server.Db.GetProgram(1, 10, nil, "", 1) if err != nil { form["err"] = err } @@ -296,7 +296,7 @@ func getProgramId() map[string]any { func getSectionId() map[string]any { form := make(map[string]any) - _, programs, err := server.Db.GetProgram(1, 10, nil, "") + _, programs, err := server.Db.GetProgram(1, 10, nil, "", 1) if err != nil { form["err"] = err } diff --git a/backend/src/models/dashboard.go b/backend/src/models/dashboard.go index 455e1708..69e520be 100644 --- a/backend/src/models/dashboard.go +++ b/backend/src/models/dashboard.go @@ -21,12 +21,15 @@ func (LeftMenuLink) TableName() string { return "left_menu_links" } -type UserFavorite struct { - ID uint `gorm:"primaryKey" json:"-"` - UserID uint `json:"user_id"` - CourseID uint `json:"course_id"` +type ProgramFavorite struct { + ID uint `gorm:"primaryKey" json:"-"` + UserID uint `json:"user_id"` + ProgramID uint `json:"program_id"` + + User *User `json:"user,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"` + Program *Program `json:"program,omitempty" gorm:"foreignKey:ProgramID;constraint:OnDelete CASCADE"` } -func (UserFavorite) TableName() string { - return "favorites" +func (ProgramFavorite) TableName() string { + return "program_favorites" } diff --git a/backend/src/models/program.go b/backend/src/models/program.go index d28c60f7..e6cd0448 100644 --- a/backend/src/models/program.go +++ b/backend/src/models/program.go @@ -7,9 +7,11 @@ type Program struct { CreditType string `json:"credit_type" gorm:"not null" validate:"required,max=50"` ProgramStatus string `json:"program_status" gorm:"not null" validate:"required,max=50"` ProgramType string `json:"program_type" gorm:"not null" validate:"required,max=50"` + IsFavorited bool `json:"is_favorited" gorm:"-"` - Tags []ProgramTag `json:"tags" gorm:"foreignKey:ProgramID;references:ID"` - Facilities []Facility `json:"facilities" gorm:"many2many:facilities_programs;"` + Tags []ProgramTag `json:"tags" gorm:"foreignKey:ProgramID;references:ID"` + Facilities []Facility `json:"facilities" gorm:"many2many:facilities_programs;"` + Favorites []ProgramFavorite `json:"-" gorm:"foreignKey:ProgramID;references:ID"` } func (Program) TableName() string { return "programs" } diff --git a/backend/src/models/users.go b/backend/src/models/users.go index 6a606baf..57346d97 100644 --- a/backend/src/models/users.go +++ b/backend/src/models/users.go @@ -40,11 +40,11 @@ type User struct { FacilityID uint `json:"facility_id"` /* foreign keys */ - Mappings []ProviderUserMapping `json:"mappings,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"` - FavoriteVideos []VideoFavorite `json:"favorite_videos,omitempty" goem:"foreignKey:UserID;constraint:OnDelete CASCADE"` - FavoriteCourses []UserFavorite `json:"favorite_courses,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"` - Facility *Facility `json:"facility,omitempty" gorm:"foreignKey:FacilityID;constraint:OnDelete SET NULL"` - UserRole *Role `json:"-" gorm:"foreignKey:Role;constraint:OnDelete SET NULL"` + Mappings []ProviderUserMapping `json:"mappings,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"` + FavoriteVideos []VideoFavorite `json:"favorite_videos,omitempty" goem:"foreignKey:UserID;constraint:OnDelete CASCADE"` + FavoritePrograms []ProgramFavorite `json:"favorite_programs,omitempty" gorm:"foreignKey:UserID;constraint:OnDelete CASCADE"` + Facility *Facility `json:"facility,omitempty" gorm:"foreignKey:FacilityID;constraint:OnDelete SET NULL"` + UserRole *Role `json:"-" gorm:"foreignKey:Role;constraint:OnDelete SET NULL"` } type ImportUser struct { diff --git a/config/nginx.conf b/config/nginx.conf index 29fefb09..98275a47 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -1,75 +1,25 @@ server { listen 80; - server_name localhost; + server_name replace-me-with-host.com; gzip on; http2 on; - location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri/ /index.html; - } - - location /videos/ { - proxy_pass http://server:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /api { - proxy_pass http://server:8080; - proxy_buffering on; - proxy_cache_methods GET HEAD; - proxy_http_version 1.1; - proxy_set_header Real-IP $remote_addr; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } + # API gateway is handled with traefik in production, so only statics are served - location /oauth2 { - proxy_pass http://hydra:4444; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; + location ~* \.(?:ico|css|js|gif|jpe?g|png|svg|woff|woff2|ttf|eot|otf|webp)$ { + expires 1d; + add_header Cache-Control "public, max-age=86400"; } - location /sessions/ { - proxy_pass http://kratos:4433; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; + location ~* \.(?:html)$ { + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"; } - location /self-service { - proxy_pass http://kratos:4433; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - } - - location /.well-known/jwks.json { - proxy_pass http://hydra:4444; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - - location /userinfo { - proxy_pass http://hydra:4444; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-store"; + } } diff --git a/frontend/Dockerfile b/frontend/Dockerfile index d197b25a..3bc24e58 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,7 +3,7 @@ WORKDIR /app/ COPY . . RUN yarn && yarn build -FROM nginx:1.21.3-alpine +FROM nginx:1.27.2-alpine-slim COPY --from=builder /app/dist /usr/share/nginx/html EXPOSE 80 ENTRYPOINT ["nginx", "-g", "daemon off;"] diff --git a/frontend/src/Components/CatalogCourseCard.tsx b/frontend/src/Components/CatalogCourseCard.tsx index ce763e6e..b60b1514 100644 --- a/frontend/src/Components/CatalogCourseCard.tsx +++ b/frontend/src/Components/CatalogCourseCard.tsx @@ -1,41 +1,24 @@ -import { BookmarkIcon } from '@heroicons/react/24/solid'; -import { BookmarkIcon as BookmarkIconOutline } from '@heroicons/react/24/outline'; import LightGreenPill from './pill-labels/LightGreenPill'; import RedPill from './pill-labels/RedPill'; import YellowPill from './pill-labels/YellowPill'; import GreyPill from './pill-labels/GreyPill'; -import { MouseEvent } from 'react'; import { CourseCatalogue, OutcomePillType, PillTagType, ViewType } from '@/common'; -import API from '@/api/api'; export default function CatalogCourseCard({ course, - callMutate, view }: { course: CourseCatalogue; - callMutate: () => void; view?: ViewType; }) { const coverImage = course.thumbnail_url; const course_type: PillTagType = course.course_type as PillTagType; - function updateFavorite(e: MouseEvent) { - e.preventDefault(); - API.put(`courses/${course.course_id}/save`, {}) - .then(() => { - callMutate(); - }) - .catch((error) => { - console.log(error); - }); - } - let coursePill: JSX.Element = Permission Only; if (course_type == PillTagType.Open) coursePill = Open Enrollment; @@ -44,16 +27,6 @@ export default function CatalogCourseCard({ if (course_type == PillTagType.SelfPaced) coursePill = Self-Paced; - let bookmark: JSX.Element; - if (course.is_favorited) - bookmark = ; - else - bookmark = ( - - ); - const outcomeTypes: OutcomePillType[] = course.outcome_types .split(',') .map((outcome) => outcome as OutcomePillType) @@ -80,7 +53,6 @@ export default function CatalogCourseCard({ >
-
updateFavorite(e)}>{bookmark}

{course.course_name}

|

{course.provider_name}

@@ -96,12 +68,6 @@ export default function CatalogCourseCard({ } else { return (
-
updateFavorite(e)} - > - {bookmark} -
void; } export default function EnrolledCourseCard({ course, recent, - view, - callMutate + view }: CourseCard) { const coverImage = course.thumbnail_url; const url = course.external_url; let status: CourseStatus | null = null; if (course.course_progress == 100) status = CourseStatus.Completed; - function updateFavorite(e: React.MouseEvent) { - e.preventDefault(); - API.put(`courses/${course.id}/save`, {}) - .then(() => { - if (callMutate) callMutate(); - }) - .catch((error) => { - console.log(error); - }); - } if (view == ViewType.List) { return (
-
updateFavorite(e)}> - {!recent && - (course.is_favorited ? ( - - ) : ( - - ))} -

{course.course_name}

|

{course.provider_platform_name}

@@ -83,17 +56,6 @@ export default function EnrolledCourseCard({
diff --git a/frontend/src/Components/ProgramCard.tsx b/frontend/src/Components/ProgramCard.tsx index 22412469..4e8d585a 100644 --- a/frontend/src/Components/ProgramCard.tsx +++ b/frontend/src/Components/ProgramCard.tsx @@ -4,6 +4,7 @@ import LightGreenPill from './pill-labels/LightGreenPill'; import { MouseEvent } from 'react'; import { Facility, Program, ViewType } from '@/common'; import API from '@/api/api'; +import { isAdministrator, useAuth } from '@/useAuth'; export default function ProgramCard({ program, @@ -14,6 +15,7 @@ export default function ProgramCard({ callMutate: () => void; view?: ViewType; }) { + const { user } = useAuth(); function updateFavorite(e: MouseEvent) { e.preventDefault(); API.put(`programs/${program.id}/save`, {}) @@ -31,13 +33,16 @@ export default function ProgramCard({ .join(', '); }; - const bookmark: JSX.Element = program.is_favorited ? ( - - ) : ( - - ); + let bookmark: JSX.Element = <>; + if (user && !isAdministrator(user)) { + bookmark = program.is_favorited ? ( + + ) : ( + + ); + } const tagPills = program.tags.map((tag) => ( {tag.value.toString()} diff --git a/frontend/src/Components/forms/LoginForm.tsx b/frontend/src/Components/forms/LoginForm.tsx index 8ba0f876..eca0e7d4 100644 --- a/frontend/src/Components/forms/LoginForm.tsx +++ b/frontend/src/Components/forms/LoginForm.tsx @@ -1,4 +1,9 @@ -import { useLoaderData, Form, useNavigation } from 'react-router-dom'; +import { + useLoaderData, + Form, + useNavigation, + useNavigate +} from 'react-router-dom'; import { TextInput } from '@/Components/inputs/TextInput'; import InputError from '@/Components/inputs/InputError'; import PrimaryButton from '@/Components/PrimaryButton'; @@ -17,6 +22,7 @@ interface Inputs { export default function LoginForm() { const loaderData = useLoaderData() as AuthFlow; + const navigate = useNavigate(); const navigation = useNavigation(); const processing = navigation.state === 'submitting'; const [user, setUser] = useState(undefined); @@ -36,15 +42,15 @@ export default function LoginForm() { data )) as ServerResponseOne; if (resp.success) { - window.location.href = - resp.data.redirect_to ?? resp.data.redirect_browser_to; + navigate(resp.data.redirect_to ?? resp.data.redirect_browser_to); return; } setErrorMessage(true); }; useEffect(() => { if (loaderData.redirect_to) { - window.location.href = loaderData.redirect_to; + navigate(loaderData.redirect_to); + return; } if (loaderData.identifier) { setUser(loaderData.identifier); diff --git a/frontend/src/Context/AuthContext.tsx b/frontend/src/Context/AuthContext.tsx index d09fa94b..6fcf2707 100644 --- a/frontend/src/Context/AuthContext.tsx +++ b/frontend/src/Context/AuthContext.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useState, useEffect } from 'react'; import { INIT_KRATOS_LOGIN_FLOW, User } from '@/common'; import { AuthContext, fetchUser } from '@/useAuth'; +import { useNavigate } from 'react-router-dom'; export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children @@ -10,6 +11,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ const passReset = '/reset-password'; const [user, setUser] = useState(); const [loading, setLoading] = useState(true); + const navigate = useNavigate(); useEffect(() => { const checkAuth = async () => { const authUser = await fetchUser(); @@ -31,7 +33,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ user?.password_reset && window.location.pathname !== passReset ) { - window.location.href = passReset; + navigate(passReset); return null; } return ( diff --git a/frontend/src/Pages/CourseCatalog.tsx b/frontend/src/Pages/CourseCatalog.tsx index bf1c492a..4abd21b6 100644 --- a/frontend/src/Pages/CourseCatalog.tsx +++ b/frontend/src/Pages/CourseCatalog.tsx @@ -17,10 +17,9 @@ export default function CourseCatalog() { const [activeView, setActiveView] = useState(ViewType.Grid); const [searchTerm, setSearchTerm] = useState(''); const [order, setOrder] = useState('asc'); - const { data, error, mutate } = useSWR< - ServerResponse, - AxiosError - >(`/api/users/${user.id}/catalogue?search=${searchTerm}&order=${order}`); + const { data, error } = useSWR, AxiosError>( + `/api/users/${user.id}/catalogue?search=${searchTerm}&order=${order}` + ); const courseData = data?.data as CourseCatalogue[]; function handleSearch(newSearch: string) { @@ -64,7 +63,6 @@ export default function CourseCatalog() { return ( void mutate()} view={activeView} key={course.course_id} /> diff --git a/frontend/src/Pages/MyCourses.tsx b/frontend/src/Pages/MyCourses.tsx index 96c1eca8..412f6095 100644 --- a/frontend/src/Pages/MyCourses.tsx +++ b/frontend/src/Pages/MyCourses.tsx @@ -20,7 +20,6 @@ export default function MyCourses() { const tabs: Tab[] = [ { name: 'Current', value: 'in_progress' }, { name: 'Completed', value: 'completed' }, - { name: 'Favorited', value: 'is_favorited' }, { name: 'All', value: 'all' } ]; @@ -31,7 +30,7 @@ export default function MyCourses() { const [activeTab, setActiveTab] = useState(tabs[0]); const [activeView, setActiveView] = useState(ViewType.Grid); - const { data, mutate, isLoading, error } = useSWR< + const { data, isLoading, error } = useSWR< ServerResponse, AxiosError >( @@ -96,9 +95,6 @@ export default function MyCourses() { { - void mutate(); - }} key={index} /> ); diff --git a/frontend/src/Pages/StudentDashboard.tsx b/frontend/src/Pages/StudentDashboard.tsx index 1196724f..6983afcc 100644 --- a/frontend/src/Pages/StudentDashboard.tsx +++ b/frontend/src/Pages/StudentDashboard.tsx @@ -173,9 +173,11 @@ export default function StudentDashboard() { } ) ) : ( -

- No activity to show. -

+ + + No activity to show. + + )} diff --git a/frontend/src/common.ts b/frontend/src/common.ts index 92f43ed7..209831e8 100644 --- a/frontend/src/common.ts +++ b/frontend/src/common.ts @@ -406,7 +406,6 @@ export interface UserCourses { provider_platform_name: string; external_url: string; course_progress: number; - is_favorited: boolean; total_time: number; grade?: string; alt_name?: string; @@ -421,7 +420,6 @@ export interface CourseCatalogue { external_url: string; course_type: string; description: string; - is_favorited: boolean; outcome_types: string; } @@ -592,7 +590,6 @@ export interface RecentCourse { provider_platform_name: string; external_url: string; id?: number; - is_favorited?: boolean; total_time?: number; }