Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
99 changes: 99 additions & 0 deletions cmd/rss_scraper/main.go
Original file line number Diff line number Diff line change
@@ -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("&lt;", "<", "&gt;", ">", "&amp;", "&").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)
}
33 changes: 20 additions & 13 deletions controllers/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"})
}

Expand Down Expand Up @@ -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"})
}

Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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...")
Expand Down
47 changes: 47 additions & 0 deletions models/kudos.go
Original file line number Diff line number Diff line change
@@ -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
}
95 changes: 95 additions & 0 deletions models/rss_feed.go
Original file line number Diff line number Diff line change
@@ -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 <br> variants: <br>, <br/>, <br />, <BR>, etc.
var brPattern = regexp.MustCompile(`(?i)<br\s*/?>\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
}
Loading