package main import ( "context" "encoding/json" "fmt" "net/http" "sort" "strings" "time" "github.com/golang-jwt/jwt/v5" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" ) type progressResponse struct { SolvedQuestionIDs []string `json:"solvedQuestionIds"` DailyActivity map[string]int `json:"dailyActivity"` DailyActivityBreakdown map[string]map[string]int `json:"dailyActivityBreakdown"` SolvedCount int `json:"solvedCount"` ActiveDays int `json:"activeDays"` WeeklySolved int `json:"weeklySolved"` Leaderboard []progressLeaderboardUser `json:"leaderboard"` CodingLeaderboard []progressLeaderboardUser `json:"codingLeaderboard"` CodingSolvedCount int `json:"codingSolvedCount"` } type progressLeaderboardUser struct { ID string `json:"id"` DisplayName string `json:"displayName"` SolvedCount int `json:"solvedCount"` CurrentStreak int `json:"currentStreak"` WeeklySolved int `json:"weeklySolved"` Coins int `json:"coins"` Score int `json:"score"` Rank int `json:"rank"` IsCurrentUser bool `json:"isCurrentUser"` CodingStreak int `json:"codingStreak,omitempty"` LanguageStats map[string]int `json:"languageStats,omitempty"` } type importedProgressBody struct { SolvedQuestionIDs []string `json:"solvedQuestionIds"` DailyActivity map[string]int `json:"dailyActivity"` } type streakSnapshot struct { CurrentStreak int LongestStreak int LastActiveAt time.Time } var solveCoinRewards = map[string]int{ "easy": 10, "medium": 25, "hard": 50, } func handleUserStats(coll *mongo.Collection) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") return } doc, requestTimeZone, statusCode, err := authenticatedUser(r, coll) if err != nil { jsonError(w, statusCode, err.Error()) return } if err := syncUserTimeZone(r.Context(), coll, doc, requestTimeZone); err != nil { jsonError(w, http.StatusInternalServerError, "Failed to load stats.") return } stats, err := buildProgressResponse(r.Context(), coll, doc, time.Now().UTC()) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to load stats.") return } jsonWrite(w, http.StatusOK, map[string]any{ "user": toPublicUser(doc), "stats": stats, }) } } func handleSolveQuestion(coll *mongo.Collection) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") return } doc, requestTimeZone, statusCode, err := authenticatedUser(r, coll) if err != nil { jsonError(w, statusCode, err.Error()) return } var body struct { QuestionID string `json:"questionId"` Difficulty string `json:"difficulty"` Language string `json:"language,omitempty"` } if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { jsonError(w, http.StatusBadRequest, "Invalid JSON body") return } questionID := strings.TrimSpace(body.QuestionID) if questionID == "" { jsonError(w, http.StatusBadRequest, "Question ID is required.") return } reward, ok := rewardForDifficulty(body.Difficulty) if !ok { jsonError(w, http.StatusBadRequest, "Question difficulty is required.") return } now := time.Now().UTC() if err := syncUserTimeZone(r.Context(), coll, doc, requestTimeZone); err != nil { jsonError(w, http.StatusInternalServerError, "Failed to record solve.") return } solvedQuestionIDs := normalizeQuestionIDs(doc.SolvedQuestionIDs) dailyActivity := normalizeDailyActivity(doc.DailyActivity) dailyActivityBreakdown := normalizeDailyActivityBreakdown(doc.DailyActivityBreakdown) alreadySolved := containsQuestionID(solvedQuestionIDs, questionID) if !alreadySolved { solvedQuestionIDs = append(solvedQuestionIDs, questionID) activityKey := activityDateKey(now, doc.StreakTimeZone) dailyActivity[activityKey] = dailyActivity[activityKey] + 1 if dailyActivityBreakdown[activityKey] == nil { dailyActivityBreakdown[activityKey] = make(map[string]int) } section := "coding" if strings.HasPrefix(questionID, "sd-") { section = "systemDesign" } else if strings.HasPrefix(questionID, "cs-dbms-") { section = "dbms" } else if strings.HasPrefix(questionID, "cs-os-") { section = "os" } else if strings.HasPrefix(questionID, "cs-cn-") { section = "cn" } else if strings.HasPrefix(questionID, "cs-") { section = "csFundamentals" } else if strings.HasPrefix(questionID, "apt-") { section = "aptitude" } dailyActivityBreakdown[activityKey][section] = dailyActivityBreakdown[activityKey][section] + 1 languageStats := doc.LanguageStats if languageStats == nil { languageStats = make(map[string]int) } if section == "coding" && body.Language != "" { lang := strings.ToLower(strings.TrimSpace(body.Language)) languageStats[lang] = languageStats[lang] + 1 } nextStreak := nextStreakUpdate(doc, requestTimeZone, now) doc.SolvedQuestionIDs = solvedQuestionIDs doc.DailyActivity = dailyActivity doc.DailyActivityBreakdown = dailyActivityBreakdown doc.CurrentStreak = nextStreak.CurrentStreak doc.LongestStreak = nextStreak.LongestStreak doc.LastActiveAt = nextStreak.LastActiveAt doc.StreakTimeZone = nextStreak.StreakTimeZone doc.Coins = maxInt(doc.Coins, 0) + reward doc.LanguageStats = languageStats if reward > 0 { difficultyLabel := body.Difficulty if len(difficultyLabel) > 0 { difficultyLabel = strings.ToUpper(difficultyLabel[:1]) + strings.ToLower(difficultyLabel[1:]) } sectionName := "Coding Problem" if section == "systemDesign" { sectionName = "System Design" } else if section == "dbms" { sectionName = "DBMS" } else if section == "os" { sectionName = "OS" } else if section == "cn" { sectionName = "Computer Networks" } else if section == "csFundamentals" { sectionName = "CS Fundamentals" } else if section == "aptitude" { sectionName = "Aptitude" } _ = recordCoinTransaction(r.Context(), coll, doc.ID, reward, sectionName+" ("+difficultyLabel+")", "problem") } _, err = coll.UpdateByID(r.Context(), doc.ID, bson.M{ "$set": bson.M{ "solvedQuestionIds": solvedQuestionIDs, "dailyActivity": dailyActivity, "dailyActivityBreakdown": dailyActivityBreakdown, "currentStreak": doc.CurrentStreak, "longestStreak": doc.LongestStreak, "lastActiveAt": doc.LastActiveAt, "streakTimeZone": doc.StreakTimeZone, "coins": doc.Coins, "languageStats": doc.LanguageStats, }, }) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to record solve.") return } } stats, err := buildProgressResponse(r.Context(), coll, doc, now) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to load updated stats.") return } jsonWrite(w, http.StatusOK, map[string]any{ "user": toPublicUser(doc), "stats": stats, }) } } func handleImportProgress(coll *mongo.Collection) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") return } doc, requestTimeZone, statusCode, err := authenticatedUser(r, coll) if err != nil { jsonError(w, statusCode, err.Error()) return } var body importedProgressBody if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { jsonError(w, http.StatusBadRequest, "Invalid JSON body") return } if err := syncUserTimeZone(r.Context(), coll, doc, requestTimeZone); err != nil { jsonError(w, http.StatusInternalServerError, "Failed to import progress.") return } mergedSolved := mergeQuestionIDs(doc.SolvedQuestionIDs, body.SolvedQuestionIDs) mergedActivity := mergeDailyActivity(doc.DailyActivity, body.DailyActivity) snapshot := streakSnapshotFromActivity(mergedActivity, doc.StreakTimeZone) doc.SolvedQuestionIDs = mergedSolved doc.DailyActivity = mergedActivity if !snapshot.LastActiveAt.IsZero() { doc.CurrentStreak = snapshot.CurrentStreak doc.LongestStreak = maxInt(doc.LongestStreak, snapshot.LongestStreak) doc.LastActiveAt = snapshot.LastActiveAt } _, err = coll.UpdateByID(r.Context(), doc.ID, bson.M{ "$set": bson.M{ "solvedQuestionIds": mergedSolved, "dailyActivity": mergedActivity, "currentStreak": doc.CurrentStreak, "longestStreak": doc.LongestStreak, "lastActiveAt": doc.LastActiveAt, "streakTimeZone": doc.StreakTimeZone, }, }) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to import progress.") return } stats, err := buildProgressResponse(r.Context(), coll, doc, time.Now().UTC()) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to load updated stats.") return } jsonWrite(w, http.StatusOK, map[string]any{ "user": toPublicUser(doc), "stats": stats, }) } } func handleSpendCoins(coll *mongo.Collection) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") return } doc, _, statusCode, err := authenticatedUser(r, coll) if err != nil { jsonError(w, statusCode, err.Error()) return } var body struct { Amount int `json:"amount"` } if err := json.NewDecoder(maxBody(w, r.Body)).Decode(&body); err != nil { jsonError(w, http.StatusBadRequest, "Invalid JSON body") return } if body.Amount <= 0 { jsonError(w, http.StatusBadRequest, "Amount must be greater than 0.") return } if maxInt(doc.Coins, 0) < body.Amount { jsonError(w, http.StatusBadRequest, "Not enough coins.") return } doc.Coins = maxInt(doc.Coins, 0) - body.Amount _ = recordCoinTransaction(r.Context(), coll, doc.ID, -body.Amount, "Store Purchase", "shop") _, err = coll.UpdateByID(r.Context(), doc.ID, bson.M{ "$set": bson.M{ "coins": doc.Coins, }, }) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to spend coins.") return } stats, err := buildProgressResponse(r.Context(), coll, doc, time.Now().UTC()) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to load updated stats.") return } jsonWrite(w, http.StatusOK, map[string]any{ "user": toPublicUser(doc), "stats": stats, }) } } func authenticatedUser( r *http.Request, coll *mongo.Collection, ) (*userDoc, string, int, error) { raw := readBearer(r) if raw == "" { return nil, "", http.StatusUnauthorized, fmt.Errorf("Not authenticated.") } tok, err := jwt.ParseWithClaims(raw, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) { if t.Method != jwt.SigningMethodHS256 { return nil, fmt.Errorf("unexpected signing method") } return []byte(jwtSecret()), nil }) if err != nil || !tok.Valid { return nil, "", http.StatusUnauthorized, fmt.Errorf("Invalid or expired session.") } claims, ok := tok.Claims.(*jwt.RegisteredClaims) if !ok || claims.Subject == "" { return nil, "", http.StatusUnauthorized, fmt.Errorf("Invalid or expired session.") } oid, err := primitive.ObjectIDFromHex(claims.Subject) if err != nil { return nil, "", http.StatusUnauthorized, fmt.Errorf("Invalid or expired session.") } var doc userDoc err = coll.FindOne(r.Context(), bson.M{"_id": oid}).Decode(&doc) if err != nil { if err == mongo.ErrNoDocuments { return nil, "", http.StatusUnauthorized, fmt.Errorf("User not found.") } return nil, "", http.StatusInternalServerError, fmt.Errorf("Could not load user.") } return &doc, readClientTimeZone(r), http.StatusOK, nil } func buildProgressResponse( ctx context.Context, coll *mongo.Collection, currentUser *userDoc, now time.Time, ) (progressResponse, error) { solvedQuestionIDs := normalizeQuestionIDs(currentUser.SolvedQuestionIDs) dailyActivity := normalizeDailyActivity(currentUser.DailyActivity) dailyActivityBreakdown := normalizeDailyActivityBreakdown(currentUser.DailyActivityBreakdown) leaderboard, codingLeaderboard, err := buildLeaderboards(ctx, coll, currentUser.ID.Hex(), now) if err != nil { return progressResponse{}, err } codingSolvedCount := 0 for _, id := range solvedQuestionIDs { if !strings.HasPrefix(id, "sd-") && !strings.HasPrefix(id, "cs-") && !strings.HasPrefix(id, "apt-") { codingSolvedCount++ } } return progressResponse{ SolvedQuestionIDs: solvedQuestionIDs, DailyActivity: dailyActivity, DailyActivityBreakdown: dailyActivityBreakdown, SolvedCount: len(solvedQuestionIDs), ActiveDays: countActiveDays(dailyActivity), WeeklySolved: weeklySolvedCount(dailyActivity, currentUser.StreakTimeZone, now), Leaderboard: leaderboard, CodingLeaderboard: codingLeaderboard, CodingSolvedCount: codingSolvedCount, }, nil } func buildLeaderboards( ctx context.Context, coll *mongo.Collection, currentUserID string, now time.Time, ) ([]progressLeaderboardUser, []progressLeaderboardUser, error) { cursor, err := coll.Find(ctx, bson.M{}, options.Find().SetProjection(bson.M{ "_id": 1, "displayName": 1, "coins": 1, "currentStreak": 1, "longestStreak": 1, "lastActiveAt": 1, "streakTimeZone": 1, "solvedQuestionIds": 1, "dailyActivity": 1, "dailyActivityBreakdown": 1, "languageStats": 1, })) if err != nil { return nil, nil, err } defer cursor.Close(ctx) entries := make([]progressLeaderboardUser, 0) codingEntries := make([]progressLeaderboardUser, 0) for cursor.Next(ctx) { var doc userDoc if err := cursor.Decode(&doc); err != nil { return nil, nil, err } solvedQuestionIDs := normalizeQuestionIDs(doc.SolvedQuestionIDs) dailyActivity := normalizeDailyActivity(doc.DailyActivity) dailyActivityBreakdown := normalizeDailyActivityBreakdown(doc.DailyActivityBreakdown) currentStreak := visibleCurrentStreak(&doc, "", now) weeklySolved := weeklySolvedCount(dailyActivity, doc.StreakTimeZone, now) codingWeeklySolved := codingWeeklySolvedCount(dailyActivityBreakdown, doc.StreakTimeZone, now) codingStreak := codingCurrentStreak(dailyActivityBreakdown, doc.StreakTimeZone, now) codingSolvedCount := 0 for _, id := range solvedQuestionIDs { if !strings.HasPrefix(id, "sd-") && !strings.HasPrefix(id, "cs-") && !strings.HasPrefix(id, "apt-") { codingSolvedCount++ } } coins := maxInt(doc.Coins, 0) solvedCount := len(solvedQuestionIDs) score := solvedCount*100 + weeklySolved*15 + currentStreak*30 + coins codingScore := codingSolvedCount*100 entries = append(entries, progressLeaderboardUser{ ID: doc.ID.Hex(), DisplayName: strings.TrimSpace(doc.DisplayName), SolvedCount: solvedCount, CurrentStreak: currentStreak, WeeklySolved: weeklySolved, Coins: coins, Score: score, IsCurrentUser: doc.ID.Hex() == currentUserID, }) codingEntries = append(codingEntries, progressLeaderboardUser{ ID: doc.ID.Hex(), DisplayName: strings.TrimSpace(doc.DisplayName), SolvedCount: codingSolvedCount, CurrentStreak: currentStreak, CodingStreak: codingStreak, WeeklySolved: codingWeeklySolved, Coins: coins, Score: codingScore, LanguageStats: doc.LanguageStats, IsCurrentUser: doc.ID.Hex() == currentUserID, }) } if err := cursor.Err(); err != nil { return nil, nil, err } sort.Slice(entries, func(i, j int) bool { if entries[i].Score != entries[j].Score { return entries[i].Score > entries[j].Score } if entries[i].SolvedCount != entries[j].SolvedCount { return entries[i].SolvedCount > entries[j].SolvedCount } if entries[i].WeeklySolved != entries[j].WeeklySolved { return entries[i].WeeklySolved > entries[j].WeeklySolved } if entries[i].Coins != entries[j].Coins { return entries[i].Coins > entries[j].Coins } return strings.ToLower(entries[i].DisplayName) < strings.ToLower(entries[j].DisplayName) }) for index := range entries { entries[index].Rank = index + 1 if entries[index].DisplayName == "" { entries[index].DisplayName = fmt.Sprintf("User %d", index+1) } } sort.Slice(codingEntries, func(i, j int) bool { if codingEntries[i].Score != codingEntries[j].Score { return codingEntries[i].Score > codingEntries[j].Score } if codingEntries[i].SolvedCount != codingEntries[j].SolvedCount { return codingEntries[i].SolvedCount > codingEntries[j].SolvedCount } if codingEntries[i].WeeklySolved != codingEntries[j].WeeklySolved { return codingEntries[i].WeeklySolved > codingEntries[j].WeeklySolved } if codingEntries[i].Coins != codingEntries[j].Coins { return codingEntries[i].Coins > codingEntries[j].Coins } return strings.ToLower(codingEntries[i].DisplayName) < strings.ToLower(codingEntries[j].DisplayName) }) for index := range codingEntries { codingEntries[index].Rank = index + 1 if codingEntries[index].DisplayName == "" { codingEntries[index].DisplayName = fmt.Sprintf("User %d", index+1) } } return entries, codingEntries, nil } func trimLeaderboard( entries []progressLeaderboardUser, currentUserID string, limit int, ) []progressLeaderboardUser { if len(entries) <= limit { return entries } currentIndex := -1 for index, entry := range entries { if entry.ID == currentUserID { currentIndex = index break } } if currentIndex == -1 || currentIndex < limit { return entries[:limit] } trimmed := make([]progressLeaderboardUser, 0, limit) trimmed = append(trimmed, entries[:limit-1]...) trimmed = append(trimmed, entries[currentIndex]) return trimmed } func rewardForDifficulty(difficulty string) (int, bool) { reward, ok := solveCoinRewards[strings.ToLower(strings.TrimSpace(difficulty))] return reward, ok } func normalizeQuestionIDs(ids []string) []string { seen := make(map[string]struct{}, len(ids)) normalized := make([]string, 0, len(ids)) for _, rawID := range ids { id := strings.TrimSpace(rawID) if id == "" { continue } if _, exists := seen[id]; exists { continue } seen[id] = struct{}{} normalized = append(normalized, id) } return normalized } func containsQuestionID(ids []string, questionID string) bool { for _, id := range ids { if id == questionID { return true } } return false } func mergeQuestionIDs(existing, incoming []string) []string { return normalizeQuestionIDs(append(normalizeQuestionIDs(existing), incoming...)) } func normalizeDailyActivity(activity map[string]int) map[string]int { if len(activity) == 0 { return map[string]int{} } normalized := make(map[string]int, len(activity)) for key, count := range activity { trimmedKey := strings.TrimSpace(key) if trimmedKey == "" || count <= 0 { continue } normalized[trimmedKey] = count } return normalized } func normalizeDailyActivityBreakdown(breakdown map[string]map[string]int) map[string]map[string]int { if len(breakdown) == 0 { return map[string]map[string]int{} } normalized := make(map[string]map[string]int, len(breakdown)) for dateKey, sectionMap := range breakdown { trimmedDateKey := strings.TrimSpace(dateKey) if trimmedDateKey == "" || len(sectionMap) == 0 { continue } normalizedMap := make(map[string]int, len(sectionMap)) for sectionKey, count := range sectionMap { trimmedSectionKey := strings.TrimSpace(sectionKey) if trimmedSectionKey == "" || count <= 0 { continue } normalizedMap[trimmedSectionKey] = count } if len(normalizedMap) > 0 { normalized[trimmedDateKey] = normalizedMap } } return normalized } func mergeDailyActivity(existing, incoming map[string]int) map[string]int { merged := normalizeDailyActivity(existing) for key, count := range normalizeDailyActivity(incoming) { if merged[key] < count { merged[key] = count } } return merged } func countActiveDays(activity map[string]int) int { activeDays := 0 for _, count := range activity { if count > 0 { activeDays++ } } return activeDays } func weeklySolvedCount(activity map[string]int, timeZone string, now time.Time) int { if len(activity) == 0 { return 0 } loc := mustLoadLocation(resolveStreakTimeZone("", timeZone)) localNow := now.In(loc) total := 0 for offset := 0; offset < 7; offset++ { currentDay := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 12, 0, 0, 0, loc).AddDate(0, 0, -offset) total += activity[currentDay.Format("2006-01-02")] } return total } func codingWeeklySolvedCount(breakdown map[string]map[string]int, timeZone string, now time.Time) int { if len(breakdown) == 0 { return 0 } loc := mustLoadLocation(resolveStreakTimeZone("", timeZone)) localNow := now.In(loc) total := 0 for offset := 0; offset < 7; offset++ { currentDay := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 12, 0, 0, 0, loc).AddDate(0, 0, -offset) dayKey := currentDay.Format("2006-01-02") if sectionMap, ok := breakdown[dayKey]; ok { total += sectionMap["coding"] } } return total } func codingCurrentStreak(breakdown map[string]map[string]int, timeZone string, now time.Time) int { if len(breakdown) == 0 { return 0 } loc := mustLoadLocation(resolveStreakTimeZone("", timeZone)) localNow := now.In(loc) streak := 0 todayKey := localNow.Format("2006-01-02") yesterdayKey := localNow.AddDate(0, 0, -1).Format("2006-01-02") todayCoding := 0 if breakdown[todayKey] != nil { todayCoding = breakdown[todayKey]["coding"] } yesterdayCoding := 0 if breakdown[yesterdayKey] != nil { yesterdayCoding = breakdown[yesterdayKey]["coding"] } if todayCoding == 0 && yesterdayCoding == 0 { return 0 } currentDay := localNow if todayCoding == 0 { currentDay = localNow.AddDate(0, 0, -1) } for { dayKey := currentDay.Format("2006-01-02") codingCount := 0 if breakdown[dayKey] != nil { codingCount = breakdown[dayKey]["coding"] } if codingCount > 0 { streak++ currentDay = currentDay.AddDate(0, 0, -1) } else { break } } return streak } func activityDateKey(now time.Time, timeZone string) string { loc := mustLoadLocation(resolveStreakTimeZone("", timeZone)) return now.In(loc).Format("2006-01-02") } func streakSnapshotFromActivity(activity map[string]int, timeZone string) streakSnapshot { if len(activity) == 0 { return streakSnapshot{} } loc := mustLoadLocation(resolveStreakTimeZone("", timeZone)) days := make([]time.Time, 0, len(activity)) for key, count := range activity { if count <= 0 { continue } parsed, err := time.ParseInLocation("2006-01-02", key, loc) if err != nil { continue } days = append(days, parsed) } if len(days) == 0 { return streakSnapshot{} } sort.Slice(days, func(i, j int) bool { return days[i].Before(days[j]) }) longestStreak := 1 currentRun := 1 for index := 1; index < len(days); index++ { dayDelta := int(days[index].Sub(days[index-1]).Hours() / 24) switch { case dayDelta <= 0: continue case dayDelta == 1: currentRun++ default: currentRun = 1 } if longestStreak < currentRun { longestStreak = currentRun } } currentStreak := 1 for index := len(days) - 1; index > 0; index-- { dayDelta := int(days[index].Sub(days[index-1]).Hours() / 24) if dayDelta != 1 { break } currentStreak++ } lastActiveAt := time.Date(days[len(days)-1].Year(), days[len(days)-1].Month(), days[len(days)-1].Day(), 12, 0, 0, 0, loc).UTC() return streakSnapshot{ CurrentStreak: currentStreak, LongestStreak: longestStreak, LastActiveAt: lastActiveAt, } } type coinTransactionDoc struct { ID primitive.ObjectID `bson:"_id" json:"id"` UserID string `bson:"userId" json:"userId"` Amount int `bson:"amount" json:"amount"` Source string `bson:"source" json:"source"` Category string `bson:"category" json:"category"` CreatedAt time.Time `bson:"createdAt" json:"createdAt"` } func recordCoinTransaction(ctx context.Context, coll *mongo.Collection, userID primitive.ObjectID, amount int, source string, category string) error { doc := coinTransactionDoc{ ID: primitive.NewObjectID(), UserID: userID.Hex(), Amount: amount, Source: source, Category: category, CreatedAt: time.Now().UTC(), } _, err := coll.Database().Collection("coin_transactions").InsertOne(ctx, doc) return err } func handleGetCoinHistory(coll *mongo.Collection) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { jsonError(w, http.StatusMethodNotAllowed, "Method not allowed") return } doc, _, statusCode, err := authenticatedUser(r, coll) if err != nil { jsonError(w, statusCode, err.Error()) return } cursor, err := coll.Database().Collection("coin_transactions").Find(r.Context(), bson.M{"userId": doc.ID.Hex()}, options.Find().SetSort(bson.M{"createdAt": -1})) if err != nil { jsonError(w, http.StatusInternalServerError, "Failed to fetch coin history.") return } defer cursor.Close(r.Context()) var transactions []coinTransactionDoc if err := cursor.All(r.Context(), &transactions); err != nil { jsonError(w, http.StatusInternalServerError, "Failed to fetch coin history.") return } if transactions == nil { transactions = []coinTransactionDoc{} } sum := 0 for _, tx := range transactions { sum += tx.Amount } if doc.Coins > sum { legacyAmount := doc.Coins - sum createdAt := doc.CreatedAt if createdAt.IsZero() { createdAt = time.Now().UTC() } transactions = append(transactions, coinTransactionDoc{ ID: primitive.NewObjectID(), UserID: doc.ID.Hex(), Amount: legacyAmount, Source: "Initial Balance", Category: "bonus", CreatedAt: createdAt, }) } jsonWrite(w, http.StatusOK, transactions) } }