From 90e650dce08b670a0bec4c56c9d4c95634cae43a Mon Sep 17 00:00:00 2001 From: Oliviayuuuu317 Date: Wed, 22 Apr 2026 18:13:29 -0400 Subject: [PATCH 01/13] add rss feed scraping --- models/rss_feed.go | 139 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 models/rss_feed.go diff --git a/models/rss_feed.go b/models/rss_feed.go new file mode 100644 index 0000000..6f8cca4 --- /dev/null +++ b/models/rss_feed.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "regexp" + "strings" +) + +type RSS struct { + Channel Channel `xml:"channel"` +} + +type Channel struct { + Items []Item `xml:"item"` +} + +type Item struct { + Title string `xml:"title"` + Description string `xml:"description"` + PubDate string `xml:"pubDate"` +} + +type Song struct { + Title string + Source string + Artist string +} + +type TimeSlot struct { + Time string + Songs []Song +} + +type ConcertDay struct { + Title string + TimeSlots []TimeSlot +} + +// matches HTML tag +var tagPattern = regexp.MustCompile(`<[^>]+>`) + +// removes all HTML tags from a string +func stripTags(s string) string { + return strings.TrimSpace(tagPattern.ReplaceAllString(s, "")) +} + +var originPattern = regexp.MustCompile(`\(from "([^"]+)"\)`) + +func parseSong(line string) Song { + song := Song{} + + title, artist, found := strings.Cut(line, " / ") + if found { + song.Artist = strings.TrimSpace(artist) + } + + match := originPattern.FindStringSubmatch(title) + + if match != nil { + song.Source = match[1] + title = strings.TrimSpace(originPattern.ReplaceAllString(title, "")) + } + song.Title = title + return song + +} + +// converts HTML into TimeSlot structure +func parseDescription(desc string) []TimeSlot { + var slots []TimeSlot + var current *TimeSlot + + var lines []string + for _, current := range strings.Split(desc, "
") { + for _, line := range strings.Split(current, "\n") { + lines = append(lines, line) + } + } + + for _, line := range lines { + text := stripTags(line) + if text == "" { + continue + } + + isHeader := false + switch text { + case "Morning", "Afternoon", "Evening": + isHeader = true + } + if isHeader { + slots = append(slots, TimeSlot{Time: text}) + current = &slots[len(slots)-1] + } else if current != nil { + current.Songs = append(current.Songs, parseSong(text)) + } + } + return slots +} + +func main() { + resp, err := http.Get("https://apps.chimes.cornell.edu/music/rss.xml") + if err != nil { + panic(err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + panic(err) + } + + var rss RSS + if err := xml.Unmarshal(data, &rss); err != nil { + panic(err) + } + + var allConcerts []ConcertDay + for _, item := range rss.Channel.Items { + cleanHTML := strings.NewReplacer("<", "<", ">", ">", "&", "&").Replace(item.Description) + day := ConcertDay{ + Title: item.Title, + TimeSlots: parseDescription(cleanHTML), + } + allConcerts = append(allConcerts, day) + } + + for _, day := range allConcerts { + fmt.Println("=====", day.Title, "=====") + for _, slot := range day.TimeSlots { + fmt.Println(slot.Time) + for _, song := range slot.Songs { + fmt.Printf("%q by %q from %q\n", song.Title, song.Artist, song.Source) + } + } + } +} From c11aeaef2ef771b0638b7b5edc168ec07c83328e Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 00:40:58 +0700 Subject: [PATCH 02/13] Add song, session, kudo models and RSS scraper - Import models from tran/chimes_sessions branch - Add Song, Session, SessionSong, Kudo, KudoType structs - Implement GetOrCreateSong and GetOrCreateSession helpers - Add RSS scraper to parse and store concert data + make db insertions idempotent --- cmd/rss_scraper/main.go | 82 +++++++++++++++++++++++++++++++++++++++++ models/kudos.go | 47 +++++++++++++++++++++++ models/rss_feed.go | 61 ++++-------------------------- models/sessions.go | 50 +++++++++++++++++++++++++ models/setup.go | 5 ++- models/songs.go | 22 +++++++++++ 6 files changed, 211 insertions(+), 56 deletions(-) create mode 100644 cmd/rss_scraper/main.go create mode 100644 models/kudos.go create mode 100644 models/sessions.go create mode 100644 models/songs.go diff --git a/cmd/rss_scraper/main.go b/cmd/rss_scraper/main.go new file mode 100644 index 0000000..eb921d5 --- /dev/null +++ b/cmd/rss_scraper/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/xml" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/cuappdev/chimes-backend/models" + "gorm.io/gorm/clause" +) + +func main() { + if err := models.ConnectDatabase(); err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + + resp, err := http.Get("https://apps.chimes.cornell.edu/music/rss.xml") + if err != nil { + log.Fatalf("Failed to fetch RSS: %v", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatalf("Failed to read response body: %v", err) + } + + var rss models.RSS + if err := xml.Unmarshal(data, &rss); err != nil { + log.Fatalf("Failed to unmarshal RSS: %v", err) + } + + for _, item := range rss.Channel.Items { + concertDate, err := parseConcertDate(item.Title) + if err != nil { + log.Printf("Failed to parse date from title %q: %v", item.Title, err) + continue + } + + cleanHTML := strings.NewReplacer("<", "<", ">", ">", "&", "&").Replace(item.Description) + timeSlots := models.ParseDescription(cleanHTML) + + for _, slot := range timeSlots { + timeOfDay := models.TimeOfDay(strings.ToLower(slot.Time)) + session, err := models.GetOrCreateSession(concertDate, timeOfDay) + if err != nil { + log.Printf("Failed to get/create session for %v %v: %v", concertDate, timeOfDay, err) + continue + } + + for order, parsedSong := range slot.Songs { + song, err := models.GetOrCreateSong(parsedSong.Title, parsedSong.Artist, parsedSong.Source) + if err != nil { + log.Printf("Failed to get/create song %q by %q: %v", parsedSong.Title, parsedSong.Artist, err) + continue + } + + sessionSong := models.SessionSong{ + SessionID: session.ID, + SongID: song.ID, + Order: uint(order), + } + if err := models.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&sessionSong).Error; err != nil { + log.Printf("Failed to create session song link: %v", err) + } + } + } + + log.Printf("Processed %d time slots for %s", len(timeSlots), item.Title) + } + + log.Println("RSS scraper completed successfully") +} + +// parseConcertDate parses an RSS item title like "Friday, June 20, 2026" into a time.Time +func parseConcertDate(title string) (time.Time, error) { + const dateFormat = "Monday, January 2, 2006" + return time.Parse(dateFormat, title) +} diff --git a/models/kudos.go b/models/kudos.go new file mode 100644 index 0000000..ac75a64 --- /dev/null +++ b/models/kudos.go @@ -0,0 +1,47 @@ +package models + +type KudoType struct { + ID uint `json:"id" gorm:"primaryKey"` + Label string `json:"label" gorm:"uniqueIndex;not null"` +} + +type KudoInput struct { + SessionID uint `json:"session_id" binding:"required"` + KudoTypeID uint `json:"kudo_type_id" binding:"required"` +} + +var DefaultKudoTypes = []KudoType{ + {ID: 1, Label: "On beat"}, + {ID: 2, Label: "Great timing"}, + {ID: 3, Label: "Solid technique"}, + {ID: 4, Label: "Love the song choice!"}, + {ID: 5, Label: "Made my day"}, +} + +type Kudo struct { + ID uint `json:"id" gorm:"primaryKey"` + SessionID uint `json:"session_id" gorm:"not null;index:idx_kudo_unique,unique"` + KudoTypeID uint `json:"kudo_type_id" gorm:"not null;index:idx_kudo_unique,unique"` + UserID uint `json:"user_id" gorm:"not null;index:idx_kudo_unique,unique"` +} + +type KudoCount struct { + KudoTypeID uint `json:"kudo_type_id"` + Label string `json:"label"` + Count int64 `json:"count"` +} + +type KudoSessionRequest struct { + SessionID uint `json:"session_id" binding:"required"` +} + +func GetKudoCountsForSession(sessionID uint) ([]KudoCount, error) { + var counts []KudoCount + err := DB.Table("kudos"). + Select("kudos.kudo_type_id, kudo_types.label, COUNT(*) as count"). + Joins("JOIN kudo_types ON kudo_types.id = kudos.kudo_type_id"). + Where("kudos.session_id = ?", sessionID). + Group("kudos.kudo_type_id, kudo_types.label"). + Scan(&counts).Error + return counts, err +} diff --git a/models/rss_feed.go b/models/rss_feed.go index 6f8cca4..a986d2b 100644 --- a/models/rss_feed.go +++ b/models/rss_feed.go @@ -1,10 +1,6 @@ -package main +package models import ( - "encoding/xml" - "fmt" - "io" - "net/http" "regexp" "strings" ) @@ -23,7 +19,7 @@ type Item struct { PubDate string `xml:"pubDate"` } -type Song struct { +type ParsedSong struct { Title string Source string Artist string @@ -31,12 +27,7 @@ type Song struct { type TimeSlot struct { Time string - Songs []Song -} - -type ConcertDay struct { - Title string - TimeSlots []TimeSlot + Songs []ParsedSong } // matches HTML tag @@ -49,8 +40,8 @@ func stripTags(s string) string { var originPattern = regexp.MustCompile(`\(from "([^"]+)"\)`) -func parseSong(line string) Song { - song := Song{} +func parseSong(line string) ParsedSong { + song := ParsedSong{} title, artist, found := strings.Cut(line, " / ") if found { @@ -65,11 +56,10 @@ func parseSong(line string) Song { } song.Title = title return song - } -// converts HTML into TimeSlot structure -func parseDescription(desc string) []TimeSlot { +// ParseDescription converts HTML into TimeSlot structure +func ParseDescription(desc string) []TimeSlot { var slots []TimeSlot var current *TimeSlot @@ -100,40 +90,3 @@ func parseDescription(desc string) []TimeSlot { } return slots } - -func main() { - resp, err := http.Get("https://apps.chimes.cornell.edu/music/rss.xml") - if err != nil { - panic(err) - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - panic(err) - } - - var rss RSS - if err := xml.Unmarshal(data, &rss); err != nil { - panic(err) - } - - var allConcerts []ConcertDay - for _, item := range rss.Channel.Items { - cleanHTML := strings.NewReplacer("<", "<", ">", ">", "&", "&").Replace(item.Description) - day := ConcertDay{ - Title: item.Title, - TimeSlots: parseDescription(cleanHTML), - } - allConcerts = append(allConcerts, day) - } - - for _, day := range allConcerts { - fmt.Println("=====", day.Title, "=====") - for _, slot := range day.TimeSlots { - fmt.Println(slot.Time) - for _, song := range slot.Songs { - fmt.Printf("%q by %q from %q\n", song.Title, song.Artist, song.Source) - } - } - } -} diff --git a/models/sessions.go b/models/sessions.go new file mode 100644 index 0000000..40cec30 --- /dev/null +++ b/models/sessions.go @@ -0,0 +1,50 @@ +package models + +import "time" + +type TimeOfDay string + +const ( + Morning TimeOfDay = "morning" + Afternoon TimeOfDay = "afternoon" + Evening TimeOfDay = "evening" +) + +type Session struct { + ID uint `json:"id" gorm:"primaryKey"` + Day time.Time `json:"date" binding:"required"` + TimeOfDay TimeOfDay `json:"time_of_day" binding:"required"` +} + +type SessionInput struct { + Day time.Time `json:"date" binding:"required"` + TimeOfDay TimeOfDay `json:"time_of_day" binding:"required"` +} + +type SessionSong struct { + SessionID uint `json:"session_id" gorm:"primaryKey"` + SongID uint `json:"song_id" gorm:"primaryKey"` + Order uint `json:"order"` +} + +func GetOrCreateSession(day time.Time, timeOfDay TimeOfDay) (*Session, error) { + var session Session + result := DB.Where("day = ? AND time_of_day = ?", day, timeOfDay).First(&session) + if result.Error == nil { + return &session, nil + } + session = Session{Day: day, TimeOfDay: timeOfDay} + if err := DB.Create(&session).Error; err != nil { + return nil, err + } + return &session, nil +} + +func CreateDailySessions(day time.Time) error { + for _, t := range []TimeOfDay{Morning, Afternoon, Evening} { + if _, err := GetOrCreateSession(day, t); err != nil { + return err + } + } + return nil +} diff --git a/models/setup.go b/models/setup.go index 6d44dbb..4f050ad 100644 --- a/models/setup.go +++ b/models/setup.go @@ -6,6 +6,7 @@ import ( "os" "time" + "github.com/joho/godotenv" "gorm.io/driver/postgres" "gorm.io/gorm" ) @@ -13,6 +14,7 @@ import ( var DB *gorm.DB func ConnectDatabase() error { + godotenv.Load() // Build connection string from env variables host := getEnv("DB_HOST", "localhost") user := getEnv("DB_USER", "postgres") @@ -37,8 +39,7 @@ func ConnectDatabase() error { sqlDB.SetMaxOpenConns(100) sqlDB.SetConnMaxLifetime(time.Hour) - // Make sure to include all models to migrate here - err = database.AutoMigrate(&User{}, &FCMToken{}) + err = database.AutoMigrate(&User{}, &FCMToken{}, &Session{}, &Song{}, &SessionSong{}, &Kudo{}, &KudoType{}) if err != nil { return fmt.Errorf("failed to migrate database: %w", err) } diff --git a/models/songs.go b/models/songs.go new file mode 100644 index 0000000..d274d93 --- /dev/null +++ b/models/songs.go @@ -0,0 +1,22 @@ +package models + +type Song struct { + ID uint `json:"id" gorm:"primary_key"` + SongName string `json:"song_name"` + Artist string `json:"artist"` + Source string `json:"source"` + InSongBook bool `json:"in_song_book"` +} + +func GetOrCreateSong(name, artist, source string) (*Song, error) { + var song Song + result := DB.Where("song_name = ? AND artist = ?", name, artist).First(&song) + if result.Error == nil { + return &song, nil + } + song = Song{SongName: name, Artist: artist, Source: source, InSongBook: false} + if err := DB.Create(&song).Error; err != nil { + return nil, err + } + return &song, nil +} From 808332f8258fe3622ba02f171eef3396cd6f9803 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 00:58:27 +0700 Subject: [PATCH 03/13] Parse concert dates in Eastern Time Dates from RSS were being interpreted as UTC instead of Eastern Time, causing them to appear one day early in the database. Use ParseInLocation to correctly parse as America/New_York timezone --- cmd/rss_scraper/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/rss_scraper/main.go b/cmd/rss_scraper/main.go index eb921d5..2c3ab70 100644 --- a/cmd/rss_scraper/main.go +++ b/cmd/rss_scraper/main.go @@ -78,5 +78,6 @@ func main() { // parseConcertDate parses an RSS item title like "Friday, June 20, 2026" into a time.Time func parseConcertDate(title string) (time.Time, error) { const dateFormat = "Monday, January 2, 2006" - return time.Parse(dateFormat, title) + loc, _ := time.LoadLocation("America/New_York") + return time.ParseInLocation(dateFormat, title, loc) } From debb40482fe3e0007033e2977ca90f69f1276317 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 01:18:18 +0700 Subject: [PATCH 04/13] Fix userID always being 0 in notification controllers and improve FindOrCreateUser error handling - Use middleware.UIDFrom + GetUserByFirebaseUID to correctly resolve the authenticated user in RegisterFCMToken and SendTestNotification instead of c.GetUint(userID) which always returned 0 - Distinguish gorm.ErrRecordNotFound from other DB errors in FindOrCreateUser so real DB failures aren't silently treated as missing users --- controllers/notifications.go | 33 ++++++++++++++++++++------------- models/users.go | 19 ++++++++++++++----- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/controllers/notifications.go b/controllers/notifications.go index bbadf80..090e702 100644 --- a/controllers/notifications.go +++ b/controllers/notifications.go @@ -2,9 +2,11 @@ package controllers import ( "net/http" - "github.com/gin-gonic/gin" + + "github.com/cuappdev/chimes-backend/middleware" "github.com/cuappdev/chimes-backend/models" "github.com/cuappdev/chimes-backend/services" + "github.com/gin-gonic/gin" ) // Struct for register token @@ -21,16 +23,18 @@ func RegisterFCMToken(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - - // Get user ID from auth middleware - userID := c.GetUint("userID") - - err := models.SaveOrUpdateToken(userID, input.Token, input.Platform) + + user, err := models.GetUserByFirebaseUID(middleware.UIDFrom(c)) if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + + if err := models.SaveOrUpdateToken(user.ID, input.Token, input.Platform); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register token"}) return } - + c.JSON(http.StatusOK, gin.H{"message": "Token registered successfully"}) } @@ -58,20 +62,23 @@ func DeleteFCMToken(c *gin.Context) { // POST /fcm/test // Send a test notification to the user func SendTestNotification(c *gin.Context) { - userID := c.GetUint("userID") - + user, err := models.GetUserByFirebaseUID(middleware.UIDFrom(c)) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + payload := services.NotificationPayload{ Title: "Test Notification", Body: "This is a test notification", Data: map[string]string{"type": "test"}, } - - err := services.SendToUser(userID, payload) - if err != nil { + + if err := services.SendToUser(user.ID, payload); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - + c.JSON(http.StatusOK, gin.H{"message": "Notification sent"}) } diff --git a/models/users.go b/models/users.go index f18df76..533e477 100644 --- a/models/users.go +++ b/models/users.go @@ -1,8 +1,11 @@ package models import ( + "errors" "log" "time" + + "gorm.io/gorm" ) type User struct { @@ -43,19 +46,17 @@ func (user *User) ToResponse() UserResponse { func FindOrCreateUser(firebaseUID, email, firstName, lastName string) (*User, error) { var user User - // Try to find existing user result := DB.Where("firebase_uid = ?", firebaseUID).First(&user) - if result.Error != nil { - log.Printf("[ERROR] User not found by Firebase UID (%s): %v", firebaseUID, result.Error) - // User doesn't exist, create new one + if !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, result.Error + } user = User{ Firebase_UID: firebaseUID, Email: email, FirstName: firstName, LastName: lastName, } - if err := DB.Create(&user).Error; err != nil { log.Printf("[ERROR] Failed to create user (Firebase UID: %s): %v", firebaseUID, err) return nil, err @@ -65,6 +66,14 @@ func FindOrCreateUser(firebaseUID, email, firstName, lastName string) (*User, er return &user, nil } +func GetUserByFirebaseUID(firebaseUID string) (*User, error) { + var user User + if err := DB.Where("firebase_uid = ?", firebaseUID).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + // UpdateRefreshToken updates the user's refresh token func (u *User) UpdateRefreshToken(refreshToken string) error { return DB.Model(u).Update("refresh_token", refreshToken).Error From bc51c3f8c3aebdf145e5971042f28ecb1d387437 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 01:18:36 +0700 Subject: [PATCH 05/13] Stop server on fatal startup failures Replace log.Printf with log.Fatalf for DB and Firebase init errors so the server exits immediately instead of continuing with nil dependencies that cause panics on the first request. --- main.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index e2e2682..ef1f1ac 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,7 @@ func main() { log.Println("Connecting to database...") // Connect to DB if err := models.ConnectDatabase(); err != nil { - log.Printf("[FATAL] Database connection failed: %v", err) + log.Fatalf("[FATAL] Database connection failed: %v", err) } // Migrate fcm token table @@ -35,16 +35,16 @@ func main() { serviceAccountPath := "service-account-key.json" // Log working dir and check file exists if _, err := os.Stat(serviceAccountPath); err != nil { - log.Printf("[FATAL] Missing service account file: %s (cwd: %s): %v", serviceAccountPath, getwdSafe(), err) + log.Fatalf("[FATAL] Missing service account file: %s (cwd: %s): %v", serviceAccountPath, getwdSafe(), err) } ac, err := auth.NewAuthClient(context.Background(), serviceAccountPath) if err != nil { - log.Printf("[FATAL] Firebase init failed: %v", err) + log.Fatalf("[FATAL] Firebase init failed: %v", err) } // Initialize Firebase Messaging if err := auth.InitFirebase(serviceAccountPath); err != nil { - log.Printf("[FATAL] Firebase Messaging init failed: %v", err) + log.Fatalf("[FATAL] Firebase Messaging init failed: %v", err) } log.Println("Setting up routes...") From a48c5e9f9abd94f7be12d1036c2ef3ee8a4f5972 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 01:21:31 +0700 Subject: [PATCH 06/13] for local dev running docker: wait for psql to be healthy before starting app container --- docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9bae1de..22148ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,8 @@ services: DB_PORT: ${DB_PORT:-5432} DB_SSLMODE: ${DB_SSLMODE:-disable} depends_on: - - db + db: + condition: service_healthy volumes: - ./service-account-key.json:/service-account-key.json ports: From 93d60e7030b81c50506b65abd4f9525a92978d9b Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 01:29:49 +0700 Subject: [PATCH 07/13] Fix nil pointer dereference in SendToUser Check for error before dereferencing response.FailureCount. When SendMulticast fails, response is nil, causing a panic. Return early if there's an error. --- services/notificationservice.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/notificationservice.go b/services/notificationservice.go index a4a49b5..e4fe92d 100644 --- a/services/notificationservice.go +++ b/services/notificationservice.go @@ -31,7 +31,10 @@ func SendToUser(userID uint, payload NotificationPayload) error { client := auth.GetMessagingClient() response, err := client.SendMulticast(context.Background(), message) - + if err != nil { + return err + } + // Remove invalid tokens if response.FailureCount > 0 { for idx, resp := range response.Responses { @@ -40,8 +43,8 @@ func SendToUser(userID uint, payload NotificationPayload) error { } } } - - return err + + return nil } // sends to a specific token From 02b974287746cd01d1b7428a71e167e9b8973bce Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 01:29:52 +0700 Subject: [PATCH 08/13] Include timezone database in Docker image Copy /usr/share/zoneinfo from the builder to the final distroless image so the RSS scraper can load timezone data (America/New_York) without errors. --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index a4cdfec..d951481 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o /chimes-backend FROM gcr.io/distroless/base-debian11 WORKDIR / COPY --from=build-stage /chimes-backend /chimes-backend +COPY --from=build-stage /usr/share/zoneinfo /usr/share/zoneinfo EXPOSE 8080 USER nonroot:nonroot ENTRYPOINT ["/chimes-backend"] \ No newline at end of file From 5a58f226bbf8eb491e90010116189291fd5912c4 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 01:29:56 +0700 Subject: [PATCH 09/13] Check timezone load error in parseConcertDate Check LoadLocation error instead of ignoring it. Fail fast if tzdata is unavailable rather than silently falling back to UTC. --- cmd/rss_scraper/main.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/rss_scraper/main.go b/cmd/rss_scraper/main.go index 2c3ab70..2043801 100644 --- a/cmd/rss_scraper/main.go +++ b/cmd/rss_scraper/main.go @@ -78,6 +78,9 @@ func main() { // parseConcertDate parses an RSS item title like "Friday, June 20, 2026" into a time.Time func parseConcertDate(title string) (time.Time, error) { const dateFormat = "Monday, January 2, 2006" - loc, _ := time.LoadLocation("America/New_York") + loc, err := time.LoadLocation("America/New_York") + if err != nil { + return time.Time{}, err + } return time.ParseInLocation(dateFormat, title, loc) } From 7c91f5003d6a8cece16f77d2475969dcc5879168 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 01:36:20 +0700 Subject: [PATCH 10/13] improved database-enforced unique constraints and atomic upsert for concurrent safety --- models/sessions.go | 26 ++++++++++++++++---------- models/songs.go | 22 +++++++++++++--------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/models/sessions.go b/models/sessions.go index 40cec30..2694955 100644 --- a/models/sessions.go +++ b/models/sessions.go @@ -1,6 +1,10 @@ package models -import "time" +import ( + "time" + + "gorm.io/gorm/clause" +) type TimeOfDay string @@ -12,8 +16,8 @@ const ( type Session struct { ID uint `json:"id" gorm:"primaryKey"` - Day time.Time `json:"date" binding:"required"` - TimeOfDay TimeOfDay `json:"time_of_day" binding:"required"` + Day time.Time `json:"date" binding:"required" gorm:"uniqueIndex:idx_session_unique"` + TimeOfDay TimeOfDay `json:"time_of_day" binding:"required" gorm:"uniqueIndex:idx_session_unique"` } type SessionInput struct { @@ -28,16 +32,18 @@ type SessionSong struct { } func GetOrCreateSession(day time.Time, timeOfDay TimeOfDay) (*Session, error) { - var session Session - result := DB.Where("day = ? AND time_of_day = ?", day, timeOfDay).First(&session) - if result.Error == nil { - return &session, nil + session := Session{Day: day, TimeOfDay: timeOfDay} + if err := DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "day"}, {Name: "time_of_day"}}, + DoNothing: true, + }).Create(&session).Error; err != nil { + return nil, err } - session = Session{Day: day, TimeOfDay: timeOfDay} - if err := DB.Create(&session).Error; err != nil { + var result Session + if err := DB.Where("day = ? AND time_of_day = ?", day, timeOfDay).First(&result).Error; err != nil { return nil, err } - return &session, nil + return &result, nil } func CreateDailySessions(day time.Time) error { diff --git a/models/songs.go b/models/songs.go index d274d93..c55dd9e 100644 --- a/models/songs.go +++ b/models/songs.go @@ -1,22 +1,26 @@ package models +import "gorm.io/gorm/clause" + type Song struct { ID uint `json:"id" gorm:"primary_key"` - SongName string `json:"song_name"` - Artist string `json:"artist"` + SongName string `json:"song_name" gorm:"uniqueIndex:idx_song_unique"` + Artist string `json:"artist" gorm:"uniqueIndex:idx_song_unique"` Source string `json:"source"` InSongBook bool `json:"in_song_book"` } func GetOrCreateSong(name, artist, source string) (*Song, error) { - var song Song - result := DB.Where("song_name = ? AND artist = ?", name, artist).First(&song) - if result.Error == nil { - return &song, nil + song := Song{SongName: name, Artist: artist, Source: source, InSongBook: false} + if err := DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "song_name"}, {Name: "artist"}}, + DoNothing: true, + }).Create(&song).Error; err != nil { + return nil, err } - song = Song{SongName: name, Artist: artist, Source: source, InSongBook: false} - if err := DB.Create(&song).Error; err != nil { + var result Song + if err := DB.Where("song_name = ? AND artist = ?", name, artist).First(&result).Error; err != nil { return nil, err } - return &song, nil + return &result, nil } From 165f3cdbd28b07f250f10f3c9c1239536eec7c49 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 01:41:54 +0700 Subject: [PATCH 11/13] Add timeout and status check to RSS fetch Use context with 30s timeout on HTTP request to prevent indefinite hangs. Check response status code before parsing body as XML to catch 404/500 errors instead of trying to parse invalid RSS payloads. --- cmd/rss_scraper/main.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/cmd/rss_scraper/main.go b/cmd/rss_scraper/main.go index 2043801..f0786ac 100644 --- a/cmd/rss_scraper/main.go +++ b/cmd/rss_scraper/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/xml" "io" "log" @@ -17,12 +18,24 @@ func main() { log.Fatalf("Failed to connect to database: %v", err) } - resp, err := http.Get("https://apps.chimes.cornell.edu/music/rss.xml") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://apps.chimes.cornell.edu/music/rss.xml", nil) + if err != nil { + log.Fatalf("Failed to create request: %v", err) + } + + resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalf("Failed to fetch RSS: %v", err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Fatalf("RSS feed returned status %d", resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) if err != nil { log.Fatalf("Failed to read response body: %v", err) From 2b8953467534715abf233fd167ddc39d84c64c84 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 01:48:43 +0700 Subject: [PATCH 12/13] Handle all
tag variants in RSS description parsing --- models/rss_feed.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/models/rss_feed.go b/models/rss_feed.go index a986d2b..f8efba4 100644 --- a/models/rss_feed.go +++ b/models/rss_feed.go @@ -33,6 +33,9 @@ type TimeSlot struct { // matches HTML tag var tagPattern = regexp.MustCompile(`<[^>]+>`) +// matches all
variants:
,
,
,
, etc. +var brPattern = regexp.MustCompile(`(?i)\s*`) + // removes all HTML tags from a string func stripTags(s string) string { return strings.TrimSpace(tagPattern.ReplaceAllString(s, "")) @@ -64,8 +67,8 @@ func ParseDescription(desc string) []TimeSlot { var current *TimeSlot var lines []string - for _, current := range strings.Split(desc, "
") { - for _, line := range strings.Split(current, "\n") { + for _, chunk := range brPattern.Split(desc, -1) { + for _, line := range strings.Split(chunk, "\n") { lines = append(lines, line) } } From 124c902c100e4f09145dcec60532ad51b8c94358 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 21 Jun 2026 02:05:38 +0700 Subject: [PATCH 13/13] updated readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f359387..868eef3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # chimes-backend +An open-sourced backend for Chimes, connecting the Cornell Chimes masters and students through song requests, guesses, and kudos.