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