|
| 1 | +package seeders |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "log/slog" |
| 6 | + "math/rand" |
| 7 | + "time" |
| 8 | + |
| 9 | + "github.com/bcc-media/wayfarer/internal/ulid" |
| 10 | + "github.com/jackc/pgx/v5" |
| 11 | +) |
| 12 | + |
| 13 | +// SeedScoreJournal creates score_journal entries for users |
| 14 | +// Each user gets at least 3 entries distributed across their projects |
| 15 | +// 80-90% ACHIEVEMENT type, 10-20% MANUAL type, all positive points |
| 16 | +func (s *Seeder) SeedScoreJournal(stats *Stats) error { |
| 17 | + slog.Info("Starting score_journal seeding") |
| 18 | + |
| 19 | + // Query achievements with their details for creating ACHIEVEMENT entries |
| 20 | + achievementQuery := ` |
| 21 | + SELECT id, project_id, event_id, challenge_id, points |
| 22 | + FROM achievements |
| 23 | + ` |
| 24 | + rows, err := s.DB.Pool.Query(s.Ctx, achievementQuery) |
| 25 | + if err != nil { |
| 26 | + return fmt.Errorf("failed to query achievements: %w", err) |
| 27 | + } |
| 28 | + defer rows.Close() |
| 29 | + |
| 30 | + // Map of projectID -> []Achievement for easy lookup |
| 31 | + type Achievement struct { |
| 32 | + ID string |
| 33 | + ProjectID string |
| 34 | + EventID *string |
| 35 | + ChallengeID *string |
| 36 | + Points int32 |
| 37 | + } |
| 38 | + achievementsByProject := make(map[string][]Achievement) |
| 39 | + |
| 40 | + for rows.Next() { |
| 41 | + var a Achievement |
| 42 | + if err := rows.Scan(&a.ID, &a.ProjectID, &a.EventID, &a.ChallengeID, &a.Points); err != nil { |
| 43 | + return fmt.Errorf("failed to scan achievement: %w", err) |
| 44 | + } |
| 45 | + achievementsByProject[a.ProjectID] = append(achievementsByProject[a.ProjectID], a) |
| 46 | + } |
| 47 | + |
| 48 | + if err := rows.Err(); err != nil { |
| 49 | + return fmt.Errorf("error iterating achievements: %w", err) |
| 50 | + } |
| 51 | + |
| 52 | + slog.Info("Loaded achievements for score journal", "total_achievements", len(achievementsByProject)) |
| 53 | + |
| 54 | + // Get all admin users for awarding manual entries |
| 55 | + adminQuery := ` |
| 56 | + SELECT id FROM users LIMIT 10 |
| 57 | + ` |
| 58 | + adminRows, err := s.DB.Pool.Query(s.Ctx, adminQuery) |
| 59 | + if err != nil { |
| 60 | + return fmt.Errorf("failed to query admin users: %w", err) |
| 61 | + } |
| 62 | + defer adminRows.Close() |
| 63 | + |
| 64 | + adminUserIDs := []string{} |
| 65 | + for adminRows.Next() { |
| 66 | + var adminID string |
| 67 | + if err := adminRows.Scan(&adminID); err != nil { |
| 68 | + return fmt.Errorf("failed to scan admin user: %w", err) |
| 69 | + } |
| 70 | + adminUserIDs = append(adminUserIDs, adminID) |
| 71 | + } |
| 72 | + |
| 73 | + if err := adminRows.Err(); err != nil { |
| 74 | + return fmt.Errorf("error iterating admin users: %w", err) |
| 75 | + } |
| 76 | + |
| 77 | + // Prepare batch insert |
| 78 | + batchRows := [][]interface{}{} |
| 79 | + batchSize := 1000 |
| 80 | + totalEntries := 0 |
| 81 | + |
| 82 | + manualReasons := []string{ |
| 83 | + "Bonus for participation", |
| 84 | + "Event attendance reward", |
| 85 | + "Extra credit for team spirit", |
| 86 | + "Special recognition award", |
| 87 | + "Encouragement bonus", |
| 88 | + "Leadership contribution", |
| 89 | + "Helping other participants", |
| 90 | + "Outstanding engagement", |
| 91 | + } |
| 92 | + |
| 93 | + // For each user, create score_journal entries |
| 94 | + for _, userID := range s.Data.UserIDs { |
| 95 | + // Determine which projects this user participates in |
| 96 | + // We'll use the same logic as progress seeding |
| 97 | + userProjects := []string{} |
| 98 | + for _, projectID := range s.Data.ProjectIDs { |
| 99 | + if rand.Float64() < s.Config.ProjectParticipationRate { |
| 100 | + userProjects = append(userProjects, projectID) |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + // Skip if user has no projects |
| 105 | + if len(userProjects) == 0 { |
| 106 | + continue |
| 107 | + } |
| 108 | + |
| 109 | + // Generate 3-10 entries for this user |
| 110 | + numEntries := 3 + rand.Intn(8) |
| 111 | + |
| 112 | + for i := 0; i < numEntries; i++ { |
| 113 | + // Random project from user's projects |
| 114 | + projectID := userProjects[rand.Intn(len(userProjects))] |
| 115 | + |
| 116 | + // 85% chance of ACHIEVEMENT, 15% chance of MANUAL (falls within 80-90% / 10-20%) |
| 117 | + isAchievement := rand.Float64() < 0.85 |
| 118 | + |
| 119 | + // Generate entry ID and timestamp (spread over last 30 days) |
| 120 | + entryID := ulid.NewScoreJournalID() |
| 121 | + createdAt := time.Now().AddDate(0, 0, -rand.Intn(30)) |
| 122 | + |
| 123 | + var row []interface{} |
| 124 | + |
| 125 | + if isAchievement && len(achievementsByProject[projectID]) > 0 { |
| 126 | + // ACHIEVEMENT type entry |
| 127 | + achievement := achievementsByProject[projectID][rand.Intn(len(achievementsByProject[projectID]))] |
| 128 | + |
| 129 | + row = []interface{}{ |
| 130 | + entryID, |
| 131 | + projectID, |
| 132 | + userID, |
| 133 | + achievement.EventID, // event_id (may be null) |
| 134 | + achievement.ChallengeID, // challenge_id (may be null) |
| 135 | + achievement.Points, // points |
| 136 | + "ACHIEVEMENT", // source_type |
| 137 | + achievement.ID, // source_id |
| 138 | + nil, // reason (null for achievements) |
| 139 | + nil, // awarded_by (null for achievements) |
| 140 | + createdAt, // created_at |
| 141 | + } |
| 142 | + } else { |
| 143 | + // MANUAL type entry |
| 144 | + points := 10 + rand.Intn(91) // 10-100 points |
| 145 | + reason := manualReasons[rand.Intn(len(manualReasons))] |
| 146 | + var awardedBy *string |
| 147 | + if len(adminUserIDs) > 0 { |
| 148 | + adminID := adminUserIDs[rand.Intn(len(adminUserIDs))] |
| 149 | + awardedBy = &adminID |
| 150 | + } |
| 151 | + |
| 152 | + // 40% chance to link to an event |
| 153 | + var eventID *string |
| 154 | + if rand.Float64() < 0.4 && len(s.Data.EventIDs[projectID]) > 0 { |
| 155 | + eID := s.Data.EventIDs[projectID][rand.Intn(len(s.Data.EventIDs[projectID]))] |
| 156 | + eventID = &eID |
| 157 | + } |
| 158 | + |
| 159 | + row = []interface{}{ |
| 160 | + entryID, |
| 161 | + projectID, |
| 162 | + userID, |
| 163 | + eventID, // event_id (may be null) |
| 164 | + nil, // challenge_id (null for manual) |
| 165 | + int32(points), // points |
| 166 | + "MANUAL", // source_type |
| 167 | + nil, // source_id (null for manual) |
| 168 | + reason, // reason |
| 169 | + awardedBy, // awarded_by |
| 170 | + createdAt, // created_at |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | + batchRows = append(batchRows, row) |
| 175 | + |
| 176 | + // Batch insert when we hit batch size |
| 177 | + if len(batchRows) >= batchSize { |
| 178 | + if err := s.insertScoreJournalBatch(batchRows); err != nil { |
| 179 | + return err |
| 180 | + } |
| 181 | + totalEntries += len(batchRows) |
| 182 | + batchRows = [][]interface{}{} |
| 183 | + } |
| 184 | + } |
| 185 | + } |
| 186 | + |
| 187 | + // Insert remaining rows |
| 188 | + if len(batchRows) > 0 { |
| 189 | + if err := s.insertScoreJournalBatch(batchRows); err != nil { |
| 190 | + return err |
| 191 | + } |
| 192 | + totalEntries += len(batchRows) |
| 193 | + } |
| 194 | + |
| 195 | + slog.Info("Score journal seeding completed", "total_entries", totalEntries) |
| 196 | + return nil |
| 197 | +} |
| 198 | + |
| 199 | +func (s *Seeder) insertScoreJournalBatch(rows [][]interface{}) error { |
| 200 | + _, err := s.DB.Pool.CopyFrom( |
| 201 | + s.Ctx, |
| 202 | + pgx.Identifier{"score_journal"}, |
| 203 | + []string{"id", "project_id", "user_id", "event_id", "challenge_id", "points", "source_type", "source_id", "reason", "awarded_by", "created_at"}, |
| 204 | + pgx.CopyFromRows(rows), |
| 205 | + ) |
| 206 | + return err |
| 207 | +} |
0 commit comments