From 442bd76e8fc337132c7a6d45c0196fac820b78eb Mon Sep 17 00:00:00 2001 From: CalebRose Date: Fri, 3 Apr 2026 06:26:15 -0700 Subject: [PATCH 1/5] posting --- controller/CronController.go | 3 +++ controller/TransferPortalController.go | 2 +- managers/ForumManager.go | 15 +++++++++++++++ managers/TransferPortalManager.go | 5 ++--- 4 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 managers/ForumManager.go diff --git a/controller/CronController.go b/controller/CronController.go index a0997a3..00dec43 100644 --- a/controller/CronController.go +++ b/controller/CronController.go @@ -47,6 +47,9 @@ func SyncRecruitingViaCron() { if ts.RunCron && !ts.IsOffSeason && !ts.CollegeSeasonOver && !ts.CFBSpringGames && ts.CollegeWeek > 0 && ts.CollegeWeek < 21 { managers.SyncRecruiting(ts) } + if ts.RunCron && ts.CollegeSeasonOver && ts.TransferPortalPhase == 1 { + managers.ProcessTransferIntention() + } if ts.RunCron && ts.IsOffSeason && ts.TransferPortalPhase == 2 { managers.EnterTheTransferPortal() } else if ts.RunCron && ts.IsOffSeason && ts.TransferPortalPhase == 3 { diff --git a/controller/TransferPortalController.go b/controller/TransferPortalController.go index 76620a2..35710d5 100644 --- a/controller/TransferPortalController.go +++ b/controller/TransferPortalController.go @@ -11,7 +11,7 @@ import ( ) func ProcessTransferIntention(w http.ResponseWriter, r *http.Request) { - managers.ProcessTransferIntention(w) + managers.ProcessTransferIntention() } func ProcessPrePortalPromises(w http.ResponseWriter, r *http.Request) { diff --git a/managers/ForumManager.go b/managers/ForumManager.go new file mode 100644 index 0000000..ae11899 --- /dev/null +++ b/managers/ForumManager.go @@ -0,0 +1,15 @@ +package managers + +import "github.com/CalebRose/SimFBA/structs" + +// ForumManager handles operations related to the forum system within the application. +// Will create threads & posts to facilitate post-game discussions for teams +// Through firebase. + +func CreatePostGameDiscussionThreadForCFBGame(game structs.CollegeGame) { + +} + +func CreatePostGameDiscussionThreadForNFLGame(game structs.NFLGame) { + +} diff --git a/managers/TransferPortalManager.go b/managers/TransferPortalManager.go index 34c78ac..f90a3dc 100644 --- a/managers/TransferPortalManager.go +++ b/managers/TransferPortalManager.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "math/rand" - "net/http" "sort" "strconv" "sync" @@ -28,7 +27,7 @@ var specificCoach = "Prefers to play for a specific coach" var legacy = "Legacy" var richHistory = "Prefers to play for a team with a rich history" -func ProcessTransferIntention(w http.ResponseWriter) { +func ProcessTransferIntention() { db := dbprovider.GetInstance().GetDB() // w.Header().Set("Content-Disposition", "attachment;filename=transferStats.csv") // w.Header().Set("Transfer-Encoding", "chunked") @@ -269,7 +268,7 @@ func ProcessTransferIntention(w http.ResponseWriter) { schemeMod = getSchemeMod(teamProfile, p, mediumDrop, mediumGain) fcsMod := 1.0 - if p.TeamID > 134 && p.TeamID != 138 && p.TeamID != 206 { + if p.TeamID > 134 && !teamProfile.IsFBS { if p.Year > 2 && p.Overall > 39 { fcsMod += (0.1 * float64(p.Year)) } From 97488c9d966690da4bd540008fbf56f916902a9f Mon Sep 17 00:00:00 2001 From: CalebRose Date: Tue, 7 Apr 2026 11:11:07 -0700 Subject: [PATCH 2/5] fixing small issues --- controller/TransferPortalController.go | 6 ++++++ main.go | 1 + managers/ExportManager.go | 2 +- managers/OffseasonManager.go | 3 ++- managers/SyncManager.go | 16 ++++++++------- managers/TransferPortalManager.go | 27 +++++++++++++++++--------- structs/PlayerResponses.go | 2 ++ 7 files changed, 39 insertions(+), 18 deletions(-) diff --git a/controller/TransferPortalController.go b/controller/TransferPortalController.go index 35710d5..4b84350 100644 --- a/controller/TransferPortalController.go +++ b/controller/TransferPortalController.go @@ -153,6 +153,12 @@ func SyncTransferPortal(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode("AI Boards for Transfer Portal Complete.") } +func SyncPromises(w http.ResponseWriter, r *http.Request) { + managers.SyncPromises() + + json.NewEncoder(w).Encode("AI Boards for Transfer Portal Complete.") +} + func FillUpTransferBoardsAI(w http.ResponseWriter, r *http.Request) { managers.AICoachFillBoardsPhase() diff --git a/main.go b/main.go index a89f0ef..99ca4b4 100644 --- a/main.go +++ b/main.go @@ -400,6 +400,7 @@ func handleRequests() http.Handler { apiRouter.HandleFunc("/portal/transfer/pre/promises", controller.ProcessPrePortalPromises).Methods("GET") apiRouter.HandleFunc("/portal/transfer/enter/portal", controller.EnterTheTransferPortal).Methods("GET") apiRouter.HandleFunc("/portal/transfer/sync", controller.SyncTransferPortal).Methods("GET") + apiRouter.HandleFunc("/portal/promise/sync", controller.SyncPromises).Methods("GET") apiRouter.HandleFunc("/portal/ai/generate/profiles", controller.FillUpTransferBoardsAI).Methods("GET") apiRouter.HandleFunc("/portal/ai/allocate/profiles", controller.AllocateAndPromisePlayersAI).Methods("GET") apiRouter.HandleFunc("/portal/page/data/{teamID}", controller.GetTransferPortalPageData).Methods("GET") diff --git a/managers/ExportManager.go b/managers/ExportManager.go index 1126dbd..075280d 100644 --- a/managers/ExportManager.go +++ b/managers/ExportManager.go @@ -1001,7 +1001,7 @@ func ExportNFLFreeAgentsToCSV(w http.ResponseWriter) { csvModel := structs.MapNFLPlayerToCSVModel(player) idStr := strconv.Itoa(int(player.PlayerID)) playerRow := []string{ - csvModel.Team, idStr, csvModel.FirstName, csvModel.LastName, csvModel.Position, + csvModel.PreviousTeam, idStr, csvModel.FirstName, csvModel.LastName, csvModel.Position, csvModel.Archetype, csvModel.PositionTwo, csvModel.ArchetypeTwo, csvModel.Year, strconv.Itoa(player.Age), strconv.Itoa(player.Stars), player.State, strconv.Itoa(player.Height), strconv.Itoa(player.Weight), csvModel.OverallGrade, csvModel.SpeedGrade, diff --git a/managers/OffseasonManager.go b/managers/OffseasonManager.go index fdaa2fd..e87aeaf 100644 --- a/managers/OffseasonManager.go +++ b/managers/OffseasonManager.go @@ -22,7 +22,8 @@ func PostSeasonStatusCleanUp() { return collegeGames[i].SeasonID < collegeGames[j].SeasonID }) - seasonIDs := []uint{1, 2, 3, 4, 5, 6} + // seasonIDs := []uint{1, 2, 3, 4, 5, 6} + seasonIDs := []uint{6} for _, seasonID := range seasonIDs { seasonIDStr := strconv.Itoa(int(seasonID)) diff --git a/managers/SyncManager.go b/managers/SyncManager.go index a938e6b..f0164da 100644 --- a/managers/SyncManager.go +++ b/managers/SyncManager.go @@ -1210,15 +1210,15 @@ func AllocateAIRedshirts(seasonId string) { playerSeasonStats := seasonStatsMap[uint(target.PlayerID)] - /* + /* * Redshirt top 20 players that have never been redshirted and either have no snaps or a long-term injury this season. * Also skips players if redshirting would put the team below that position minimum. * GetAllCollegePlayersByTeamId returns players sorted by OVR by default. */ - if (playerSeasonStats.GamesPlayed == 0 || - (target.InjuryType != "" && target.WeeksOfRecovery >= 10)) && - !target.IsRedshirt && !target.IsRedshirting && - isAboveMinPositionCount(target.Position, positionCountMap) { + if (playerSeasonStats.GamesPlayed == 0 || + (target.InjuryType != "" && target.WeeksOfRecovery >= 10)) && + !target.IsRedshirt && !target.IsRedshirting && + isAboveMinPositionCount(target.Position, positionCountMap) { redshirts[redshirtCount] = fmt.Sprintf("%s %s %s %s, GamesPlayed: %d, Injury Weeks: %d\n", team.TeamAbbr, target.Position, target.FirstName, target.LastName, playerSeasonStats.GamesPlayed, target.WeeksOfRecovery) SetRedshirtStatusForPlayer(strconv.Itoa(target.TeamID)) @@ -1239,8 +1239,8 @@ func getPositionCounts(players []structs.CollegePlayer) map[string]int { } func isAboveMinPositionCount(position string, positionCountMap map[string]int) bool { - minPositionThreshold := 0; - + minPositionThreshold := 0 + switch position { case "ATH": minPositionThreshold = 0 @@ -1291,7 +1291,9 @@ func GetFitsByScheme(scheme string, isBadFit bool) []string { "Old School": {GoodFits: []string{"Run Stopper DE", "Run Stopper OLB", "Run Stopper ILB", "Field General ILB"}, BadFits: []string{"Nose Tackle DT", "Coverage OLB", "Coverage ILB"}}, "2-Gap": {GoodFits: []string{"Run Stopper DE", "Nose Tackle DT", "Run Stopper OLB", "Pass Rush OLB", "Run Stopper ILB"}, BadFits: []string{"Speed Rusher DE", "Pass Rusher DT", "Speed OLB", "Speed ILB"}}, "4-man Front Spread Stopper": {GoodFits: []string{"Speed Rusher DE", "Pass Rusher DT", "Coverage OLB", "Coverage ILB"}, BadFits: []string{"Run Stopper DE", "Nose Tackle DT", "Run Stoppper OLB", "Run Stopper ILB", "Run Stopper FS", "Run Stopper SS"}}, + "4-Man Front Spread Stopper": {GoodFits: []string{"Speed Rusher DE", "Pass Rusher DT", "Coverage OLB", "Coverage ILB"}, BadFits: []string{"Run Stopper DE", "Nose Tackle DT", "Run Stoppper OLB", "Run Stopper ILB", "Run Stopper FS", "Run Stopper SS"}}, "3-man Front Spread Stopper": {GoodFits: []string{"Nose Tackle DT", "Pash Rush OLB", "Coverage ILB"}, BadFits: []string{"Nose Tackle DT", "Run Stopper OLB", "Run Stopper ILB", "Run Stopper FS", "Run Stopper SS", "Speed OLB", "Speed ILB", "Field General ILB"}}, + "3-Man Front Spread Stopper": {GoodFits: []string{"Nose Tackle DT", "Pash Rush OLB", "Coverage ILB"}, BadFits: []string{"Nose Tackle DT", "Run Stopper OLB", "Run Stopper ILB", "Run Stopper FS", "Run Stopper SS", "Speed OLB", "Speed ILB", "Field General ILB"}}, "Speed": {GoodFits: []string{"Speed Rusher DE", "Pass Rusher DT", "Coverage OLB", "Speed OLB", "Speed ILB"}, BadFits: []string{"Run Stopper DE", "Nose Tackle DT", "Pass Rush OLB", "Field General ILB"}}, "Multiple": {GoodFits: []string{"Run Stopper DE", "Speed OLB", "Speed ILB", "Field General ILB", "Run Stopper FS", "Run Stopper SS"}, BadFits: []string{"Speed Rusher DE", "Pass Rusher DT", "Coverage OLB", "Coverage ILB"}}, } diff --git a/managers/TransferPortalManager.go b/managers/TransferPortalManager.go index fa94f9a..fa7e71a 100644 --- a/managers/TransferPortalManager.go +++ b/managers/TransferPortalManager.go @@ -1923,18 +1923,18 @@ func SyncPromises() { seasonStats := seasonStatsMap[promise.CollegePlayerID] if promise.PromiseType == "Wins" { - benchMarkStr = strconv.Itoa(int(promise.Benchmark)) standings := standingsMap[promise.TeamID] result = strconv.Itoa(int(standings.TotalWins)) if standings.TotalWins >= promise.Benchmark { promise.FulfillPromise() } - } else if promise.PromiseType == "Snaps" { - benchMarkStr = strconv.Itoa(int(promise.Benchmark)) - snapsPerGame := float64(seasonStats.Snaps) / float64(seasonStats.GamesPlayed) - result = util.ConvertFloatTostring(snapsPerGame) - if snapsPerGame >= float64(promise.Benchmark) { - promise.FulfillPromise() + } else if promise.PromiseType == "Snaps" || promise.PromiseType == "Snap Count" { + if seasonStats.Snaps > 0 { + snapsPerGame := float64(seasonStats.Snaps) / float64(seasonStats.GamesPlayed) + result = util.ConvertFloatTostring(snapsPerGame) + if snapsPerGame >= float64(promise.Benchmark) { + promise.FulfillPromise() + } } } else if promise.PromiseType == "Home State Game" || promise.PromiseType == "Different State" { // Loop through games @@ -1943,7 +1943,8 @@ func SyncPromises() { games := GetCollegeGamesByTeamIdAndSeasonId(teamID, seasonID, false) for _, game := range games { stateKey := util.GetStateKey(promise.BenchmarkStr) - if game.State == stateKey || game.State == promise.BenchmarkStr { + gameStateKey := util.GetStateKey(game.State) + if gameStateKey == stateKey || game.State == promise.BenchmarkStr { result = "" promise.FulfillPromise() break @@ -1976,7 +1977,15 @@ func SyncPromises() { standings := standingsMap[promise.TeamID] postSeasonStatus := standings.PostSeasonStatus // postSeasonStatus has substring "Round of" or postSeasonStatus == "Sweet 16" or "Elite 8" or "Final 4" or contains "National Champion", fullfill - if strings.Contains(postSeasonStatus, "Playoffs") || postSeasonStatus == "Semifinals" || postSeasonStatus == "Quarterfinals" || strings.Contains(postSeasonStatus, "National Champion") { + if strings.Contains(postSeasonStatus, "Playoff") || postSeasonStatus == "Semifinals" || postSeasonStatus == "Quarterfinals" || strings.Contains(postSeasonStatus, "National") { + result = "" + promise.FulfillPromise() + } + } else if promise.PromiseType == "Bowl Game" { + standings := standingsMap[promise.TeamID] + postSeasonStatus := standings.PostSeasonStatus + // postSeasonStatus has substring "Round of" or postSeasonStatus == "Sweet 16" or "Elite 8" or "Final 4" or contains "National Champion", fullfill + if strings.Contains(postSeasonStatus, "Bowl") || strings.Contains(postSeasonStatus, "Playoff") || postSeasonStatus == "Semifinals" || postSeasonStatus == "Quarterfinals" || strings.Contains(postSeasonStatus, "National") { result = "" promise.FulfillPromise() } diff --git a/structs/PlayerResponses.go b/structs/PlayerResponses.go index 3177d9b..b24284a 100644 --- a/structs/PlayerResponses.go +++ b/structs/PlayerResponses.go @@ -54,6 +54,7 @@ type CollegePlayerCSV struct { ArchetypeTwo string Year string Team string + PreviousTeam string Age int Stars int HighSchool string @@ -880,6 +881,7 @@ func MapNFLPlayerToCSVModel(player NFLPlayer) CollegePlayerCSV { Archetype: player.Archetype, PositionTwo: player.PositionTwo, ArchetypeTwo: player.ArchetypeTwo, + PreviousTeam: player.PreviousTeam, Team: player.TeamAbbr, Year: Year, Age: player.Age, From e65bc85aa72595f345db6735e54c9d31ba5b5e72 Mon Sep 17 00:00:00 2001 From: CalebRose Date: Tue, 7 Apr 2026 11:14:08 -0700 Subject: [PATCH 3/5] fixing cron issue --- controller/CronController.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/controller/CronController.go b/controller/CronController.go index 2af7623..a671535 100644 --- a/controller/CronController.go +++ b/controller/CronController.go @@ -75,6 +75,9 @@ func SyncFreeAgencyViaCron() { func RunCFBProgressionsViaCron() { db := dbprovider.GetInstance().GetDB() ts := managers.GetTimestamp() + if !ts.RunCron { + return + } if ts.CollegeWeek < 21 { return } @@ -92,6 +95,9 @@ func RunCFBProgressionsViaCron() { func RunNFLProgressionsViaCron() { db := dbprovider.GetInstance().GetDB() ts := managers.GetTimestamp() + if !ts.RunCron { + return + } if ts.NFLWeek < 23 { return } From 02cb60041efdb77036b9841a3ce00d1e7b6c6e03 Mon Sep 17 00:00:00 2001 From: CalebRose Date: Tue, 7 Apr 2026 11:33:43 -0700 Subject: [PATCH 4/5] offseason pipeline updates --- controller/CronController.go | 2 ++ managers/AdminManager.go | 4 ---- managers/OffseasonManager.go | 24 ++++++++++++++++++++++++ structs/TeamRecruitingProfile.go | 3 +++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/controller/CronController.go b/controller/CronController.go index a671535..367d81c 100644 --- a/controller/CronController.go +++ b/controller/CronController.go @@ -88,6 +88,7 @@ func RunCFBProgressionsViaCron() { managers.CFBProgressionMain() ts.ToggleCollegeProgression() + managers.RecruitingAndTransferPortalCleanUp() repository.SaveTimestamp(ts, db) } } @@ -107,6 +108,7 @@ func RunNFLProgressionsViaCron() { db.Model(&structs.NFLPlayer{}).Where("id > ?", 0).Update("has_progressed", false) managers.NFLProgressionMain() ts.ToggleProfessionalProgression() + managers.FreeAgencyCleanUp() repository.SaveTimestamp(ts, db) } } diff --git a/managers/AdminManager.go b/managers/AdminManager.go index 735cb89..efe5396 100644 --- a/managers/AdminManager.go +++ b/managers/AdminManager.go @@ -584,10 +584,6 @@ func CreateCollegeSeason() { } func GenerateOffseasonData() { - // Fulfill Promises - - // Run First Phase of Portal - // Create Standings Records for Leagues GenerateNewSeasonStandings() diff --git a/managers/OffseasonManager.go b/managers/OffseasonManager.go index e87aeaf..850d30d 100644 --- a/managers/OffseasonManager.go +++ b/managers/OffseasonManager.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/CalebRose/SimFBA/dbprovider" + "github.com/CalebRose/SimFBA/models" "github.com/CalebRose/SimFBA/repository" "github.com/CalebRose/SimFBA/structs" ) @@ -338,3 +339,26 @@ func UpdateTeamProfileAffinities() { repository.SaveRecruitingTeamProfile(teamProfile, db) } } + +func RecruitingAndTransferPortalCleanUp() { + db := dbprovider.GetInstance().GetDB() + db.Model(&models.NFLWarRoom{}).Where("id > ?", 0).Update("spent_points", 0) + + // Clear Transfer Portal Profiles Table + db.Delete(&structs.TransferPortalProfile{}, "id > ?", 0) + + // Clear Recruiting Profiles Table + db.Delete(&structs.RecruitPlayerProfile{}, "id > ?", 0) + + // Clear Transfer Profiles Table + db.Delete(&structs.TransferPortalProfile{}, "id > ?", 0) + + // Clear NFL Scouting Boards + db.Delete(&models.ScoutingProfile{}, "id > ?", 0) +} + +func FreeAgencyCleanUp() { + db := dbprovider.GetInstance().GetDB() + db.Delete(&structs.FreeAgencyOffer{}, "id > ?", 0) + db.Delete(&structs.NFLExtensionOffer{}, "id > ?", 0) +} diff --git a/structs/TeamRecruitingProfile.go b/structs/TeamRecruitingProfile.go index aa4cc58..d2dd569 100644 --- a/structs/TeamRecruitingProfile.go +++ b/structs/TeamRecruitingProfile.go @@ -195,6 +195,9 @@ func (r *RecruitingTeamProfile) SeasonReset() { r.ResetStarCount() r.ResetScholarshipCount() r.SetRecruitingClassSize(25) + if !r.IsFBS { + r.SetRecruitingClassSize(20) + } r.Rank247Score = 0 r.RecruitingClassRank = 0 r.ESPNScore = 0 From ae0dbaf999d0885bb6f989e5b055b9f6c89f319d Mon Sep 17 00:00:00 2001 From: CalebRose Date: Tue, 7 Apr 2026 17:21:22 -0700 Subject: [PATCH 5/5] Adding infrastructure for forum posts from the API --- TechDocs/Instructions.md | 5 + ...g_notifications_forums_technical_design.md | 1248 +++++++++++++++++ controller/FirebaseController.go | 77 + firebase/client.go | 100 ++ firebase/forum_service.go | 224 +++ firebase/notification_service.go | 247 ++++ firebase/recipient_resolver.go | 52 + firebase/routes.go | 58 + firebase/types.go | 283 ++++ go.mod | 57 +- go.sum | 134 ++ main.go | 4 + managers/AdminManager.go | 125 ++ managers/ForumManager.go | 589 +++++++- managers/FreeAgencyManager.go | 31 +- managers/GameplanManager.go | 49 +- managers/MapHelper.go | 20 + managers/SyncManager.go | 23 +- managers/TransferPortalManager.go | 47 +- 19 files changed, 3349 insertions(+), 24 deletions(-) create mode 100644 TechDocs/Instructions.md create mode 100644 TechDocs/golang_notifications_forums_technical_design.md create mode 100644 controller/FirebaseController.go create mode 100644 firebase/client.go create mode 100644 firebase/forum_service.go create mode 100644 firebase/notification_service.go create mode 100644 firebase/recipient_resolver.go create mode 100644 firebase/routes.go create mode 100644 firebase/types.go diff --git a/TechDocs/Instructions.md b/TechDocs/Instructions.md new file mode 100644 index 0000000..988245c --- /dev/null +++ b/TechDocs/Instructions.md @@ -0,0 +1,5 @@ +# This file is mainly for Copilot to handle larger instructions for larger features like forums and chats + +## This file is mainly for developers to read. + +## No further instructions for Copilot or Claude so long as they utilize the markdown documents in this folder and utilize the \_design folder for designing reusable components when possible diff --git a/TechDocs/golang_notifications_forums_technical_design.md b/TechDocs/golang_notifications_forums_technical_design.md new file mode 100644 index 0000000..49afa0f --- /dev/null +++ b/TechDocs/golang_notifications_forums_technical_design.md @@ -0,0 +1,1248 @@ +# Golang Technical Design: Notifications and Forum Thread/Post Creation with Firebase + +## Notes Before Beginning + +Please note that before beginning that the files within t_FirebaseLogicFromFrontendCode are based off a design that comes from the inspiration from this design. This document is to help provide an overview on what we need to do with the Golang API through interacting with Firebase. The code within t_FirebaseLogicFromFrontendCode will help provide context as a means of what the code looks like on the Frontend. + +## Document Info + +- **Project**: Simulation Sports Backend Notifications + Forum Automation +- **Backend Stack**: Golang, Firebase Admin SDK / Firestore +- **Frontend Context**: React.js, TypeScript, Tailwind CSS, Firebase-backed notifications and forums +- **Primary Goals**: + - Send in-app notifications after sports-domain events + - Support future email and Discord fan-out without redesigning the core backend + - Create forum threads and posts automatically from Go services + - Include explicit routing metadata in each notification so the frontend can navigate users to the correct page + +--- + +## 1. Overview + +This document defines a backend-first design for: + +- generating notifications from Go services +- storing notifications in Firestore +- creating forum threads and posts from Go APIs +- ensuring notifications include route metadata for frontend navigation +- supporting future delivery channels such as email and Discord + +This design assumes: + +- the Go API is the source of truth for sports-domain events +- Firebase Authentication manages user identity +- Firestore stores notifications, forum threads, forum posts, and eventually user notification settings +- the frontend already has or will have a notification UI and forum UI backed by Firebase + +The design should prioritize: + +- idempotency +- explicit routing contracts +- clean separation of domain logic from delivery logic +- easy extension into email and Discord later +- low operational complexity + +--- + +## 2. Goals + +### 2.1 Functional Goals + +The backend should support notifications for events such as: + +- a player is injured +- a recruit signs +- a practice squad player receives an offer +- a recruiting sync completes +- a team needs to update its gameplan + +The backend should also support: + +- creating system-generated forum threads +- creating system-generated forum posts +- linking notifications to exact frontend destinations +- deep-linking into forum threads and posts +- future fan-out into: + - email + - Discord + - digests or batched notifications + +### 2.2 Non-Goals + +This version does not require: + +- mobile push notifications +- direct SMTP sending from Go +- a Discord listener service +- full user preference management implementation +- full-text search design +- image upload design for forums + +--- + +## 3. Architectural Principles + +### 3.1 The Go API Owns Domain Events + +The Go backend already knows when meaningful simulation events occur. It should remain responsible for deciding: + +- what happened +- who should be notified +- what the notification should say +- where the user should go when they click it +- whether a forum artifact should be created + +The frontend should not have to infer this from partial data. + +### 3.2 Notifications Should Be Typed and Explicit + +Every notification should have: + +- a stable type +- a user-facing title +- a user-facing message +- an explicit route object +- metadata describing the referenced entity + +Avoid one-off notification payloads that vary unpredictably by service. + +### 3.3 Routing Must Be Backend-Defined + +Do not rely on the frontend to guess where a notification goes based only on IDs. + +Each notification should include: + +- `routeName` +- `path` +- `params` +- optional `query` +- optional `anchor` + +This keeps routing logic deterministic. + +### 3.4 Forum Automation Must Be Idempotent + +Automated threads and posts should never duplicate when jobs retry or services rerun. + +Examples: + +- one postgame thread per game +- one recruiting sync announcement per sync run +- one injury bulletin per source event + +Use stable external event keys. + +--- + +## 4. High-Level Architecture + +```txt +Sports Domain Services (Go) + | + v +Application Event / Domain Event + | + v +Notification Service + |----> Firestore notifications/ + |----> Firestore notificationEvents/ (optional outbox) + |----> Firestore threads/ + |----> Firestore posts/ + | + +----> Future emailQueue/ + +----> Future discordQueue/ +``` + +### Backend responsibilities + +- detect domain events +- resolve recipients +- generate notification payloads +- create notification docs +- create forum threads/posts when needed +- maintain idempotency + +### Frontend responsibilities + +- render notifications +- navigate based on notification route metadata +- render forum thread/post destinations +- mark notifications read + +--- + +## 5. Firebase Integration from Go + +Recommended server-side options: + +- **Firebase Admin SDK for Go** +- **Google Cloud Firestore Go client** + +Because this is privileged backend automation, the service should use server credentials and write directly to Firestore. + +### Recommendation + +Create a shared infrastructure package that initializes: + +- Firebase app +- Firestore client +- optional Auth admin client + +Suggested package structure: + +```txt +internal/ + firebase/ + client.go + notifications/ + service.go + repository.go + builders.go + types.go + forums/ + service.go + repository.go + types.go + routing/ + routes.go + domain/ + injuries/ + recruiting/ + practice/ + gameplan/ +``` + +--- + +## 6. Firestore Data Model + +## 6.1 Notifications Collection + +Collection: + +```txt +notifications/{notificationId} +``` + +Suggested document shape: + +```go +type Notification struct { + ID string `firestore:"id"` + RecipientUID string `firestore:"recipientUid"` + Type string `firestore:"type"` + Title string `firestore:"title"` + Message string `firestore:"message"` + Severity string `firestore:"severity"` + IsRead bool `firestore:"isRead"` + CreatedAt time.Time `firestore:"createdAt"` + ReadAt *time.Time `firestore:"readAt,omitempty"` + + League string `firestore:"league,omitempty"` + TeamID uint `firestore:"teamId,omitempty"` + SeasonID uint `firestore:"seasonId,omitempty"` + WeekID uint `firestore:"weekId,omitempty"` + + EntityType string `firestore:"entityType,omitempty"` + EntityID string `firestore:"entityId,omitempty"` + + Route NotificationRoute `firestore:"route"` + Metadata map[string]interface{} `firestore:"metadata,omitempty"` + Delivery NotificationDelivery `firestore:"delivery"` + + SourceEventKey string `firestore:"sourceEventKey,omitempty"` +} +``` + +### Why these fields matter + +- `Type` lets the frontend render consistent badges/styles +- `Route` lets the frontend navigate without guessing +- `Metadata` supports richer UX without forcing every field into the top-level schema +- `SourceEventKey` supports idempotency and debugging +- `Delivery` leaves room for email and Discord later + +--- + +## 6.2 Notification Route Contract + +```go +type NotificationRoute struct { + RouteName string `firestore:"routeName"` + Path string `firestore:"path"` + Params map[string]string `firestore:"params,omitempty"` + Query map[string]string `firestore:"query,omitempty"` + Anchor string `firestore:"anchor,omitempty"` + State map[string]interface{} `firestore:"state,omitempty"` +} +``` + +### Example + +```go +NotificationRoute{ + RouteName: "team_recruiting", + Path: "/simcfb/teams/44/recruiting", + Params: map[string]string{ + "league": "simcfb", + "teamId": "44", + }, +} +``` + +The frontend should use `Path` as the primary destination and may use `RouteName`, `Params`, and `State` for enhanced behavior. + +--- + +## 6.3 Notification Delivery State + +```go +type NotificationDelivery struct { + InAppCreated bool `firestore:"inAppCreated"` + EmailQueued bool `firestore:"emailQueued"` + DiscordQueued bool `firestore:"discordQueued"` +} +``` + +This allows the same core notification pipeline to expand later without redesigning the schema. + +--- + +## 6.4 Optional Notification Outbox + +Collection: + +```txt +notificationEvents/{eventId} +``` + +Suggested document shape: + +```go +type NotificationEvent struct { + ID string `firestore:"id"` + EventType string `firestore:"eventType"` + SourceEventKey string `firestore:"sourceEventKey"` + League string `firestore:"league,omitempty"` + TeamID uint `firestore:"teamId,omitempty"` + EntityType string `firestore:"entityType,omitempty"` + EntityID string `firestore:"entityId,omitempty"` + Payload map[string]interface{} `firestore:"payload"` + RecipientUIDs []string `firestore:"recipientUids"` + Status string `firestore:"status"` + CreatedAt time.Time `firestore:"createdAt"` + ProcessedAt *time.Time `firestore:"processedAt,omitempty"` +} +``` + +### Why keep an outbox? + +- easier replay +- better debugging +- easier retries +- simpler fan-out into email or Discord later + +### Recommendation + +You can write notifications directly for V1. For a cleaner long-term design, add `notificationEvents/`. + +--- + +## 6.5 Threads Collection + +Collection: + +```txt +threads/{threadId} +``` + +Suggested document shape: + +```go +type ForumThread struct { + ID string `firestore:"id"` + ForumID string `firestore:"forumId"` + ForumPath []string `firestore:"forumPath"` + Title string `firestore:"title"` + Slug string `firestore:"slug"` + AuthorUID string `firestore:"authorUid"` + AuthorName string `firestore:"authorName"` + CreatedByType string `firestore:"createdByType"` // user, system, bot + ThreadType string `firestore:"threadType"` // standard, poll, game_reference, system_event + FirstPostID string `firestore:"firstPostId"` + IsPinned bool `firestore:"isPinned"` + IsLocked bool `firestore:"isLocked"` + IsDeleted bool `firestore:"isDeleted"` + ReplyCount int `firestore:"replyCount"` + ParticipantCount int `firestore:"participantCount"` + LatestPostID string `firestore:"latestPostId,omitempty"` + LatestActivityAt time.Time `firestore:"latestActivityAt"` + CreatedAt time.Time `firestore:"createdAt"` + UpdatedAt time.Time `firestore:"updatedAt"` + + ReferencedGameID string `firestore:"referencedGameId,omitempty"` + ExternalEventKey string `firestore:"externalEventKey,omitempty"` + Metadata map[string]interface{} `firestore:"metadata,omitempty"` +} +``` + +--- + +## 6.6 Posts Collection + +Collection: + +```txt +posts/{postId} +``` + +Suggested document shape: + +```go +type ForumPost struct { + ID string `firestore:"id"` + ThreadID string `firestore:"threadId"` + ForumID string `firestore:"forumId"` + AuthorUID string `firestore:"authorUid"` + AuthorName string `firestore:"authorName"` + CreatedByType string `firestore:"createdByType"` // user, system, bot + + Body map[string]interface{} `firestore:"body"` // rich text JSON + BodyText string `firestore:"bodyText"` // plain text fallback + ReplyToPostID string `firestore:"replyToPostId,omitempty"` + QuotedPostID string `firestore:"quotedPostId,omitempty"` + + IsEdited bool `firestore:"isEdited"` + IsDeleted bool `firestore:"isDeleted"` + + CreatedAt time.Time `firestore:"createdAt"` + UpdatedAt time.Time `firestore:"updatedAt"` + + Metadata map[string]interface{} `firestore:"metadata,omitempty"` +} +``` + +--- + +## 7. Notification Types + +Define stable constants in Go. + +```go +const ( + NotificationPlayerInjured = "player_injured" + NotificationRecruitSigned = "recruit_signed" + NotificationPracticeSquadOffer = "practice_squad_offer" + NotificationRecruitingSyncComplete = "recruiting_sync_complete" + NotificationGameplanUpdateNeeded = "gameplan_update_needed" + NotificationForumThreadCreated = "forum_thread_created" + NotificationForumPostReply = "forum_post_reply" +) +``` + +Each type should have: + +- a message template +- a title template +- a severity +- a route builder +- a recipient resolution strategy + +--- + +## 8. Notification Route Strategy + +The backend should own route generation in one place. + +Suggested package: + +```txt +internal/routing/routes.go +``` + +Suggested interface: + +```go +type RouteBuilder interface { + BuildPlayerInjuryRoute(league string, teamID uint, playerID uint) NotificationRoute + BuildRecruitingRoute(league string, teamID uint) NotificationRoute + BuildPracticeSquadRoute(league string, teamID uint, playerID uint) NotificationRoute + BuildGameplanRoute(league string, teamID uint) NotificationRoute + BuildForumThreadRoute(threadID string) NotificationRoute + BuildForumPostRoute(threadID string, postID string) NotificationRoute +} +``` + +### Example implementation + +```go +func BuildGameplanRoute(league string, teamID uint) NotificationRoute { + path := fmt.Sprintf("/%s/teams/%d/gameplan", league, teamID) + return NotificationRoute{ + RouteName: "team_gameplan", + Path: path, + Params: map[string]string{ + "league": league, + "teamId": strconv.Itoa(int(teamID)), + }, + } +} +``` + +### Recommended mapping + +| Notification Type | Route Name | Suggested Path | +| -------------------------- | ------------------------------------- | ------------------------------------------------ | +| `player_injured` | `team_player_detail` or `team_roster` | `/{league}/teams/{teamId}/roster` or player page | +| `recruit_signed` | `team_recruiting` | `/{league}/teams/{teamId}/recruiting` | +| `practice_squad_offer` | `team_practice_squad` | `/{league}/teams/{teamId}/practice-squad` | +| `recruiting_sync_complete` | `recruiting_sync_status` | sync status/admin page | +| `gameplan_update_needed` | `team_gameplan` | `/{league}/teams/{teamId}/gameplan` | +| `forum_thread_created` | `forum_thread` | `/forums/thread/{threadId}` | +| `forum_post_reply` | `forum_post` | `/forums/thread/{threadId}` + anchor | + +Keep `RouteName` stable even if path formats change later. + +--- + +## 9. Recipient Resolution + +Do not scatter recipient logic throughout domain services. + +Create a resolver layer. + +```go +type RecipientResolver interface { + ResolvePlayerInjuryRecipients(ctx context.Context, teamID uint) ([]string, error) + ResolveRecruitSignedRecipients(ctx context.Context, teamID uint) ([]string, error) + ResolvePracticeSquadOfferRecipients(ctx context.Context, teamID uint) ([]string, error) + ResolveRecruitingSyncRecipients(ctx context.Context, league string) ([]string, error) + ResolveGameplanRecipients(ctx context.Context, teamID uint) ([]string, error) +} +``` + +### Expected patterns + +- **player injured** + - team owner + - coaches + - team admins +- **recruit signed** + - team owner + - recruiting staff + - team admins +- **practice squad offer** + - team owner + - contract manager + - team admins +- **recruiting sync complete** + - league ops + - admins +- **gameplan update needed** + - team owner + - active coach users + +--- + +## 10. Notification Service Design + +Suggested service interface: + +```go +type NotificationService interface { + NotifyPlayerInjured(ctx context.Context, input PlayerInjuryNotificationInput) error + NotifyRecruitSigned(ctx context.Context, input RecruitSignedNotificationInput) error + NotifyPracticeSquadOffer(ctx context.Context, input PracticeSquadOfferNotificationInput) error + NotifyRecruitingSyncComplete(ctx context.Context, input RecruitingSyncCompleteInput) error + NotifyGameplanUpdateNeeded(ctx context.Context, input GameplanUpdateNeededInput) error +} +``` + +### Suggested input types + +```go +type PlayerInjuryNotificationInput struct { + League string + TeamID uint + TeamName string + PlayerID uint + PlayerName string + InjuryName string + GamesMissed int + SourceEventKey string +} + +type RecruitSignedNotificationInput struct { + League string + TeamID uint + TeamName string + RecruitID uint + RecruitName string + SourceEventKey string +} + +type PracticeSquadOfferNotificationInput struct { + League string + TeamID uint + TeamName string + PlayerID uint + PlayerName string + OfferingTeamID uint + OfferingTeamName string + SourceEventKey string +} + +type RecruitingSyncCompleteInput struct { + League string + SyncRunID string + SummaryMessage string + SourceEventKey string +} + +type GameplanUpdateNeededInput struct { + League string + TeamID uint + TeamName string + Reason string + SourceEventKey string +} +``` + +--- + +## 11. Notification Build Flow + +Each notification method should follow the same shape: + +1. validate input +2. check idempotency using `SourceEventKey` +3. resolve recipients +4. build title/message +5. build route +6. create one notification doc per recipient +7. optionally create outbox record + +Example flow: + +```txt +Game simulation finishes + -> player injury detected + -> build PlayerInjuryNotificationInput + -> notification service checks SourceEventKey + -> resolve team recipients + -> build team roster or player route + -> write notifications +``` + +--- + +## 12. Idempotency Strategy + +This is critical. + +### Why + +- job retries happen +- event handlers rerun +- infrastructure can duplicate work +- forum automation is especially sensitive to duplicates + +### Requirement + +Every domain event that can create notifications or forum artifacts must generate a stable key. + +### Example keys + +```txt +injury:simphl:season12:game881:player193 +recruit_sign:simcfb:season5:team44:recruit1182 +practice_offer:simnfl:season4:team12:player892:offerTeam31 +recruit_sync:simcfb:sync_2026_04_07T12_00_00Z +gameplan_needed:simchl:season2:team8:week9 +postgame_thread:simcfb:season4:game1219 +``` + +### Recommended persistence options + +Option A: + +- store processed events in `notificationEvents/` + +Option B: + +- query notifications/threads by `SourceEventKey` or `ExternalEventKey` + +### Recommendation + +Use an outbox or event registry for clarity. + +--- + +## 13. Forum Automation Design + +Suggested service interface: + +```go +type ForumService interface { + CreateThread(ctx context.Context, input CreateForumThreadInput) (*ForumThread, error) + CreatePost(ctx context.Context, input CreateForumPostInput) (*ForumPost, error) + FindThreadByExternalEventKey(ctx context.Context, eventKey string) (*ForumThread, error) +} +``` + +### Create thread input + +```go +type CreateForumThreadInput struct { + ForumID string + ForumPath []string + Title string + Slug string + AuthorUID string + AuthorName string + CreatedByType string + ThreadType string + FirstPostBody map[string]interface{} + FirstPostBodyText string + ReferencedGameID string + ExternalEventKey string + Metadata map[string]interface{} +} +``` + +### Create post input + +```go +type CreateForumPostInput struct { + ThreadID string + ForumID string + AuthorUID string + AuthorName string + CreatedByType string + Body map[string]interface{} + BodyText string + ReplyToPostID string + QuotedPostID string + Metadata map[string]interface{} +} +``` + +--- + +## 14. Batched Thread + First Post Creation + +When creating a new thread, the backend should create both: + +- the thread document +- the first post document + +Use a Firestore batch or transaction. + +### Recommended flow + +1. generate `threadId` +2. generate `postId` +3. create post with `threadId` +4. create thread with `firstPostId = postId` +5. optionally update forum counters/latest activity +6. commit batch + +This keeps thread creation atomic enough for the application. + +--- + +## 15. Rich Text Body Format from Go + +The frontend may use structured JSON for forum post rendering. + +The Go backend should be able to produce: + +- a plain text fallback via `BodyText` +- a structured JSON body via `Body` + +### Example simple system post + +```go +body := map[string]interface{}{ + "type": "doc", + "content": []map[string]interface{}{ + { + "type": "paragraph", + "content": []map[string]interface{}{ + { + "type": "text", + "text": "Postgame discussion is now open. Share your thoughts here.", + }, + }, + }, + }, +} +``` + +This allows automated Go-created posts to use the same frontend renderer as user-authored posts. + +--- + +## 16. Example Notification Templates + +### 16.1 Player Injured + +**Title** +`Injury Update: {PlayerName}` + +**Message** +`{PlayerName} suffered {InjuryName} and is expected to miss {GamesMissed} games.` + +**Route** +Team roster or player detail page + +**Metadata** + +```json +{ + "playerId": 193, + "injuryName": "Separated Shoulder", + "gamesMissed": 4 +} +``` + +### 16.2 Recruit Signed + +**Title** +`Recruit Signed: {RecruitName}` + +**Message** +`{RecruitName} has signed with {TeamName}.` + +**Route** +Team recruiting page + +### 16.3 Practice Squad Offer + +**Title** +`Practice Squad Offer Received` + +**Message** +`{OfferingTeamName} has made an offer for {PlayerName}.` + +**Route** +Practice squad/contracts page + +### 16.4 Recruiting Sync Complete + +**Title** +`Recruiting Sync Complete` + +**Message** +`The latest recruiting sync has completed successfully.` + +**Route** +Recruiting admin or sync status page + +### 16.5 Gameplan Update Needed + +**Title** +`Gameplan Update Needed` + +**Message** +`Your team needs a gameplan update before the next simulation cycle.` + +**Route** +Team gameplan page + +--- + +## 17. Example Automated Forum Flows + +### 17.1 Postgame Thread + +**Trigger** +A game becomes final. + +**Flow** + +1. build `ExternalEventKey = postgame_thread:{league}:{gameId}` +2. check whether a thread already exists +3. create thread in appropriate forum +4. create first post with matchup summary +5. store referenced game ID +6. optionally notify subscribers or team users + +Suggested title: + +```txt +Postgame Thread: Away Team at Home Team +``` + +Suggested thread type: + +```txt +game_reference +``` + +### 17.2 Recruiting Sync Announcement + +**Trigger** +A recruiting sync job finishes. + +**Flow** + +1. build stable sync event key +2. create thread in admin or recruiting forum, or post into existing operations thread +3. notify admins or league ops users + +### 17.3 Injury Bulletin + +**Trigger** +A major injury occurs and should be publicly surfaced. + +**Flow** + +1. create in-app notifications for team stakeholders +2. optionally create a thread/post in a news forum if the product wants public visibility + +--- + +## 18. Repository Layer + +Create repositories so services are not directly coupled to Firestore query details. + +### Notification repository + +```go +type NotificationRepository interface { + CreateNotifications(ctx context.Context, notifications []Notification) error + ExistsBySourceEventKey(ctx context.Context, sourceEventKey string, notificationType string) (bool, error) + CreateEvent(ctx context.Context, event NotificationEvent) error + MarkEventProcessed(ctx context.Context, eventID string) error +} +``` + +### Forum repository + +```go +type ForumRepository interface { + CreateThreadWithFirstPost(ctx context.Context, thread ForumThread, post ForumPost) error + CreatePost(ctx context.Context, post ForumPost) error + FindThreadByExternalEventKey(ctx context.Context, eventKey string) (*ForumThread, error) +} +``` + +### Why use repositories + +- easier unit testing +- clear ownership of Firestore logic +- easier future refactors + +--- + +## 19. Frontend Contract Requirements + +The frontend notification renderer should expect: + +- `type` +- `title` +- `message` +- `route.routeName` +- `route.path` +- `route.params` +- `route.query` +- `route.anchor` +- `metadata` + +### Click behavior + +1. user clicks notification +2. frontend navigates to `route.path` +3. if `route.anchor` exists, scroll to that element +4. optional state/query can pre-open the right tab or subview + +### Example forum reply notification + +```json +{ + "type": "forum_post_reply", + "title": "New reply in Postgame Thread", + "message": "A new reply was posted in your thread.", + "route": { + "routeName": "forum_post", + "path": "/forums/thread/abc123", + "anchor": "post_xyz789" + } +} +``` + +The frontend should navigate to `/forums/thread/abc123` and scroll to `#post_xyz789`. + +--- + +## 20. Suggested Firestore Collections + +```txt +notifications/ +notificationEvents/ +threads/ +posts/ +forums/ +userNotificationSettings/ +emailQueue/ +discordQueue/ +``` + +### Notes + +- `userNotificationSettings/` is future-facing but should be planned now +- `emailQueue/` and `discordQueue/` can be introduced later without changing the core notification schema + +--- + +## 21. Error Handling + +### Notification creation failures + +Log at minimum: + +- notification type +- source event key +- recipient count +- team/league context +- route destination + +Do not silently drop failures. + +### Forum creation failures + +Log at minimum: + +- external event key +- forum id +- intended thread title +- generated thread/post IDs +- whether a duplicate check occurred + +Retries must remain idempotent. + +### Partial failure concerns + +When writing many notifications: + +- use Firestore batch writes where appropriate +- chunk large batches +- fail loudly on repository errors +- keep event keys available for replay + +--- + +## 22. Observability + +At minimum, track: + +- `notifications_created_total` +- `notifications_failed_total` +- `forum_threads_created_total` +- `forum_posts_created_total` +- `idempotent_skips_total` +- `recipient_resolution_failures_total` + +Log fields should include: + +- `sourceEventKey` +- `notificationType` +- `routeName` +- `path` +- `threadId` +- `postId` +- `league` +- `teamId` + +--- + +## 23. Testing Strategy + +### Unit tests + +Test: + +- route builders +- notification title/message builders +- recipient resolvers +- idempotency checks +- metadata generation + +### Repository tests + +Test: + +- create notifications +- create thread + first post atomically +- query by external event key +- query by source event key + +### Integration tests + +Test flows such as: + +- injury event -> notifications created +- recruit signs -> notifications point to recruiting page +- postgame event -> thread and first post created +- duplicate event key -> duplicate creation prevented + +--- + +## 24. Example Go Skeleton + +```go +type notificationService struct { + notifications NotificationRepository + recipients RecipientResolver + routes RouteBuilder + clock func() time.Time +} + +func (s *notificationService) NotifyRecruitSigned(ctx context.Context, input RecruitSignedNotificationInput) error { + exists, err := s.notifications.ExistsBySourceEventKey(ctx, input.SourceEventKey, NotificationRecruitSigned) + if err != nil { + return err + } + if exists { + return nil + } + + recipientUIDs, err := s.recipients.ResolveRecruitSignedRecipients(ctx, input.TeamID) + if err != nil { + return err + } + + route := s.routes.BuildRecruitingRoute(input.League, input.TeamID) + now := s.clock() + + notifications := make([]Notification, 0, len(recipientUIDs)) + for _, uid := range recipientUIDs { + notifications = append(notifications, Notification{ + RecipientUID: uid, + Type: NotificationRecruitSigned, + Title: fmt.Sprintf("Recruit Signed: %s", input.RecruitName), + Message: fmt.Sprintf("%s has signed with %s.", input.RecruitName, input.TeamName), + Severity: "success", + IsRead: false, + CreatedAt: now, + League: input.League, + TeamID: input.TeamID, + EntityType: "recruit", + EntityID: strconv.Itoa(int(input.RecruitID)), + Route: route, + SourceEventKey: input.SourceEventKey, + Delivery: NotificationDelivery{ + InAppCreated: true, + }, + Metadata: map[string]interface{}{ + "recruitId": input.RecruitID, + "recruitName": input.RecruitName, + }, + }) + } + + return s.notifications.CreateNotifications(ctx, notifications) +} +``` + +--- + +## 25. Future Email and Discord Extension + +This design is intentionally compatible with future secondary delivery channels. + +### Email later + +A future processor can: + +- read notification events or notifications +- check `userNotificationSettings/{uid}` +- queue outbound email documents in `emailQueue/` + +### Discord later + +A future Discord worker can: + +- read from `discordQueue/` +- map users or channels +- send bot messages + +### Important recommendation + +Treat email and Discord as delivery channels, not as the primary notification source. +The source of truth should remain the Go domain event pipeline. + +--- + +## 26. Suggested Implementation Phases + +### Phase 1: In-App Notifications + +- notification schema +- route builder package +- recipient resolver package +- notification service for 5 core event types +- frontend consumption of route metadata + +### Phase 2: Forum Automation + +- thread + first post batch creation +- postgame thread creation +- recruiting sync admin threads +- forum reply notification support + +### Phase 3: Preferences + +- `userNotificationSettings/{uid}` +- per-type opt in/out +- in-app vs email vs Discord toggles + +### Phase 4: Secondary Delivery + +- email queue and processor +- Discord queue and worker +- digest support + +--- + +## 27. Recommended Final Decisions + +- Use typed notifications with stable constants +- Include explicit route metadata in every notification +- Use `SourceEventKey` for notification idempotency +- Use `ExternalEventKey` for automated forum thread/post idempotency +- Keep Firestore access behind repository interfaces +- Use a centralized route builder package +- Create automated threads and first posts in a single batch +- Design now for future email/Discord fan-out, but do not block on implementing them first + +--- + +## 28. Conclusion + +A Go + Firebase implementation is a strong fit for both in-app notifications and automated forum content. + +The most important design choice is this: + +**the backend must own the event type, recipients, and route payload.** + +Once the notification contract includes: + +- stable type +- title/message +- metadata +- route object +- idempotency key + +the frontend becomes much simpler and more reliable. + +For your use case, the best next steps are: + +1. define notification type enums +2. implement centralized route builders +3. build a notification service with recipient resolution and idempotency +4. add forum thread/post automation with external event keys +5. later extend the same pipeline into email and Discord + +This gives you one clean backend-first notification and forum system instead of several disconnected features. diff --git a/controller/FirebaseController.go b/controller/FirebaseController.go new file mode 100644 index 0000000..0b653aa --- /dev/null +++ b/controller/FirebaseController.go @@ -0,0 +1,77 @@ +package controller + +import ( + "context" + "encoding/json" + "net/http" + + fbsvc "github.com/CalebRose/SimFBA/firebase" +) + +// TestNotificationToTuscan sends a test notification to the user "TuscanSota". +// GET /firebase/test/notification/ +func TestNotificationToTuscan(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + + uids := fbsvc.ResolveUIDsByUsernames(ctx, []string{"TuscanSota"}) + if len(uids) == 0 { + http.Error(w, "Could not resolve UID for TuscanSota", http.StatusNotFound) + return + } + + eventKey := fbsvc.BuildSourceEventKey("test_notification", "tuscan") + err := fbsvc.NotifyGameplanIssue(ctx, fbsvc.GameplanNotificationInput{ + League: "cfb", + Domain: fbsvc.DomainSystem, + TeamID: 0, + TeamName: "Test Team", + TeamAbbr: "TST", + Message: "This is a test notification from the SimFBA API. If you can see this, Firebase notifications are working correctly!", + RecipientUIDs: uids, + SourceEventKey: eventKey, + }) + if err != nil { + http.Error(w, "Failed to send notification: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "message": "Test notification sent to TuscanSota", + "uids": uids, + }) +} + +// TestForumPost creates a test thread with an initial post in the "daily" forum. +// GET /firebase/test/forum/ +func TestForumPost(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + + input := fbsvc.CreateForumThreadInput{ + ForumID: "daily", + ForumPath: []string{"daily"}, + Title: "API Test Thread", + AuthorUID: "system", + AuthorUsername: "SimSN", + AuthorDisplayName: "SimSN System", + CreatedByType: fbsvc.CreatedBySystem, + ThreadType: fbsvc.ThreadTypeStandard, + FirstPostBodyText: "This is a test thread created by the SimFBA API. If you can see this, forum thread creation is working correctly!", + ExternalEventKey: fbsvc.BuildSourceEventKey("test_forum_thread", "daily"), + } + + thread, err := fbsvc.CreateThread(ctx, input) + if err != nil { + http.Error(w, "Failed to create forum thread: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "message": "Test thread created in daily forum", + "threadId": thread.ID, + "title": thread.Title, + }) +} diff --git a/firebase/client.go b/firebase/client.go new file mode 100644 index 0000000..1a87f2c --- /dev/null +++ b/firebase/client.go @@ -0,0 +1,100 @@ +package firebase + +import ( + "context" + "encoding/json" + "log" + "os" + "strings" + "sync" + + "cloud.google.com/go/firestore" + firebase "firebase.google.com/go/v4" + "google.golang.org/api/option" +) + +var ( + once sync.Once + firestoreClient *firestore.Client +) + +// GetFirestoreClient returns a singleton Firestore client. +// +// Credential resolution order: +// 1. Individual FIREBASE_* environment variables (recommended for production). +// 2. FIREBASE_SERVICE_ACCOUNT_KEY env var treated as a file path. +// 3. secrets/serviceAccountKey.json relative to the working directory (local dev). +func GetFirestoreClient() *firestore.Client { + once.Do(func() { + ctx := context.Background() + + var credOpt option.ClientOption + if jsonBytes := buildCredentialsFromEnv(); jsonBytes != nil { + credOpt = option.WithCredentialsJSON(jsonBytes) + } else { + credOpt = option.WithCredentialsFile(resolveKeyPath()) + } + + app, err := firebase.NewApp(ctx, nil, credOpt) + if err != nil { + log.Fatalf("firebase: failed to initialise app: %v", err) + } + client, err := app.Firestore(ctx) + if err != nil { + log.Fatalf("firebase: failed to create Firestore client: %v", err) + } + firestoreClient = client + }) + return firestoreClient +} + +// buildCredentialsFromEnv assembles a service-account JSON document from +// individual environment variables. Returns nil if FIREBASE_PRIVATE_KEY is +// not set, signalling that the file-based fallback should be used instead. +// +// Required env vars: +// +// FIREBASE_PROJECT_ID +// FIREBASE_PRIVATE_KEY_ID +// FIREBASE_PRIVATE_KEY (PEM block; literal \n sequences are expanded automatically) +// FIREBASE_CLIENT_EMAIL +// FIREBASE_CLIENT_ID +// FIREBASE_CLIENT_X509_CERT_URL +func buildCredentialsFromEnv() []byte { + privateKey := os.Getenv("FIREBASE_PRIVATE_KEY") + if privateKey == "" { + return nil + } + + // Environment variables store the PEM block with literal \n instead of + // real newlines. The key parser requires actual newline characters. + privateKey = strings.ReplaceAll(privateKey, `\n`, "\n") + + creds := map[string]string{ + "type": "service_account", + "project_id": os.Getenv("FIREBASE_PROJECT_ID"), + "private_key_id": os.Getenv("FIREBASE_PRIVATE_KEY_ID"), + "private_key": privateKey, + "client_email": os.Getenv("FIREBASE_CLIENT_EMAIL"), + "client_id": os.Getenv("FIREBASE_CLIENT_ID"), + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": os.Getenv("FIREBASE_CLIENT_X509_CERT_URL"), + } + + b, err := json.Marshal(creds) + if err != nil { + log.Fatalf("firebase: failed to marshal credentials from env: %v", err) + } + return b +} + +// resolveKeyPath returns the path to the service account key file. +// Override with FIREBASE_SERVICE_ACCOUNT_KEY for a non-default path. +func resolveKeyPath() string { + if path := os.Getenv("FIREBASE_SERVICE_ACCOUNT_KEY"); path != "" { + return path + } + return "secrets/serviceAccountKey.json" +} diff --git a/firebase/forum_service.go b/firebase/forum_service.go new file mode 100644 index 0000000..9ebc5db --- /dev/null +++ b/firebase/forum_service.go @@ -0,0 +1,224 @@ +package firebase + +import ( + "context" + "fmt" + "log" + "regexp" + "strings" + "time" + + "cloud.google.com/go/firestore" + "google.golang.org/api/iterator" +) + +// ───────────────────────────────────────────── +// Forum Service +// ───────────────────────────────────────────── + +// CreateThread creates a new thread and its first post atomically in Firestore. +// If input.ExternalEventKey is set and a thread with that key already exists, +// the existing thread is returned without creating a duplicate (idempotency). +// +// The thread and post are written as two separate documents (Firestore does not +// support cross-collection transactions in the same batch easily without a +// transaction); the post is patched with the threadId immediately after both +// docs are created, matching the flow used by the frontend service. +func CreateThread(ctx context.Context, input CreateForumThreadInput) (*ForumThread, error) { + if input.ForumID == "" { + return nil, fmt.Errorf("firebase: ForumID is required") + } + if input.Title == "" { + return nil, fmt.Errorf("firebase: Title is required") + } + + client := GetFirestoreClient() + + // Idempotency: return existing thread if we already created one for this event. + if input.ExternalEventKey != "" { + existing, err := FindThreadByExternalEventKey(ctx, input.ExternalEventKey) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil + } + } + + now := time.Now().UTC() + slug := buildSlug(input.Title) + + author := ThreadAuthor{ + UID: input.AuthorUID, + Username: input.AuthorUsername, + DisplayName: input.AuthorDisplayName, + } + + postBody := buildSimplePostBody(input.FirstPostBodyText) + if input.FirstPostBody != nil { + postBody = input.FirstPostBody + } + preview := truncate(input.FirstPostBodyText, 200) + + // 1. Create a post document with an empty threadId; we patch it below. + postRef := client.Collection("posts").NewDoc() + post := ForumPost{ + ID: postRef.ID, + ThreadID: "", // patched in step 3 + ForumID: input.ForumID, + Author: PostAuthor(author), + EditorVersion: 1, + Body: postBody, + BodyText: input.FirstPostBodyText, + Mentions: []PostMention{}, + Reactions: map[string][]string{}, + IsEdited: false, + IsDeleted: false, + CreatedAt: now, + UpdatedAt: now, + } + if _, err := postRef.Set(ctx, post); err != nil { + return nil, fmt.Errorf("firebase: failed to create first post: %w", err) + } + + // 2. Create the thread document. + threadRef := client.Collection("threads").NewDoc() + activityBy := &ActivityBy{UID: input.AuthorUID, Username: input.AuthorUsername} + thread := ForumThread{ + ID: threadRef.ID, + ForumID: input.ForumID, + ForumPath: input.ForumPath, + Title: input.Title, + Slug: slug, + Author: author, + ContentPreview: preview, + FirstPostID: postRef.ID, + IsPinned: false, + IsLocked: false, + IsAnnouncement: false, + IsDeleted: false, + Tags: []string{}, + ThreadType: input.ThreadType, + ReferencedGameID: input.ReferencedGameID, + ReferencedLeague: input.ReferencedLeague, + ReplyCount: 0, + ParticipantCount: 1, + LatestPostID: postRef.ID, + LatestActivityAt: now, + LatestActivityBy: activityBy, + CreatedAt: now, + UpdatedAt: now, + ExternalEventKey: input.ExternalEventKey, + } + if _, err := threadRef.Set(ctx, thread); err != nil { + // Best-effort cleanup: delete the orphaned post. + if _, delErr := postRef.Delete(ctx); delErr != nil { + log.Printf("firebase: failed to clean up orphaned post %s: %v", postRef.ID, delErr) + } + return nil, fmt.Errorf("firebase: failed to create thread: %w", err) + } + + // 3. Patch the post with the real threadId. + if _, err := postRef.Update(ctx, []firestore.Update{ + {Path: "threadId", Value: threadRef.ID}, + }); err != nil { + // Non-fatal: the thread exists; the frontend can handle a post without threadId + // being present, but log so ops can fix it. + log.Printf("firebase: failed to patch post %s with threadId %s: %v", postRef.ID, threadRef.ID, err) + } + + // 4. Increment forum counters (best-effort; failure is non-fatal). + if input.ForumID != "" { + forumRef := client.Collection("forums").Doc(input.ForumID) + if _, err := forumRef.Update(ctx, []firestore.Update{ + {Path: "threadCount", Value: firestore.Increment(1)}, + {Path: "postCount", Value: firestore.Increment(1)}, + {Path: "latestActivityAt", Value: now}, + {Path: "latestActivityBy", Value: activityBy}, + {Path: "latestThreadId", Value: threadRef.ID}, + }); err != nil { + log.Printf("firebase: failed to increment forum counters for forum %s: %v", input.ForumID, err) + } + } + + thread.ID = threadRef.ID + return &thread, nil +} + +// FindThreadByExternalEventKey looks up a thread by its ExternalEventKey. +// Returns nil, nil if no thread is found. +func FindThreadByExternalEventKey(ctx context.Context, eventKey string) (*ForumThread, error) { + client := GetFirestoreClient() + + iter := client.Collection("threads"). + Where("externalEventKey", "==", eventKey). + Limit(1). + Documents(ctx) + defer iter.Stop() + + doc, err := iter.Next() + if err == iterator.Done { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("firebase: FindThreadByExternalEventKey: %w", err) + } + + var thread ForumThread + if err := doc.DataTo(&thread); err != nil { + return nil, fmt.Errorf("firebase: failed to decode thread document: %w", err) + } + thread.ID = doc.Ref.ID + return &thread, nil +} + +// ───────────────────────────────────────────── +// Rich text helpers +// ───────────────────────────────────────────── + +// buildSimplePostBody produces a minimal Tiptap/ProseMirror-compatible rich +// text document that the frontend renderer can display. +func buildSimplePostBody(text string) map[string]interface{} { + return map[string]interface{}{ + "type": "doc", + "content": []map[string]interface{}{ + { + "type": "paragraph", + "content": []map[string]interface{}{ + { + "type": "text", + "text": text, + }, + }, + }, + }, + } +} + +// ───────────────────────────────────────────── +// String helpers +// ───────────────────────────────────────────── + +var nonAlphanumRe = regexp.MustCompile(`[^a-z0-9\s-]`) +var whitespaceRe = regexp.MustCompile(`\s+`) + +// buildSlug converts a title to a URL-friendly slug (max 80 chars), mirroring +// the logic used by the frontend CreateThread function. +func buildSlug(title string) string { + s := strings.ToLower(title) + s = nonAlphanumRe.ReplaceAllString(s, "") + s = whitespaceRe.ReplaceAllString(s, "-") + if len(s) > 80 { + s = s[:80] + } + return s +} + +// truncate returns at most n runes from s, used for content previews. +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) +} diff --git a/firebase/notification_service.go b/firebase/notification_service.go new file mode 100644 index 0000000..e04afa5 --- /dev/null +++ b/firebase/notification_service.go @@ -0,0 +1,247 @@ +package firebase + +import ( + "context" + "fmt" + "log" + "time" + + "cloud.google.com/go/firestore" + "google.golang.org/api/iterator" +) + +// ───────────────────────────────────────────── +// Notification Service +// ───────────────────────────────────────────── + +// NotifyPlayerInjured creates one notification document per recipient for a +// player-injury event. Idempotent: if a notification with the same +// SourceEventKey already exists for a recipient it is skipped. +func NotifyPlayerInjured(ctx context.Context, input PlayerInjuryNotificationInput) error { + if len(input.RecipientUIDs) == 0 { + return nil + } + + message := fmt.Sprintf( + "%s suffered %s and is expected to miss %d game(s).", + input.PlayerName, input.InjuryName, input.GamesMissed, + ) + linkTo := BuildTeamRosterRoute(input.League, input.TeamID) + + return writeNotificationsIfNew(ctx, input.RecipientUIDs, ForumNotification{ + Type: NotificationTypeInjury, + Domain: input.Domain, + LinkTo: linkTo, + Message: message, + ActorUsername: "SimSN", + IsRead: false, + SourceEventKey: input.SourceEventKey, + }) +} + +// NotifyGameplanIssue sends a depth-chart / gameplan penalty notification to +// the coach or owner of a team. Idempotent via SourceEventKey. +func NotifyGameplanIssue(ctx context.Context, input GameplanNotificationInput) error { + if len(input.RecipientUIDs) == 0 { + return nil + } + linkTo := BuildTeamGameplanRoute(input.League, input.TeamID) + + return writeNotificationsIfNew(ctx, input.RecipientUIDs, ForumNotification{ + Type: NotificationTypeGameplan, + Domain: input.Domain, + LinkTo: linkTo, + Message: input.Message, + ActorUsername: "SimSN", + IsRead: false, + SourceEventKey: input.SourceEventKey, + }) +} + +// NotifyRecruitingSyncMissed sends a notification to a coach when they failed to +// allocate any recruiting points during the weekly sync. Idempotent via SourceEventKey. +func NotifyRecruitingSyncMissed(ctx context.Context, input RecruitingSyncMissedNotificationInput) error { + if len(input.RecipientUIDs) == 0 { + return nil + } + linkTo := BuildTeamRecruitingRoute("cfb", input.TeamID) + + return writeNotificationsIfNew(ctx, input.RecipientUIDs, ForumNotification{ + Type: NotificationTypeRecruiting, + Domain: DomainCFB, + LinkTo: linkTo, + Message: input.Message, + ActorUsername: "SimSN", + IsRead: false, + SourceEventKey: input.SourceEventKey, + }) +} + +// NotifyPracticeSquadOffer notifies an NFL team's owner and GM that another team +// has placed a practice squad offer on one of their players. Idempotent via SourceEventKey. +func NotifyPracticeSquadOffer(ctx context.Context, input PracticeSquadOfferNotificationInput) error { + if len(input.RecipientUIDs) == 0 { + return nil + } + + message := fmt.Sprintf( + "%s have placed an offer on %s %s to pick up from your practice squad.", + input.OfferingTeam, input.Position, input.PlayerName, + ) + linkTo := BuildTeamRosterRoute("nfl", input.OwnerTeamID) + + return writeNotificationsIfNew(ctx, input.RecipientUIDs, ForumNotification{ + Type: NotificationTypeFreeAgency, + Domain: DomainNFL, + LinkTo: linkTo, + Message: message, + ActorUsername: "SimSN", + IsRead: false, + SourceEventKey: input.SourceEventKey, + }) +} + +// NotifyTransferIntention notifies a coach that one of their players has declared +// an intention to enter the transfer portal. Idempotent via SourceEventKey. +func NotifyTransferIntention(ctx context.Context, input TransferIntentionNotificationInput) error { + if len(input.RecipientUIDs) == 0 { + return nil + } + + message := fmt.Sprintf( + "%d star %s %s has a %s likeliness of entering the transfer portal. Please navigate to the Roster page to submit a promise.", + input.Stars, input.Position, input.PlayerName, input.TransferLikeliness, + ) + linkTo := BuildTeamRosterRoute("cfb", input.TeamID) + + return writeNotificationsIfNew(ctx, input.RecipientUIDs, ForumNotification{ + Type: NotificationTypeTransfer, + Domain: DomainCFB, + LinkTo: linkTo, + Message: message, + ActorUsername: "SimSN", + IsRead: false, + SourceEventKey: input.SourceEventKey, + }) +} + +// NotifyTeamInjury notifies a team's coaches or owners that a player was injured +// during a game. The link leads to the team's roster page. +// Idempotent via SourceEventKey (keyed per player per game). +func NotifyTeamInjury(ctx context.Context, input TeamInjuryNotificationInput) error { + if len(input.RecipientUIDs) == 0 { + return nil + } + + weeksStr := "1 week" + if input.WeeksOfRecovery > 1 { + weeksStr = fmt.Sprintf("%d weeks", input.WeeksOfRecovery) + } + message := fmt.Sprintf( + "%s (%s) suffered %s and is expected to miss %s. Check the roster for details.", + input.PlayerName, input.Position, input.InjuryType, weeksStr, + ) + linkTo := BuildTeamRosterRoute(input.League, input.TeamID) + + return writeNotificationsIfNew(ctx, input.RecipientUIDs, ForumNotification{ + Type: NotificationTypeInjury, + Domain: input.Domain, + LinkTo: linkTo, + Message: message, + ActorUsername: "SimSN", + IsRead: false, + SourceEventKey: input.SourceEventKey, + }) +} + +// NotifyRecruitSigned creates one notification document per recipient when a +// recruit commits to a team. Idempotent via SourceEventKey. +func NotifyRecruitSigned(ctx context.Context, input RecruitSignedNotificationInput) error { + if len(input.RecipientUIDs) == 0 { + return nil + } + + message := fmt.Sprintf("%s has signed with %s.", input.RecruitName, input.TeamName) + linkTo := BuildTeamRecruitingRoute(input.League, input.TeamID) + + return writeNotificationsIfNew(ctx, input.RecipientUIDs, ForumNotification{ + Type: NotificationTypeRecruiting, + Domain: input.Domain, + LinkTo: linkTo, + Message: message, + ActorUsername: "SimSN", + IsRead: false, + SourceEventKey: input.SourceEventKey, + }) +} + +// ───────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────── + +// writeNotificationsIfNew writes one notification document per recipient UID, +// skipping any recipient that already has a document with the same +// SourceEventKey (idempotency guard). +func writeNotificationsIfNew( + ctx context.Context, + recipientUIDs []string, + template ForumNotification, +) error { + client := GetFirestoreClient() + col := client.Collection("notifications") + now := time.Now().UTC() + + for _, uid := range recipientUIDs { + if uid == "" { + continue + } + + // Idempotency: skip if this event was already delivered to this recipient. + if template.SourceEventKey != "" { + exists, err := notificationExists(ctx, col, uid, template.SourceEventKey) + if err != nil { + log.Printf("firebase: idempotency check failed for uid=%s key=%s: %v", uid, template.SourceEventKey, err) + } + if exists { + continue + } + } + + ref := col.NewDoc() + n := template + n.ID = ref.ID + n.UID = uid + n.CreatedAt = now + + if _, err := ref.Set(ctx, n); err != nil { + log.Printf("firebase: failed to write notification for uid=%s: %v", uid, err) + } + } + + return nil +} + +// notificationExists returns true when a notification doc already exists for +// the given uid and sourceEventKey. +func notificationExists( + ctx context.Context, + col *firestore.CollectionRef, + uid string, + sourceEventKey string, +) (bool, error) { + iter := col. + Where("uid", "==", uid). + Where("sourceEventKey", "==", sourceEventKey). + Limit(1). + Documents(ctx) + defer iter.Stop() + + _, err := iter.Next() + if err == iterator.Done { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} diff --git a/firebase/recipient_resolver.go b/firebase/recipient_resolver.go new file mode 100644 index 0000000..3130550 --- /dev/null +++ b/firebase/recipient_resolver.go @@ -0,0 +1,52 @@ +package firebase + +import ( + "context" + "log" + + "cloud.google.com/go/firestore" + "google.golang.org/api/iterator" +) + +// ResolveUIDsByUsernames queries the Firestore "users" collection for each +// username and returns the matching document IDs (i.e. Firebase Auth UIDs). +// Usernames that are not found are silently skipped and a warning is logged. +func ResolveUIDsByUsernames(ctx context.Context, usernames []string) []string { + if len(usernames) == 0 { + return nil + } + + client := GetFirestoreClient() + uids := make([]string, 0, len(usernames)) + + for _, username := range usernames { + if username == "" { + continue + } + uid, err := resolveUID(ctx, client, username) + if err != nil { + log.Printf("firebase: could not resolve UID for username %q: %v", username, err) + continue + } + uids = append(uids, uid) + } + + return uids +} + +func resolveUID(ctx context.Context, client *firestore.Client, username string) (string, error) { + iter := client.Collection("users"). + Where("username", "==", username). + Limit(1). + Documents(ctx) + defer iter.Stop() + + doc, err := iter.Next() + if err == iterator.Done { + return "", nil + } + if err != nil { + return "", err + } + return doc.Ref.ID, nil +} diff --git a/firebase/routes.go b/firebase/routes.go new file mode 100644 index 0000000..9384716 --- /dev/null +++ b/firebase/routes.go @@ -0,0 +1,58 @@ +package firebase + +import ( + "fmt" + "strconv" +) + +// BuildTeamRosterRoute builds the notification route pointing to a team's roster page. +func BuildTeamRosterRoute(league string, teamID uint) string { + if league == "cfb" { + return fmt.Sprintf("/%s/roster/%d", league, teamID) + } + return fmt.Sprintf("/%s/team/%d", league, teamID) +} + +// BuildTeamRecruitingRoute builds the route pointing to a team's recruiting page. +func BuildTeamRecruitingRoute(league string, teamID uint) string { + return fmt.Sprintf("/%s/recruiting", league) +} + +// BuildTeamGameplanRoute builds the route pointing to a team's gameplan page. +func BuildTeamGameplanRoute(league string, teamID uint) string { + return fmt.Sprintf("/%s/gameplan", league) +} + +// BuildTeamPracticeSquadRoute builds the route pointing to a team's practice squad page. +func BuildTeamPracticeSquadRoute(league string, teamID uint) string { + return fmt.Sprintf("/%s/practicesquad", league) +} + +// BuildForumThreadRoute builds the route pointing to a specific forum thread. +func BuildForumThreadRoute(threadID string) string { + return fmt.Sprintf("/forums/thread/%s", threadID) +} + +// BuildForumPostRoute builds the route pointing to a specific post inside a thread +// using an anchor fragment. +func BuildForumPostRoute(threadID string, postID string) string { + return fmt.Sprintf("/forums/thread/%s#post-%s", threadID, postID) +} + +// BuildSourceEventKey generates a stable idempotency key from its components. +// Example: "injury:cfb:season12:game881:player193" +func BuildSourceEventKey(parts ...string) string { + key := "" + for i, p := range parts { + if i > 0 { + key += ":" + } + key += p + } + return key +} + +// UintToString converts a uint to a decimal string. +func UintToString(v uint) string { + return strconv.FormatUint(uint64(v), 10) +} diff --git a/firebase/types.go b/firebase/types.go new file mode 100644 index 0000000..89b2f36 --- /dev/null +++ b/firebase/types.go @@ -0,0 +1,283 @@ +package firebase + +import "time" + +// ───────────────────────────────────────────── +// Shared sub-types +// ───────────────────────────────────────────── + +// ThreadAuthor mirrors the frontend ThreadAuthor shape stored in Firestore. +type ThreadAuthor struct { + UID string `firestore:"uid"` + Username string `firestore:"username"` + DisplayName string `firestore:"displayName"` + LogoURL string `firestore:"logoUrl,omitempty"` +} + +// PostAuthor mirrors the frontend PostAuthor shape stored in Firestore. +type PostAuthor struct { + UID string `firestore:"uid"` + Username string `firestore:"username"` + DisplayName string `firestore:"displayName"` + LogoURL string `firestore:"logoUrl,omitempty"` +} + +// ActivityBy is the compact author snapshot used for "latest activity" fields. +type ActivityBy struct { + UID string `firestore:"uid"` + Username string `firestore:"username"` +} + +// PostMention is a user mentioned inside a post body. +type PostMention struct { + UID string `firestore:"uid"` + Username string `firestore:"username"` +} + +// ───────────────────────────────────────────── +// Forum Thread +// ───────────────────────────────────────────── + +// Thread type constants (must stay in sync with the frontend ThreadType union). +const ( + ThreadTypeStandard = "standard" + ThreadTypeGameReference = "game_reference" + ThreadTypePoll = "poll" +) + +// CreatedByType constants. +const ( + CreatedByUser = "user" + CreatedBySystem = "system" + CreatedByBot = "bot" +) + +// ForumThread is the Firestore document shape for the "threads" collection. +// It aligns with the frontend Thread interface in forumModels.ts. +type ForumThread struct { + ID string `firestore:"id"` + ForumID string `firestore:"forumId"` + ForumPath []string `firestore:"forumPath"` + Title string `firestore:"title"` + Slug string `firestore:"slug"` + Author ThreadAuthor `firestore:"author"` + ContentPreview string `firestore:"contentPreview"` + FeatureImageURL string `firestore:"featureImageUrl,omitempty"` + FirstPostID string `firestore:"firstPostId"` + IsPinned bool `firestore:"isPinned"` + IsLocked bool `firestore:"isLocked"` + IsAnnouncement bool `firestore:"isAnnouncement"` + IsDeleted bool `firestore:"isDeleted"` + Tags []string `firestore:"tags"` + ThreadType string `firestore:"threadType"` + PollID string `firestore:"pollId,omitempty"` + ReferencedGameID string `firestore:"referencedGameId,omitempty"` + ReferencedLeague string `firestore:"referencedLeague,omitempty"` + ReplyCount int `firestore:"replyCount"` + ParticipantCount int `firestore:"participantCount"` + LatestPostID string `firestore:"latestPostId,omitempty"` + LatestActivityAt time.Time `firestore:"latestActivityAt"` + LatestActivityBy *ActivityBy `firestore:"latestActivityBy,omitempty"` + CreatedAt time.Time `firestore:"createdAt"` + UpdatedAt time.Time `firestore:"updatedAt"` + + // ExternalEventKey is set on system-generated threads for idempotency. + // Corresponds to a queryable field so we can check before creating duplicates. + ExternalEventKey string `firestore:"externalEventKey,omitempty"` +} + +// ───────────────────────────────────────────── +// Forum Post +// ───────────────────────────────────────────── + +// ForumPost is the Firestore document shape for the "posts" collection. +// It aligns with the frontend Post interface in forumModels.ts. +type ForumPost struct { + ID string `firestore:"id"` + ThreadID string `firestore:"threadId"` + ForumID string `firestore:"forumId"` + Author PostAuthor `firestore:"author"` + EditorVersion int `firestore:"editorVersion"` + Body map[string]interface{} `firestore:"body"` + BodyText string `firestore:"bodyText"` + QuotedPostID string `firestore:"quotedPostId,omitempty"` + ReplyToPostID string `firestore:"replyToPostId,omitempty"` + Mentions []PostMention `firestore:"mentions"` + Reactions map[string][]string `firestore:"reactions"` + IsEdited bool `firestore:"isEdited"` + EditedAt *time.Time `firestore:"editedAt,omitempty"` + EditedBy string `firestore:"editedBy,omitempty"` + IsDeleted bool `firestore:"isDeleted"` + DeletedAt *time.Time `firestore:"deletedAt,omitempty"` + DeletedBy string `firestore:"deletedBy,omitempty"` + ModerationReason string `firestore:"moderationReason,omitempty"` + CreatedAt time.Time `firestore:"createdAt"` + UpdatedAt time.Time `firestore:"updatedAt"` +} + +// ───────────────────────────────────────────── +// Notification +// ───────────────────────────────────────────── + +// Notification type constants — aligned with the frontend NotificationForumType union. +const ( + NotificationTypeInjury = "injury" + NotificationTypeRecruiting = "recruiting" + NotificationTypeGameplan = "gameplan" + NotificationTypeTrade = "trade" + NotificationTypeDraft = "draft" + NotificationTypeFreeAgency = "free_agency" + NotificationTypeTransfer = "transfer" + NotificationTypeSystem = "system" + NotificationTypeForumReply = "reply" + NotificationTypeForumMention = "mention" +) + +// Notification domain constants — aligned with the frontend NotificationDomain union. +const ( + DomainCFB = "cfb" + DomainNFL = "nfl" + DomainForum = "forum" + DomainSystem = "system" +) + +// ForumNotification is the Firestore document shape for the "notifications" collection. +// It aligns with the frontend ForumNotification interface in forumModels.ts. +type ForumNotification struct { + ID string `firestore:"id"` + UID string `firestore:"uid"` // Firebase Auth UID of the recipient + Type string `firestore:"type"` // NotificationForumType + Domain string `firestore:"domain"` // NotificationDomain + LinkTo string `firestore:"linkTo,omitempty"` + ThreadID string `firestore:"threadId,omitempty"` + PostID string `firestore:"postId,omitempty"` + ActorUID string `firestore:"actorUid,omitempty"` + ActorUsername string `firestore:"actorUsername,omitempty"` + Message string `firestore:"message"` + IsRead bool `firestore:"isRead"` + CreatedAt time.Time `firestore:"createdAt"` + + // SourceEventKey supports idempotency checks — not rendered by the frontend. + SourceEventKey string `firestore:"sourceEventKey,omitempty"` +} + +// ───────────────────────────────────────────── +// Service input types +// ───────────────────────────────────────────── + +// CreateForumThreadInput carries all the data required to create a thread + its first post atomically. +type CreateForumThreadInput struct { + ForumID string + ForumPath []string + Title string + AuthorUID string + AuthorUsername string + AuthorDisplayName string + CreatedByType string // CreatedByUser, CreatedBySystem, CreatedByBot + ThreadType string // ThreadTypeStandard, ThreadTypeGameReference, etc. + FirstPostBodyText string + // FirstPostBody is an optional pre-built ProseMirror JSON document. + // When non-nil it is used as the post Body instead of the auto-generated + // single-paragraph document derived from FirstPostBodyText. + FirstPostBody map[string]interface{} + ReferencedGameID string + ReferencedLeague string + ExternalEventKey string + Metadata map[string]interface{} +} + +// PlayerInjuryNotificationInput carries the context needed to build injury notifications. +type PlayerInjuryNotificationInput struct { + League string + Domain string // e.g. DomainCFB, DomainNFL + TeamID uint + TeamName string + PlayerID uint + PlayerName string + InjuryName string + GamesMissed int + RecipientUIDs []string // Firebase Auth UIDs of coaches/owners to notify + SourceEventKey string +} + +// GameplanNotificationInput carries the context needed to notify a coach or owner +// about a depth-chart or gameplan issue that has resulted in a penalty. +type GameplanNotificationInput struct { + League string + Domain string // e.g. DomainCFB, DomainNFL + TeamID uint + TeamName string + TeamAbbr string + Message string // fully-formed message from the caller + RecipientUIDs []string + SourceEventKey string +} + +// RecruitSignedNotificationInput carries the context needed to build recruit-signing notifications. +type RecruitSignedNotificationInput struct { + League string + Domain string // e.g. DomainCFB + TeamID uint + TeamName string + RecruitID uint + RecruitName string + RecipientUIDs []string + SourceEventKey string +} + +// RecruitingSyncMissedNotificationInput carries the context needed to notify a coach +// that they missed allocating recruiting points during the weekly sync. +type RecruitingSyncMissedNotificationInput struct { + TeamID uint + TeamName string + TeamAbbr string + WeeksMissed int + Message string // fully-formed message from the caller + RecipientUIDs []string + SourceEventKey string +} + +// TransferIntentionNotificationInput carries the context needed to notify a coach +// that one of their players has declared an intention to enter the transfer portal. +type TransferIntentionNotificationInput struct { + TeamID uint + TeamAbbr string + PlayerID uint + PlayerName string + Position string + Stars int + TransferLikeliness string + RecipientUIDs []string + SourceEventKey string +} + +// PracticeSquadOfferNotificationInput carries the context needed to notify an NFL +// team's staff that another team has placed a practice squad offer on their player. +type PracticeSquadOfferNotificationInput struct { + OwnerTeamID uint + OwnerTeamName string + OwnerTeamAbbr string + OfferingTeam string + PlayerID uint + PlayerName string + Position string + RecipientUIDs []string + SourceEventKey string +} + +// TeamInjuryNotificationInput carries the context needed to notify a team's +// coaches or owners that a player was injured during a game. +type TeamInjuryNotificationInput struct { + League string + Domain string // e.g. DomainCFB, DomainNFL + TeamID uint + TeamName string + PlayerID uint + PlayerName string + Position string + InjuryType string + WeeksOfRecovery uint + GameID string + RecipientUIDs []string + SourceEventKey string +} diff --git a/go.mod b/go.mod index fe06e7d..c65f8da 100644 --- a/go.mod +++ b/go.mod @@ -11,15 +11,66 @@ require ( github.com/nelkinda/health-go v0.0.1 github.com/robfig/cron/v3 v3.0.1 github.com/victorspringer/http-cache v0.0.0-20240523143319-7d9f48f8ab91 - golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 - golang.org/x/text v0.14.0 + golang.org/x/crypto v0.40.0 + golang.org/x/text v0.27.0 gorm.io/driver/mysql v1.5.2 gorm.io/gorm v1.25.5 ) require ( + cel.dev/expr v0.23.1 // indirect + cloud.google.com/go v0.121.0 // indirect + cloud.google.com/go/auth v0.16.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/firestore v1.18.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + cloud.google.com/go/longrunning v0.6.7 // indirect + cloud.google.com/go/monitoring v1.24.2 // indirect + cloud.google.com/go/storage v1.53.0 // indirect + firebase.google.com/go/v4 v4.19.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect + github.com/MicahParks/keyfunc v1.9.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/tkrajina/go-reflector v0.5.5 // indirect + github.com/zeebo/errs v1.4.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.35.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/time v0.11.0 // indirect + google.golang.org/api v0.231.0 // indirect + google.golang.org/appengine/v2 v2.0.6 // indirect + google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 // indirect + google.golang.org/grpc v1.72.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect ) require ( @@ -30,5 +81,5 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/nelkinda/http-go v0.0.1 // indirect github.com/tkrajina/typescriptify-golang-structs v0.2.0 - golang.org/x/sys v0.5.0 // indirect + golang.org/x/sys v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 2e2a4ca..a145568 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,45 @@ +cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg= +cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg= +cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q= +cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= +cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s= +cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= +cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM= +cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U= +cloud.google.com/go/storage v1.53.0 h1:gg0ERZwL17pJ+Cz3cD2qS60w1WMDnwcm5YPAIQBHUAw= +cloud.google.com/go/storage v1.53.0/go.mod h1:7/eO2a/srr9ImZW9k5uufcNahT2+fPb8w5it1i5boaA= +firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= +firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= +github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/antchfx/xmlquery v1.2.4/go.mod h1:KQQuESaxSlqugE2ZBcM/qn+ebIpt+d+4Xx7YcSGAIrM= github.com/antchfx/xpath v1.1.6/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk= github.com/aslakhellesoy/gox v1.0.100/go.mod h1:AJl542QsKKG96COVsv0N74HHzVQgDIQPceVUh1aeU2M= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/christianhujer/assert v0.0.2 h1:j+nZAzx9h4su7L8hw0NGdd93J1BtjwnTyp8jd4wiRXs= github.com/christianhujer/assert v0.0.2/go.mod h1:yszWvVhUvkosrPxaPy9FqnC6XH16zFoFs8+hPXKs4ZQ= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cucumber/gherkin-go/v11 v11.0.0/go.mod h1:CX33k2XU2qog4e+TFjOValoq6mIUq0DmVccZs238R9w= github.com/cucumber/godog v0.9.0/go.mod h1:roWCHkpeK6UTOyIRRl7IR+fgfBeZ4vZR7OSq2J/NbM4= @@ -16,10 +50,21 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= @@ -51,11 +96,26 @@ github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/V github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -107,6 +167,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -119,6 +181,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -137,7 +201,28 @@ github.com/victorspringer/http-cache v0.0.0-20240523143319-7d9f48f8ab91 h1:b5+Iz github.com/victorspringer/http-cache v0.0.0-20240523143319-7d9f48f8ab91/go.mod h1:D1AD6nlXv7HkIfTVd8ZWK1KQEiXYNy/LbLkx8H9tIQw= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.mongodb.org/mongo-driver v1.3.4/go.mod h1:MSWZXKOynuguX+JSvwP8i+58jYCXxbia8HS3gZBapIE= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -146,15 +231,29 @@ golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -163,18 +262,53 @@ golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.231.0 h1:LbUD5FUl0C4qwia2bjXhCMH65yz1MLPzA/0OYEsYY7Q= +google.golang.org/api v0.231.0/go.mod h1:H52180fPI/QQlUc0F4xWfGZILdv09GCWKt2bcsn164A= +google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= +google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= +google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0= +google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 h1:IqsN8hx+lWLqlN+Sc3DoMy/watjofWiU8sRFgQ8fhKM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= +google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index 99ca4b4..6d43884 100644 --- a/main.go +++ b/main.go @@ -440,6 +440,10 @@ func handleRequests() http.Handler { // Easter Controls apiRouter.HandleFunc("/easter/egg/collude/", controller.CollusionButton).Methods("POST") + // Firebase test endpoints + // apiRouter.HandleFunc("/firebase/test/notification/", controller.TestNotificationToTuscan).Methods("GET") + // apiRouter.HandleFunc("/firebase/test/forum/", controller.TestForumPost).Methods("GET") + // Websocket myRouter.HandleFunc("/ws", ws.WebSocketHandler) diff --git a/managers/AdminManager.go b/managers/AdminManager.go index efe5396..c7fe46b 100644 --- a/managers/AdminManager.go +++ b/managers/AdminManager.go @@ -1,11 +1,13 @@ package managers import ( + "context" "fmt" "log" "strconv" "github.com/CalebRose/SimFBA/dbprovider" + fbsvc "github.com/CalebRose/SimFBA/firebase" "github.com/CalebRose/SimFBA/repository" "github.com/CalebRose/SimFBA/structs" "github.com/CalebRose/SimFBA/util" @@ -82,6 +84,9 @@ func SyncTimeslot(timeslot string) { } if isCFB { + sentANotification := make(map[uint]bool) + collegeTeams := GetAllCollegeTeams() + collegeTeamMap := MakeCollegeTeamMap(collegeTeams) // Get Games gameIDs := []string{} games := GetCollegeGamesByTimeslotAndWeekId(strconv.Itoa(ts.CollegeWeekID), timeslot, ts.CFBSpringGames) @@ -139,6 +144,31 @@ func SyncTimeslot(timeslot string) { continue } if h.WasInjured { + team := collegeTeamMap[h.TeamID] + if !sentANotification[h.TeamID] && team.Coach != "AI" && team.Coach != "" { + // Send a notification! + ctx := context.Background() + uids := fbsvc.ResolveUIDsByUsernames(ctx, []string{team.Coach}) + if len(uids) > 0 { + playerRecord := GetCollegePlayerByCollegePlayerId(strconv.Itoa(h.CollegePlayerID)) + eventKey := fbsvc.BuildSourceEventKey("injury", "cfb", gameID, strconv.Itoa(h.CollegePlayerID)) + _ = fbsvc.NotifyTeamInjury(ctx, fbsvc.TeamInjuryNotificationInput{ + League: "cfb", + Domain: fbsvc.DomainCFB, + TeamID: h.TeamID, + TeamName: team.TeamName, + PlayerID: uint(h.CollegePlayerID), + PlayerName: playerRecord.FirstName + " " + playerRecord.LastName, + Position: playerRecord.Position, + InjuryType: h.InjuryType, + WeeksOfRecovery: h.WeeksOfRecovery, + GameID: gameID, + RecipientUIDs: uids, + SourceEventKey: eventKey, + }) + } + sentANotification[h.TeamID] = true + } playerRecord := GetCollegePlayerByCollegePlayerId(strconv.Itoa(h.CollegePlayerID)) playerRecord.SetIsInjured(h.WasInjured, h.InjuryType, h.WeeksOfRecovery) repository.SaveCFBPlayer(playerRecord, db) @@ -165,6 +195,31 @@ func SyncTimeslot(timeslot string) { continue } if a.WasInjured { + team := collegeTeamMap[a.TeamID] + if !sentANotification[a.TeamID] && team.Coach != "AI" && team.Coach != "" { + // Send a notification! + ctx := context.Background() + uids := fbsvc.ResolveUIDsByUsernames(ctx, []string{team.Coach}) + if len(uids) > 0 { + playerRecord := GetCollegePlayerByCollegePlayerId(strconv.Itoa(a.CollegePlayerID)) + eventKey := fbsvc.BuildSourceEventKey("injury", "cfb", gameID, strconv.Itoa(a.CollegePlayerID)) + _ = fbsvc.NotifyTeamInjury(ctx, fbsvc.TeamInjuryNotificationInput{ + League: "cfb", + Domain: fbsvc.DomainCFB, + TeamID: a.TeamID, + TeamName: team.TeamName, + PlayerID: uint(a.CollegePlayerID), + PlayerName: playerRecord.FirstName + " " + playerRecord.LastName, + Position: playerRecord.Position, + InjuryType: a.InjuryType, + WeeksOfRecovery: a.WeeksOfRecovery, + GameID: gameID, + RecipientUIDs: uids, + SourceEventKey: eventKey, + }) + } + sentANotification[a.TeamID] = true + } playerRecord := GetCollegePlayerByCollegePlayerId(strconv.Itoa(a.CollegePlayerID)) playerRecord.SetIsInjured(a.WasInjured, a.InjuryType, a.WeeksOfRecovery) repository.SaveCFBPlayer(playerRecord, db) @@ -201,6 +256,9 @@ func SyncTimeslot(timeslot string) { repository.SaveCFBSeasonSnaps(seasonSnaps, db) } + // Create postgame discussion thread in Firestore (non-blocking) + go CreatePostGameDiscussionThreadForCFBGame(game, homeTeamStats, awayTeamStats) + // Update Standings homeTeamStandings := GetCFBStandingsByTeamIDAndSeasonID(strconv.Itoa(homeTeamID), strconv.Itoa(ts.CollegeSeasonID)) awayTeamStandings := GetCFBStandingsByTeamIDAndSeasonID(strconv.Itoa(awayTeamID), strconv.Itoa(ts.CollegeSeasonID)) @@ -335,6 +393,9 @@ func SyncTimeslot(timeslot string) { db.Model(&structs.CollegeTeamStats{}).Where("game_id in (?)", gameIDs).Update("reveal_results", true) } else { // Get Games + nflTeams := GetAllNFLTeams() + nflTeamMap := MakeNFLTeamMap(nflTeams) + sentANotification := make(map[uint]bool) games := GetNFLGamesByTimeslotAndWeekId(strconv.Itoa(ts.NFLWeekID), timeslot, ts.NFLPreseason) // seasonStatsMap := make(map[int]structs.NFLTeamSeasonStats) @@ -371,6 +432,36 @@ func SyncTimeslot(timeslot string) { continue } if h.WasInjured { + team := nflTeamMap[h.TeamID] + if !sentANotification[h.TeamID] && team.NFLOwnerName != "AI" && team.NFLOwnerName != "" { + // Send a notification! + ctx := context.Background() + var usernames []string + usernames = append(usernames, team.NFLOwnerName) + if team.NFLGMName != "" && team.NFLGMName != "AI" { + usernames = append(usernames, team.NFLGMName) + } + uids := fbsvc.ResolveUIDsByUsernames(ctx, usernames) + if len(uids) > 0 { + playerRecord := GetNFLPlayerRecord(strconv.Itoa(h.NFLPlayerID)) + eventKey := fbsvc.BuildSourceEventKey("injury", "nfl", gameID, strconv.Itoa(h.NFLPlayerID)) + _ = fbsvc.NotifyTeamInjury(ctx, fbsvc.TeamInjuryNotificationInput{ + League: "nfl", + Domain: fbsvc.DomainNFL, + TeamID: h.TeamID, + TeamName: team.TeamName, + PlayerID: uint(h.NFLPlayerID), + PlayerName: playerRecord.FirstName + " " + playerRecord.LastName, + Position: playerRecord.Position, + InjuryType: h.InjuryType, + WeeksOfRecovery: h.WeeksOfRecovery, + GameID: gameID, + RecipientUIDs: uids, + SourceEventKey: eventKey, + }) + } + sentANotification[h.TeamID] = true + } playerRecord := GetNFLPlayerRecord(strconv.Itoa(h.NFLPlayerID)) playerRecord.SetIsInjured(h.WasInjured, h.InjuryType, h.WeeksOfRecovery) repository.SaveNFLPlayer(playerRecord, db) @@ -397,6 +488,37 @@ func SyncTimeslot(timeslot string) { continue } if a.WasInjured { + team := nflTeamMap[a.TeamID] + if !sentANotification[a.TeamID] && team.NFLOwnerName != "AI" && team.NFLOwnerName != "" { + // Send a notification! + // String "It looks like a player on your team got injured this week! Check your team page for more details." + ctx := context.Background() + var usernames []string + usernames = append(usernames, team.NFLOwnerName) + if team.NFLGMName != "" && team.NFLGMName != "AI" { + usernames = append(usernames, team.NFLGMName) + } + uids := fbsvc.ResolveUIDsByUsernames(ctx, usernames) + if len(uids) > 0 { + playerRecord := GetNFLPlayerRecord(strconv.Itoa(a.NFLPlayerID)) + eventKey := fbsvc.BuildSourceEventKey("injury", "nfl", gameID, strconv.Itoa(a.NFLPlayerID)) + _ = fbsvc.NotifyTeamInjury(ctx, fbsvc.TeamInjuryNotificationInput{ + League: "nfl", + Domain: fbsvc.DomainNFL, + TeamID: a.TeamID, + TeamName: team.TeamName, + PlayerID: uint(a.NFLPlayerID), + PlayerName: playerRecord.FirstName + " " + playerRecord.LastName, + Position: playerRecord.Position, + InjuryType: a.InjuryType, + WeeksOfRecovery: a.WeeksOfRecovery, + GameID: gameID, + RecipientUIDs: uids, + SourceEventKey: eventKey, + }) + } + sentANotification[a.TeamID] = true + } playerRecord := GetNFLPlayerRecord(strconv.Itoa(a.NFLPlayerID)) playerRecord.SetIsInjured(a.WasInjured, a.InjuryType, a.WeeksOfRecovery) repository.SaveNFLPlayer(playerRecord, db) @@ -435,6 +557,9 @@ func SyncTimeslot(timeslot string) { repository.SaveNFLSeasonSnaps(seasonSnaps, db) } + // Create postgame discussion thread in Firestore (non-blocking) + go CreatePostGameDiscussionThreadForNFLGame(game, homeTeamStats, awayTeamStats) + // Update Standings homeTeamStandings := GetNFLStandingsByTeamIDAndSeasonID(strconv.Itoa(homeTeamID), strconv.Itoa(ts.NFLSeasonID)) awayTeamStandings := GetNFLStandingsByTeamIDAndSeasonID(strconv.Itoa(awayTeamID), strconv.Itoa(ts.NFLSeasonID)) diff --git a/managers/ForumManager.go b/managers/ForumManager.go index ae11899..8a42308 100644 --- a/managers/ForumManager.go +++ b/managers/ForumManager.go @@ -1,15 +1,594 @@ package managers -import "github.com/CalebRose/SimFBA/structs" +import ( + "context" + "fmt" + "log" + "strconv" + "strings" + + fbsvc "github.com/CalebRose/SimFBA/firebase" + "github.com/CalebRose/SimFBA/structs" +) // ForumManager handles operations related to the forum system within the application. -// Will create threads & posts to facilitate post-game discussions for teams -// Through firebase. +// Creates threads & posts in Firebase Firestore to facilitate post-game discussions. + +// PostGameForumID is the Firestore document ID of the forum category used for +// post-game discussion threads. Override by changing this constant once the +// forum is set up in Firestore. +const PostGameForumID = "postgame-discussions" + +// CreatePostGameDiscussionThreadForCFBGame creates a system-generated postgame +// discussion thread in Firestore for a completed college football game. +// homeTeamStats and awayTeamStats are the per-game box-score records already +// loaded inside SyncTimeslot. +// The operation is idempotent: calling it twice for the same game has no effect. +func CreatePostGameDiscussionThreadForCFBGame( + game structs.CollegeGame, + homeTeamStats structs.CollegeTeamStats, + awayTeamStats structs.CollegeTeamStats, +) { + ctx := context.Background() + + gameID := strconv.Itoa(int(game.ID)) + eventKey := fmt.Sprintf("postgame_thread:cfb:season%d:game%s", game.SeasonID, gameID) + + title := buildPostGameThreadTitle(game.AwayTeam, game.HomeTeam, game.GameTitle) + paragraphs := buildCFBPostGameParagraphs(game, homeTeamStats, awayTeamStats) + bodyText := strings.Join(paragraphs, "\n\n") + richBody := buildRichPostBody(paragraphs) + + input := fbsvc.CreateForumThreadInput{ + ForumID: PostGameForumID + "-simcfb", + ForumPath: []string{PostGameForumID, "simcfb"}, + Title: title, + AuthorUID: "system", + AuthorUsername: "SimSN", + AuthorDisplayName: "SimSN System", + CreatedByType: fbsvc.CreatedBySystem, + ThreadType: fbsvc.ThreadTypeGameReference, + FirstPostBodyText: bodyText, + FirstPostBody: richBody, + ReferencedGameID: gameID, + ReferencedLeague: "cfb", + ExternalEventKey: eventKey, + } + + thread, err := fbsvc.CreateThread(ctx, input) + if err != nil { + log.Printf("ForumManager: failed to create CFB postgame thread for game %s: %v", gameID, err) + return + } + + log.Printf("ForumManager: created CFB postgame thread %s for game %s (%s)", thread.ID, gameID, title) +} + +// CreatePostGameDiscussionThreadForNFLGame creates a system-generated postgame +// discussion thread in Firestore for a completed NFL game. +// homeTeamStats and awayTeamStats are the per-game box-score records already +// loaded inside SyncTimeslot. +// The operation is idempotent: calling it twice for the same game has no effect. +func CreatePostGameDiscussionThreadForNFLGame( + game structs.NFLGame, + homeTeamStats structs.NFLTeamStats, + awayTeamStats structs.NFLTeamStats, +) { + ctx := context.Background() + + gameID := strconv.Itoa(int(game.ID)) + eventKey := fmt.Sprintf("postgame_thread:nfl:season%d:game%s", game.SeasonID, gameID) + + title := buildPostGameThreadTitle(game.AwayTeam, game.HomeTeam, game.GameTitle) + paragraphs := buildNFLPostGameParagraphs(game, homeTeamStats, awayTeamStats) + bodyText := strings.Join(paragraphs, "\n\n") + richBody := buildRichPostBody(paragraphs) + + input := fbsvc.CreateForumThreadInput{ + ForumID: PostGameForumID + "-simnfl", + ForumPath: []string{PostGameForumID, "simnfl"}, + Title: title, + AuthorUID: "system", + AuthorUsername: "SimSN", + AuthorDisplayName: "SimSN System", + CreatedByType: fbsvc.CreatedBySystem, + ThreadType: fbsvc.ThreadTypeGameReference, + FirstPostBodyText: bodyText, + FirstPostBody: richBody, + ReferencedGameID: gameID, + ReferencedLeague: "nfl", + ExternalEventKey: eventKey, + } + + thread, err := fbsvc.CreateThread(ctx, input) + if err != nil { + log.Printf("ForumManager: failed to create NFL postgame thread for game %s: %v", gameID, err) + return + } + + log.Printf("ForumManager: created NFL postgame thread %s for game %s (%s)", thread.ID, gameID, title) +} + +// ───────────────────────────────────────────── +// Body builders +// ───────────────────────────────────────────── + +func buildCFBPostGameParagraphs( + game structs.CollegeGame, + home structs.CollegeTeamStats, + away structs.CollegeTeamStats, +) []string { + return buildPostGameParagraphs( + game.AwayTeam, game.HomeTeam, + game.AwayTeamScore, game.HomeTeamScore, + game.Stadium, game.City, game.State, + game.GameTemp, game.WindSpeed, game.WindCategory, game.Precip, + game.IsDomed, + game.MVP, + away.BaseTeamStats, home.BaseTeamStats, + ) +} + +func buildNFLPostGameParagraphs( + game structs.NFLGame, + home structs.NFLTeamStats, + away structs.NFLTeamStats, +) []string { + return buildPostGameParagraphs( + game.AwayTeam, game.HomeTeam, + game.AwayTeamScore, game.HomeTeamScore, + game.Stadium, game.City, game.State, + game.GameTemp, game.WindSpeed, game.WindCategory, game.Precip, + game.IsDomed, + game.MVP, + away.BaseTeamStats, home.BaseTeamStats, + ) +} + +// buildPostGameParagraphs constructs the ordered list of paragraph strings +// shared by both CFB and NFL forum post bodies. +func buildPostGameParagraphs( + awayTeam, homeTeam string, + awayScore, homeScore int, + stadium, city, state string, + gameTemp, windSpeed float64, windCategory, precip string, + isDomed bool, + mvp string, + away, home structs.BaseTeamStats, +) []string { + paras := []string{} + + // ── Final score ────────────────────────────────────────────────────────── + paras = append(paras, fmt.Sprintf( + "FINAL: %s %d, %s %d", + awayTeam, awayScore, homeTeam, homeScore, + )) + + // ── Quarter-by-quarter scoring ─────────────────────────────────────────── + awayQs := formatQuarters(awayTeam, away) + homeQs := formatQuarters(homeTeam, home) + paras = append(paras, "SCORING BY QUARTER:\n"+awayQs+"\n"+homeQs) + + // ── Offensive stats ────────────────────────────────────────────────────── + offLines := []string{ + "OFFENSE:", + fmt.Sprintf(" %-20s PASS: %4d yds %dTD %d INT | RUSH: %4d yds %dTD", + awayTeam, + away.PassingYards, away.PassingTouchdowns, away.PassingInterceptions, + away.RushingYards, away.RushingTouchdowns, + ), + fmt.Sprintf(" %-20s PASS: %4d yds %dTD %d INT | RUSH: %4d yds %dTD", + homeTeam, + home.PassingYards, home.PassingTouchdowns, home.PassingInterceptions, + home.RushingYards, home.RushingTouchdowns, + ), + } + paras = append(paras, strings.Join(offLines, "\n")) + + // ── Defensive / turnover stats ─────────────────────────────────────────── + defLines := []string{ + "DEFENSE & TURNOVERS:", + fmt.Sprintf(" %-20s SACKS: %.0f INTs: %d TFL: %.0f FORCED FMBL: %d", + awayTeam, + away.DefensiveSacks, away.DefensiveInterceptions, away.TacklesForLoss, away.ForcedFumbles, + ), + fmt.Sprintf(" %-20s SACKS: %.0f INTs: %d TFL: %.0f FORCED FMBL: %d", + homeTeam, + home.DefensiveSacks, home.DefensiveInterceptions, home.TacklesForLoss, home.ForcedFumbles, + ), + } + paras = append(paras, strings.Join(defLines, "\n")) + + // ── Special teams ──────────────────────────────────────────────────────── + if away.FieldGoalsAttempted > 0 || home.FieldGoalsAttempted > 0 { + stLines := []string{ + "SPECIAL TEAMS:", + fmt.Sprintf(" %-20s FG: %d/%d (long %d) XP: %d/%d", + awayTeam, + away.FieldGoalsMade, away.FieldGoalsAttempted, away.LongestFieldGoal, + away.ExtraPointsMade, away.ExtraPointsAttempted, + ), + fmt.Sprintf(" %-20s FG: %d/%d (long %d) XP: %d/%d", + homeTeam, + home.FieldGoalsMade, home.FieldGoalsAttempted, home.LongestFieldGoal, + home.ExtraPointsMade, home.ExtraPointsAttempted, + ), + } + paras = append(paras, strings.Join(stLines, "\n")) + } + + // ── Game info ──────────────────────────────────────────────────────────── + paras = append(paras, fmt.Sprintf("VENUE: %s — %s, %s", stadium, city, state)) + + if !isDomed { + weatherLine := fmt.Sprintf("WEATHER: %.0f°F", gameTemp) + if windSpeed > 0 { + weatherLine += fmt.Sprintf(" | Wind: %.0f mph (%s)", windSpeed, windCategory) + } + if precip != "" && precip != "None" && precip != "Clear" { + weatherLine += fmt.Sprintf(" | %s", precip) + } + paras = append(paras, weatherLine) + } + + // ── MVP ────────────────────────────────────────────────────────────────── + if mvp != "" { + paras = append(paras, fmt.Sprintf("MVP: %s", mvp)) + } + + // ── Discussion prompt ──────────────────────────────────────────────────── + paras = append(paras, "Postgame discussion is open. Share your reactions below.") + + return paras +} + +// formatQuarters returns a single line showing per-quarter scoring for a team. +func formatQuarters(team string, s structs.BaseTeamStats) string { + line := fmt.Sprintf(" %-20s Q1: %2d Q2: %2d Q3: %2d Q4: %2d", + team, s.Score1Q, s.Score2Q, s.Score3Q, s.Score4Q) + if s.ScoreOT > 0 { + line += fmt.Sprintf(" OT: %2d", s.ScoreOT) + } + line += fmt.Sprintf(" TOTAL: %2d", s.Score1Q+s.Score2Q+s.Score3Q+s.Score4Q+s.ScoreOT) + return line +} + +// ───────────────────────────────────────────── +// Rich text helpers +// ───────────────────────────────────────────── + +// buildRichPostBody converts a slice of paragraph strings into a ProseMirror +// document compatible with the frontend's RichTextDocument interface. +func buildRichPostBody(paragraphs []string) map[string]interface{} { + content := make([]map[string]interface{}, 0, len(paragraphs)) + for _, p := range paragraphs { + content = append(content, map[string]interface{}{ + "type": "paragraph", + "content": []map[string]interface{}{ + {"type": "text", "text": p}, + }, + }) + } + return map[string]interface{}{ + "type": "doc", + "content": content, + } +} + +// ───────────────────────────────────────────── +// Shared title helper +// ───────────────────────────────────────────── + +func buildPostGameThreadTitle(awayTeam, homeTeam, gameTitle string) string { + if gameTitle != "" { + return fmt.Sprintf("Postgame Thread: %s", gameTitle) + } + return fmt.Sprintf("Postgame Thread: %s at %s", awayTeam, homeTeam) +} + +// ───────────────────────────────────────────── +// Transfer portal helpers +// ───────────────────────────────────────────── + +// TransferIntentionsSummary bundles all the counters produced by the transfer +// intentions run so they can be passed to the forum-thread creator without a +// long argument list. +type TransferIntentionsSummary struct { + Season int + TransferCount int + FreshmanCount int + RedshirtFreshmanCount int + SophomoreCount int + RedshirtSophomoreCount int + JuniorCount int + RedshirtJuniorCount int + SeniorCount int + RedshirtSeniorCount int + LowCount int + MediumCount int + HighCount int +} + +// CreateTransferIntentionsForumThread creates a system-generated forum thread in +// the "daily" forum summarising the transfer intentions run for the given season. +// The operation is idempotent: calling it twice for the same season has no effect. +func CreateTransferIntentionsForumThread(summary TransferIntentionsSummary) { + ctx := context.Background() + + title := fmt.Sprintf("SimCFB: Season %d Transfer Intentions", summary.Season) + eventKey := fmt.Sprintf("transfer_intentions_thread:cfb:season%d", summary.Season) -func CreatePostGameDiscussionThreadForCFBGame(game structs.CollegeGame) { + paragraphs := buildTransferIntentionsParagraphs(summary) + bodyText := strings.Join(paragraphs, "\n\n") + richBody := buildRichPostBody(paragraphs) + input := fbsvc.CreateForumThreadInput{ + ForumID: "media-simcfb", + ForumPath: []string{"media", "simcfb"}, + Title: title, + AuthorUID: "system", + AuthorUsername: "SimSN", + AuthorDisplayName: "SimSN System", + CreatedByType: fbsvc.CreatedBySystem, + ThreadType: fbsvc.ThreadTypeStandard, + FirstPostBodyText: bodyText, + FirstPostBody: richBody, + ReferencedLeague: "cfb", + ExternalEventKey: eventKey, + } + + thread, err := fbsvc.CreateThread(ctx, input) + if err != nil { + log.Printf("ForumManager: failed to create transfer intentions thread for season %d: %v", summary.Season, err) + return + } + + log.Printf("ForumManager: created transfer intentions thread %s for season %d", thread.ID, summary.Season) +} + +func buildTransferIntentionsParagraphs(s TransferIntentionsSummary) []string { + var paragraphs []string + + paragraphs = append(paragraphs, + fmt.Sprintf( + "Transfer season is underway for Season %d. A total of %d players have announced their intention to enter the transfer portal. Teams have one week to submit promises to retain their players.", + s.Season, s.TransferCount, + ), + ) + + // Year-by-year breakdown + paragraphs = append(paragraphs, + fmt.Sprintf( + "Class breakdown — Freshmen: %d | RS Freshmen: %d | Sophomores: %d | RS Sophomores: %d | Juniors: %d | RS Juniors: %d | Seniors: %d | RS Seniors: %d.", + s.FreshmanCount, s.RedshirtFreshmanCount, + s.SophomoreCount, s.RedshirtSophomoreCount, + s.JuniorCount, s.RedshirtJuniorCount, + s.SeniorCount, s.RedshirtSeniorCount, + ), + ) + + // Likeliness breakdown + paragraphs = append(paragraphs, + fmt.Sprintf( + "Transfer likeliness — Low: %d | Medium: %d | High: %d.", + s.LowCount, s.MediumCount, s.HighCount, + ), + ) + + paragraphs = append(paragraphs, + "Which transfers are you keeping an eye on this season? Share your thoughts below!", + ) + + return paragraphs +} + +// ───────────────────────────────────────────── +// Transfer portal sync thread +// ───────────────────────────────────────────── + +// CreateTransferPortalSyncForumThread creates a system-generated forum thread in +// the "media-cfb" subforum summarising the signings from a single transfer portal +// sync round. signings is a list of human-readable player labels for every player +// that signed with a new team during the sync. +// The operation is idempotent: calling it twice for the same season/round has no +// effect. +func CreateTransferPortalSyncForumThread(season, round int, signings []string) { + ctx := context.Background() + + title := fmt.Sprintf("SimCFB: Season %d Transfer Portal — Round %d Results", season, round) + eventKey := fmt.Sprintf("transfer_portal_sync:cfb:season%d:round%d", season, round) + + paragraphs := buildTransferPortalSyncParagraphs(season, round, signings) + bodyText := strings.Join(paragraphs, "\n\n") + richBody := buildRichPostBody(paragraphs) + + input := fbsvc.CreateForumThreadInput{ + ForumID: "media-simcfb", + ForumPath: []string{"media", "simcfb"}, + Title: title, + AuthorUID: "system", + AuthorUsername: "SimSN", + AuthorDisplayName: "SimSN System", + CreatedByType: fbsvc.CreatedBySystem, + ThreadType: fbsvc.ThreadTypeStandard, + FirstPostBodyText: bodyText, + FirstPostBody: richBody, + ReferencedLeague: "cfb", + ExternalEventKey: eventKey, + } + + thread, err := fbsvc.CreateThread(ctx, input) + if err != nil { + log.Printf("ForumManager: failed to create transfer portal sync thread for season %d round %d: %v", season, round, err) + return + } + + log.Printf("ForumManager: created transfer portal sync thread %s for season %d round %d", thread.ID, season, round) +} + +func buildTransferPortalSyncParagraphs(season, round int, signings []string) []string { + var paragraphs []string + + count := len(signings) + if count == 0 { + paragraphs = append(paragraphs, + fmt.Sprintf( + "Transfer Portal Round %d is complete for Season %d. No players signed with new programs this round.", + round, season, + ), + ) + } else { + paragraphs = append(paragraphs, + fmt.Sprintf( + "Transfer Portal Round %d results are in for Season %d. A total of %d player(s) have signed with new programs this round.", + round, season, count, + ), + ) + for _, label := range signings { + paragraphs = append(paragraphs, label) + } + } + + paragraphs = append(paragraphs, "Discuss the latest transfer portal news below!") + + return paragraphs +} + +// ───────────────────────────────────────────── +// Transfer portal open thread +// ───────────────────────────────────────────── + +// CreateTransferPortalOpenForumThread creates a system-generated forum thread in +// the "media-simcfb" subforum announcing the transfer portal is open for the +// given season, with one paragraph per player entering the portal. +// playerLabels is a list of human-readable labels built before WillTransfer() +// clears each player’s TeamAbbr. +// The operation is idempotent: calling it twice for the same season has no effect. +func CreateTransferPortalOpenForumThread(season int, playerLabels []string) { + ctx := context.Background() + + title := fmt.Sprintf("SimCFB: Season %d Transfer Portal is Now Open", season) + eventKey := fmt.Sprintf("transfer_portal_open:cfb:season%d", season) + + paragraphs := buildTransferPortalOpenParagraphs(season, playerLabels) + bodyText := strings.Join(paragraphs, "\n\n") + richBody := buildRichPostBody(paragraphs) + + input := fbsvc.CreateForumThreadInput{ + ForumID: "media-simcfb", + ForumPath: []string{"media", "simcfb"}, + Title: title, + AuthorUID: "system", + AuthorUsername: "SimSN", + AuthorDisplayName: "SimSN System", + CreatedByType: fbsvc.CreatedBySystem, + ThreadType: fbsvc.ThreadTypeStandard, + FirstPostBodyText: bodyText, + FirstPostBody: richBody, + ReferencedLeague: "cfb", + ExternalEventKey: eventKey, + } + + thread, err := fbsvc.CreateThread(ctx, input) + if err != nil { + log.Printf("ForumManager: failed to create transfer portal open thread for season %d: %v", season, err) + return + } + + log.Printf("ForumManager: created transfer portal open thread %s for season %d", thread.ID, season) } -func CreatePostGameDiscussionThreadForNFLGame(game structs.NFLGame) { +func buildTransferPortalOpenParagraphs(season int, playerLabels []string) []string { + var paragraphs []string + + count := len(playerLabels) + paragraphs = append(paragraphs, + fmt.Sprintf( + "The SimCFB Transfer Portal is now open for Season %d. A total of %d player(s) have officially entered the portal and are seeking a new home.", + season, count, + ), + ) + + if count > 0 { + paragraphs = append(paragraphs, "The following players have entered the transfer portal:") + for _, label := range playerLabels { + paragraphs = append(paragraphs, label) + } + } + + paragraphs = append(paragraphs, "Which players are you targeting this transfer portal cycle? Share your thoughts below!") + + return paragraphs +} + +// ───────────────────────────────────────────── +// Recruiting sync thread +// ───────────────────────────────────────────── + +// CreateRecruitingSyncForumThread creates a system-generated weekly thread in +// the "media-simcfb" subforum listing every recruit that signed with a program +// during the sync. signings is a list of human-readable labels built at the +// moment each recruit commits (before any state is mutated further). +// The operation is idempotent: calling it twice for the same season/week has no +// effect. +func CreateRecruitingSyncForumThread(season, week int, signings []string) { + ctx := context.Background() + + title := fmt.Sprintf("SimCFB: Season %d Week %d Recruiting Commitments", season, week) + eventKey := fmt.Sprintf("recruiting_sync:cfb:season%d:week%d", season, week) + + paragraphs := buildRecruitingSyncParagraphs(season, week, signings) + bodyText := strings.Join(paragraphs, "\n\n") + richBody := buildRichPostBody(paragraphs) + + input := fbsvc.CreateForumThreadInput{ + ForumID: "media-simcfb", + ForumPath: []string{"media", "simcfb"}, + Title: title, + AuthorUID: "system", + AuthorUsername: "SimSN", + AuthorDisplayName: "SimSN System", + CreatedByType: fbsvc.CreatedBySystem, + ThreadType: fbsvc.ThreadTypeStandard, + FirstPostBodyText: bodyText, + FirstPostBody: richBody, + ReferencedLeague: "cfb", + ExternalEventKey: eventKey, + } + + thread, err := fbsvc.CreateThread(ctx, input) + if err != nil { + log.Printf("ForumManager: failed to create recruiting sync thread for season %d week %d: %v", season, week, err) + return + } + + log.Printf("ForumManager: created recruiting sync thread %s for season %d week %d", thread.ID, season, week) +} + +func buildRecruitingSyncParagraphs(season, week int, signings []string) []string { + var paragraphs []string + + count := len(signings) + if count == 0 { + paragraphs = append(paragraphs, + fmt.Sprintf( + "Week %d recruiting is complete for Season %d. No recruits signed with a program this week.", + week, season, + ), + ) + } else { + paragraphs = append(paragraphs, + fmt.Sprintf( + "Week %d recruiting results are in for Season %d. A total of %d recruit(s) have committed to a program this week.", + week, season, count, + ), + ) + for _, label := range signings { + paragraphs = append(paragraphs, label) + } + } + + paragraphs = append(paragraphs, "React to the latest commitments and discuss your team\u2019s recruiting class below!") + return paragraphs } diff --git a/managers/FreeAgencyManager.go b/managers/FreeAgencyManager.go index 1eb8854..94f084c 100644 --- a/managers/FreeAgencyManager.go +++ b/managers/FreeAgencyManager.go @@ -1,6 +1,7 @@ package managers import ( + "context" "errors" "fmt" "log" @@ -10,6 +11,7 @@ import ( "strings" "sync" + fbsvc "github.com/CalebRose/SimFBA/firebase" "github.com/CalebRose/SimFBA/dbprovider" "github.com/CalebRose/SimFBA/models" "github.com/CalebRose/SimFBA/repository" @@ -409,9 +411,32 @@ func CreateFAOffer(offer structs.FreeAgencyOfferDTO) structs.FreeAgencyOffer { } if player.IsPracticeSquad && player.TeamID != int(offer.TeamID) { - // Notify team - notificationMessage := offer.Team + " have placed an offer on " + player.Position + " " + player.FirstName + " " + player.LastName + " to pick up from the practice squad." - CreateNotification("NFL", notificationMessage, "Practice Squad Offer", uint(player.TeamID)) + nflTeam := GetNFLTeamByTeamID(strconv.Itoa(player.TeamID)) + ctx := context.Background() + var usernames []string + if nflTeam.NFLOwnerName != "" && nflTeam.NFLOwnerName != "AI" { + usernames = append(usernames, nflTeam.NFLOwnerName) + } + if nflTeam.NFLGMName != "" && nflTeam.NFLGMName != "AI" { + usernames = append(usernames, nflTeam.NFLGMName) + } + if len(usernames) > 0 { + uids := fbsvc.ResolveUIDsByUsernames(ctx, usernames) + if len(uids) > 0 { + eventKey := fbsvc.BuildSourceEventKey("practice_squad_offer", "nfl", strconv.Itoa(int(player.ID)), strconv.Itoa(int(offer.TeamID))) + _ = fbsvc.NotifyPracticeSquadOffer(ctx, fbsvc.PracticeSquadOfferNotificationInput{ + OwnerTeamID: uint(player.TeamID), + OwnerTeamName: nflTeam.TeamName, + OwnerTeamAbbr: nflTeam.TeamAbbr, + OfferingTeam: offer.Team, + PlayerID: uint(player.PlayerID), + PlayerName: player.FirstName + " " + player.LastName, + Position: player.Position, + RecipientUIDs: uids, + SourceEventKey: eventKey, + }) + } + } message := offer.Team + " have placed an offer on " + player.TeamAbbr + " " + player.Position + " " + player.FirstName + " " + player.LastName + " to pick up from the practice squad." CreateNewsLog("NFL", message, "Free Agency", player.TeamID, ts) } diff --git a/managers/GameplanManager.go b/managers/GameplanManager.go index 44af45c..b907b95 100644 --- a/managers/GameplanManager.go +++ b/managers/GameplanManager.go @@ -1,6 +1,7 @@ package managers import ( + "context" "fmt" "log" "sort" @@ -8,6 +9,7 @@ import ( "sync" "github.com/CalebRose/SimFBA/dbprovider" + fbsvc "github.com/CalebRose/SimFBA/firebase" "github.com/CalebRose/SimFBA/repository" "github.com/CalebRose/SimFBA/structs" "gorm.io/gorm" @@ -3308,9 +3310,25 @@ func FixBrokenGameplans() { repository.SaveRecruitingTeamProfile(rtp, db) team.MarkTeamForPenalty() repository.SaveCFBTeam(team, db) - // Notify team - message := rtp.TeamAbbreviation + " has lost a scholarship due to having an injured player (" + playerLabel + ") on their depthchart. This is penalty number " + strconv.Itoa(int(team.PenaltyMarks)) + "." - CreateNotification("CFB", message, "Invalid Depth Chart", t) + // Notify team coach via Firebase + if team.Coach != "" && team.Coach != "AI" { + message := rtp.TeamAbbreviation + " has lost a scholarship due to having an injured player (" + playerLabel + ") on their depthchart. This is penalty number " + strconv.Itoa(int(team.PenaltyMarks)) + "." + ctx := context.Background() + uids := fbsvc.ResolveUIDsByUsernames(ctx, []string{team.Coach}) + if len(uids) > 0 { + eventKey := fbsvc.BuildSourceEventKey("gameplan_penalty", "cfb", strconv.Itoa(int(t))) + _ = fbsvc.NotifyGameplanIssue(ctx, fbsvc.GameplanNotificationInput{ + League: "cfb", + Domain: fbsvc.DomainCFB, + TeamID: t, + TeamName: team.TeamName, + TeamAbbr: rtp.TeamAbbreviation, + Message: message, + RecipientUIDs: uids, + SourceEventKey: eventKey, + }) + } + } // Autosort Depth Chart ReAlignCollegeDepthChart(db, id, gp) } @@ -3339,13 +3357,28 @@ func FixBrokenGameplans() { if isBroken { n.MarkTeamForPenalty() - - // Notify team - message := n.TeamName + " has been marked for having an injured player (" + playerLabel + ") on their depthchart. This is penalty number " + strconv.Itoa(int(n.PenaltyMarks)) + "." - CreateNotification("NFL", message, "Invalid Depth Chart", n.ID) - repository.SaveNFLTeam(n, db) + // Notify team owner via Firebase + if n.NFLOwnerName != "" && n.NFLOwnerName != "AI" { + message := n.TeamName + " has been marked for having an injured player (" + playerLabel + ") on their depthchart. This is penalty number " + strconv.Itoa(int(n.PenaltyMarks)) + "." + ctx := context.Background() + uids := fbsvc.ResolveUIDsByUsernames(ctx, []string{n.NFLOwnerName}) + if len(uids) > 0 { + eventKey := fbsvc.BuildSourceEventKey("gameplan_penalty", "nfl", strconv.Itoa(int(n.ID))) + _ = fbsvc.NotifyGameplanIssue(ctx, fbsvc.GameplanNotificationInput{ + League: "nfl", + Domain: fbsvc.DomainNFL, + TeamID: n.ID, + TeamName: n.TeamName, + TeamAbbr: n.TeamAbbr, + Message: message, + RecipientUIDs: uids, + SourceEventKey: eventKey, + }) + } + } + // Autosort Depth Chart ReAlignNFLDepthChart(db, id, gp, players) } diff --git a/managers/MapHelper.go b/managers/MapHelper.go index b0470c8..21fa4f9 100644 --- a/managers/MapHelper.go +++ b/managers/MapHelper.go @@ -423,3 +423,23 @@ func MakeHistoricCollegePlayerMap(players []structs.HistoricCollegePlayer) map[u return profileMap } + +func MakeCollegeTeamMap(teams []structs.CollegeTeam) map[uint]structs.CollegeTeam { + profileMap := make(map[uint]structs.CollegeTeam) + + for _, rp := range teams { + profileMap[rp.ID] = rp + } + + return profileMap +} + +func MakeNFLTeamMap(teams []structs.NFLTeam) map[uint]structs.NFLTeam { + profileMap := make(map[uint]structs.NFLTeam) + + for _, rp := range teams { + profileMap[rp.ID] = rp + } + + return profileMap +} diff --git a/managers/SyncManager.go b/managers/SyncManager.go index f0164da..ac0d1c0 100644 --- a/managers/SyncManager.go +++ b/managers/SyncManager.go @@ -1,6 +1,7 @@ package managers import ( + "context" "fmt" "log" "math" @@ -11,6 +12,7 @@ import ( "time" "github.com/CalebRose/SimFBA/dbprovider" + fbsvc "github.com/CalebRose/SimFBA/firebase" "github.com/CalebRose/SimFBA/repository" "github.com/CalebRose/SimFBA/structs" "github.com/CalebRose/SimFBA/util" @@ -57,6 +59,7 @@ func SyncRecruiting(timestamp structs.Timestamp) { } var recruitProfiles []structs.RecruitPlayerProfile + var signingLabels []string // Get every recruit recruits := GetAllUnsignedRecruits() @@ -215,6 +218,7 @@ func SyncRecruiting(timestamp structs.Timestamp) { teamAbbreviation := recruitTeamProfile.TeamAbbreviation recruitTeamProfile.AddStarPlayer(recruit.Stars) recruit.AssignCollege(teamAbbreviation) + signingLabels = append(signingLabels, fmt.Sprintf("%d★ %s %s %s (%s, %s → %s)", recruit.Stars, recruit.Position, recruit.FirstName, recruit.LastName, recruit.City, recruit.State, teamAbbreviation)) newsLog := structs.NewsLog{ TeamID: winningTeamID, @@ -282,7 +286,7 @@ func SyncRecruiting(timestamp structs.Timestamp) { timestamp.ToggleLockRecruiting() repository.SaveTimestamp(timestamp, db) } - + go CreateRecruitingSyncForumThread(timestamp.Season, timestamp.CollegeWeek, signingLabels) } func SyncRecruitingEfficiency(timestamp structs.Timestamp) { @@ -924,7 +928,22 @@ func updateTeamRankings(teamRecruitingProfiles []structs.RecruitingTeamProfile, } else if rp.WeeksMissed >= 4 { notificationMessage += " Because you have missed more than 4 weeks, you will lose your team. Please reach out to Tuscan on how you can keep your team." } - CreateNotification("CFB", notificationMessage, "Recruiting Sync", rp.ID) + if rp.IsUserTeam && rp.Recruiter != "" && rp.Recruiter != "AI" { + ctx := context.Background() + uids := fbsvc.ResolveUIDsByUsernames(ctx, []string{rp.Recruiter}) + if len(uids) > 0 { + eventKey := fbsvc.BuildSourceEventKey("recruiting_sync_missed", "cfb", strconv.Itoa(int(rp.TeamID)), strconv.Itoa(rp.WeeksMissed+1)) + _ = fbsvc.NotifyRecruitingSyncMissed(ctx, fbsvc.RecruitingSyncMissedNotificationInput{ + TeamID: uint(rp.TeamID), + TeamName: rp.Team, + TeamAbbr: rp.TeamAbbreviation, + WeeksMissed: rp.WeeksMissed + 1, + Message: notificationMessage, + RecipientUIDs: uids, + SourceEventKey: eventKey, + }) + } + } } rp.ResetSpentPoints() diff --git a/managers/TransferPortalManager.go b/managers/TransferPortalManager.go index fa7e71a..01a8e97 100644 --- a/managers/TransferPortalManager.go +++ b/managers/TransferPortalManager.go @@ -1,6 +1,7 @@ package managers import ( + "context" "errors" "fmt" "log" @@ -12,6 +13,7 @@ import ( "github.com/CalebRose/SimFBA/constants" "github.com/CalebRose/SimFBA/dbprovider" + fbsvc "github.com/CalebRose/SimFBA/firebase" "github.com/CalebRose/SimFBA/models" "github.com/CalebRose/SimFBA/repository" "github.com/CalebRose/SimFBA/structs" @@ -326,8 +328,24 @@ func ProcessTransferIntention() { message := "Breaking News! " + strconv.Itoa(p.Stars) + " star " + p.Position + " " + p.FirstName + " " + p.LastName + " has announced their intention to transfer from " + p.TeamAbbr + "!" CreateNewsLog("CFB", message, "Transfer Portal", int(p.TeamID), ts) } - notificationMessage := strconv.Itoa(p.Stars) + " star " + p.Position + " " + p.FirstName + " " + p.LastName + " has a " + p.TransferLikeliness + " likeliness of entering the transfer portal. Please navigate to the Roster page to submit a promise." - CreateNotification("CFB", notificationMessage, "Transfer Intention", uint(p.TeamID)) + if teamProfile != nil && teamProfile.IsUserTeam && teamProfile.Recruiter != "" && teamProfile.Recruiter != "AI" { + ctx := context.Background() + uids := fbsvc.ResolveUIDsByUsernames(ctx, []string{teamProfile.Recruiter}) + if len(uids) > 0 { + eventKey := fbsvc.BuildSourceEventKey("transfer_intention", "cfb", strconv.Itoa(int(p.ID))) + _ = fbsvc.NotifyTransferIntention(ctx, fbsvc.TransferIntentionNotificationInput{ + TeamID: uint(p.TeamID), + TeamAbbr: p.TeamAbbr, + PlayerID: uint(p.PlayerID), + PlayerName: p.FirstName + " " + p.LastName, + Position: p.Position, + Stars: p.Stars, + TransferLikeliness: p.TransferLikeliness, + RecipientUIDs: uids, + SourceEventKey: eventKey, + }) + } + } // fmt.Println(strconv.Itoa(p.Year)+" YEAR "+p.TeamAbbr+" "+p.Position+" "+p.FirstName+" "+p.LastName+" HAS ANNOUNCED THEIR INTENTION TO TRANSFER | Weight: ", int(transferWeight)) // // db.Save(&p) // csvModel := structs.MapPlayerToCSVModel(p) @@ -354,6 +372,21 @@ func ProcessTransferIntention() { CreateNewsLog("CFB", transferPortalMessage, "Transfer Portal", 0, ts) ts.EnactPromisePhase() repository.SaveTimestamp(ts, db) + go CreateTransferIntentionsForumThread(TransferIntentionsSummary{ + Season: ts.Season, + TransferCount: transferCount, + FreshmanCount: freshmanCount, + RedshirtFreshmanCount: redshirtFreshmanCount, + SophomoreCount: sophomoreCount, + RedshirtSophomoreCount: redshirtSophomoreCount, + JuniorCount: juniorCount, + RedshirtJuniorCount: redshirtJuniorCount, + SeniorCount: seniorCount, + RedshirtSeniorCount: redshirtSeniorCount, + LowCount: lowCount, + MediumCount: mediumCount, + HighCount: highCount, + }) fmt.Println("Total number of players entering the transfer portal: ", transferCount) fmt.Println("Total number of freshmen entering the transfer portal: ", freshmanCount) fmt.Println("Total number of redshirt freshmen entering the transfer portal: ", redshirtFreshmanCount) @@ -562,6 +595,7 @@ func EnterTheTransferPortal() { ts := GetTimestamp() // Get All Teams teams := GetAllCollegeTeams() + var portalEntrants []string for _, t := range teams { teamID := strconv.Itoa(int(t.ID)) @@ -576,6 +610,7 @@ func EnterTheTransferPortal() { promise := GetCollegePromiseByCollegePlayerID(playerID, teamID) if promise.ID == 0 { + portalEntrants = append(portalEntrants, fmt.Sprintf("%d★ %s %s %s (%s)", p.Stars, p.Position, p.FirstName, p.LastName, p.TeamAbbr)) p.WillTransfer() repository.SaveCollegePlayerRecord(p, db) continue @@ -597,10 +632,11 @@ func EnterTheTransferPortal() { // If the dice roll is within the 40%. They leave. // Okay this makes sense. + portalEntrants = append(portalEntrants, fmt.Sprintf("%d★ %s %s %s (%s)", p.Stars, p.Position, p.FirstName, p.LastName, p.TeamAbbr)) p.WillTransfer() // Create News Log - message := "Breaking News! " + p.TeamAbbr + " " + strconv.Itoa(p.Stars) + " Star " + p.Position + " " + p.FirstName + " " + p.LastName + " has officially entered the transfer portal!" + message := "Breaking News! " + p.PreviousTeam + " " + strconv.Itoa(p.Stars) + " Star " + p.Position + " " + p.FirstName + " " + p.LastName + " has officially entered the transfer portal!" CreateNewsLog("CFB", message, "Transfer Portal", int(p.PreviousTeamID), ts) repository.SaveCFBPlayer(p, db) @@ -621,6 +657,7 @@ func EnterTheTransferPortal() { ts.EnactPortalPhase() repository.SaveTimestamp(ts, db) + go CreateTransferPortalOpenForumThread(ts.Season, portalEntrants) } func AddTransferPlayerToBoard(transferPortalProfileDto structs.TransferPortalProfile) structs.TransferPortalProfile { @@ -1033,6 +1070,7 @@ func SyncTransferPortal() { rosterMap := GetFullTeamRosterWithCrootsMap() collegePromises := GetAllCollegePromises() collegePromiseMap := MakeCollegePromiseMap(collegePromises) + var signingLabels []string if !ts.IsRecruitingLocked { ts.ToggleLockRecruiting() @@ -1145,6 +1183,7 @@ func SyncTransferPortal() { repository.SaveCollegePromiseRecord(promise, db) } portalPlayer.SignWithNewTeam(teamProfile.TeamID, teamProfile.TeamAbbreviation) + signingLabels = append(signingLabels, fmt.Sprintf("%d★ %s %s %s (%s → %s)", portalPlayer.Stars, portalPlayer.Position, portalPlayer.FirstName, portalPlayer.LastName, portalPlayer.PreviousTeam, portalPlayer.TeamAbbr)) message := portalPlayer.FirstName + " " + portalPlayer.LastName + ", " + strconv.Itoa(portalPlayer.Stars) + " star " + portalPlayer.Position + " from " + portalPlayer.PreviousTeam + " has signed with " + portalPlayer.TeamAbbr + " with " + strconv.Itoa(int(odds)) + " percent odds." CreateNewsLog("CFB", message, "Transfer Portal", int(winningTeamID), ts) fmt.Println("Created new log!") @@ -1197,8 +1236,10 @@ func SyncTransferPortal() { } } + currentRound := ts.TransferPortalRound ts.IncrementTransferPortalRound() repository.SaveTimestamp(ts, db) + go CreateTransferPortalSyncForumThread(ts.Season, int(currentRound), signingLabels) } func GetPromisesByTeamID(teamID string) []structs.CollegePromise {