RYP / cmd /server /progress.go
Soumya79's picture
Upload 1361 files
f91a684 verified
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)
}
}