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 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. diff --git a/cmd/rss_scraper/main.go b/cmd/rss_scraper/main.go new file mode 100644 index 0000000..f0786ac --- /dev/null +++ b/cmd/rss_scraper/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "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) + } + + 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) + } + + 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" + loc, err := time.LoadLocation("America/New_York") + if err != nil { + return time.Time{}, err + } + return time.ParseInLocation(dateFormat, title, loc) +} 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/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: 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...") 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 new file mode 100644 index 0000000..f8efba4 --- /dev/null +++ b/models/rss_feed.go @@ -0,0 +1,95 @@ +package models + +import ( + "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 ParsedSong struct { + Title string + Source string + Artist string +} + +type TimeSlot struct { + Time string + Songs []ParsedSong +} + +// 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, "")) +} + +var originPattern = regexp.MustCompile(`\(from "([^"]+)"\)`) + +func parseSong(line string) ParsedSong { + song := ParsedSong{} + + 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 +} + +// ParseDescription converts HTML into TimeSlot structure +func ParseDescription(desc string) []TimeSlot { + var slots []TimeSlot + var current *TimeSlot + + var lines []string + for _, chunk := range brPattern.Split(desc, -1) { + for _, line := range strings.Split(chunk, "\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 +} diff --git a/models/sessions.go b/models/sessions.go new file mode 100644 index 0000000..2694955 --- /dev/null +++ b/models/sessions.go @@ -0,0 +1,56 @@ +package models + +import ( + "time" + + "gorm.io/gorm/clause" +) + +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" gorm:"uniqueIndex:idx_session_unique"` + TimeOfDay TimeOfDay `json:"time_of_day" binding:"required" gorm:"uniqueIndex:idx_session_unique"` +} + +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) { + 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 + } + var result Session + if err := DB.Where("day = ? AND time_of_day = ?", day, timeOfDay).First(&result).Error; err != nil { + return nil, err + } + return &result, 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..c55dd9e --- /dev/null +++ b/models/songs.go @@ -0,0 +1,26 @@ +package models + +import "gorm.io/gorm/clause" + +type Song struct { + ID uint `json:"id" gorm:"primary_key"` + 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) { + 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 + } + var result Song + if err := DB.Where("song_name = ? AND artist = ?", name, artist).First(&result).Error; err != nil { + return nil, err + } + return &result, nil +} 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 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