Skip to content

Commit 3791025

Browse files
committed
Achivements mutations +++++
1 parent 42d5d2b commit 3791025

27 files changed

+6915
-1314
lines changed

backend/cmd/seed-journal/main.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"log"
6+
"log/slog"
7+
"math/rand"
8+
"os"
9+
"time"
10+
11+
"github.com/bcc-media/wayfarer/cmd/seed/seeders"
12+
"github.com/bcc-media/wayfarer/internal/config"
13+
"github.com/bcc-media/wayfarer/internal/database"
14+
"github.com/bcc-media/wayfarer/internal/logger"
15+
"github.com/jaswdr/faker"
16+
)
17+
18+
func main() {
19+
// Load configuration
20+
cfg, err := config.Load()
21+
if err != nil {
22+
log.Fatalf("Failed to load config: %v", err)
23+
}
24+
25+
// Initialize structured logger
26+
lgr := logger.New(cfg.Server.Environment, logger.ParseLevel(cfg.Log.Level))
27+
slog.SetDefault(lgr)
28+
29+
// Set up random seed
30+
seed := time.Now().UnixNano()
31+
fake := faker.NewWithSeed(rand.NewSource(seed))
32+
33+
slog.Info("Starting score_journal seeding only", "seed", seed)
34+
35+
// Connect to database
36+
ctx := context.Background()
37+
db, err := database.Connect(ctx, cfg.Database)
38+
if err != nil {
39+
slog.Error("Failed to connect to database", "error", err)
40+
os.Exit(1)
41+
}
42+
defer db.Close()
43+
44+
// Query existing data from database
45+
seededData := seeders.NewSeededData()
46+
47+
// Get all user IDs
48+
userRows, err := db.Pool.Query(ctx, "SELECT id FROM users ORDER BY id")
49+
if err != nil {
50+
slog.Error("Failed to query users", "error", err)
51+
os.Exit(1)
52+
}
53+
for userRows.Next() {
54+
var userID string
55+
if err := userRows.Scan(&userID); err != nil {
56+
slog.Error("Failed to scan user", "error", err)
57+
os.Exit(1)
58+
}
59+
seededData.UserIDs = append(seededData.UserIDs, userID)
60+
}
61+
userRows.Close()
62+
63+
if len(seededData.UserIDs) == 0 {
64+
slog.Error("No users found in database. Please run the main seed script first.")
65+
os.Exit(1)
66+
}
67+
68+
slog.Info("Loaded users", "count", len(seededData.UserIDs))
69+
70+
// Get all project IDs
71+
projectRows, err := db.Pool.Query(ctx, "SELECT id FROM projects ORDER BY id")
72+
if err != nil {
73+
slog.Error("Failed to query projects", "error", err)
74+
os.Exit(1)
75+
}
76+
for projectRows.Next() {
77+
var projectID string
78+
if err := projectRows.Scan(&projectID); err != nil {
79+
slog.Error("Failed to scan project", "error", err)
80+
os.Exit(1)
81+
}
82+
seededData.ProjectIDs = append(seededData.ProjectIDs, projectID)
83+
}
84+
projectRows.Close()
85+
86+
if len(seededData.ProjectIDs) == 0 {
87+
slog.Error("No projects found in database. Please run the main seed script first.")
88+
os.Exit(1)
89+
}
90+
91+
slog.Info("Loaded projects", "count", len(seededData.ProjectIDs))
92+
93+
// Get all event IDs grouped by project
94+
eventRows, err := db.Pool.Query(ctx, "SELECT id, project_id FROM events ORDER BY project_id, id")
95+
if err != nil {
96+
slog.Error("Failed to query events", "error", err)
97+
os.Exit(1)
98+
}
99+
for eventRows.Next() {
100+
var eventID, projectID string
101+
if err := eventRows.Scan(&eventID, &projectID); err != nil {
102+
slog.Error("Failed to scan event", "error", err)
103+
os.Exit(1)
104+
}
105+
seededData.EventIDs[projectID] = append(seededData.EventIDs[projectID], eventID)
106+
}
107+
eventRows.Close()
108+
109+
slog.Info("Loaded events", "count", countEvents(seededData.EventIDs))
110+
111+
// Initialize seeder context
112+
seeder := &seeders.Seeder{
113+
DB: db,
114+
Fake: fake,
115+
Ctx: ctx,
116+
Data: seededData,
117+
Config: seeders.SeedConfig{
118+
ProjectParticipationRate: 0.9, // Same as default
119+
AchievementCompletionRate: 0.7, // Same as default
120+
},
121+
}
122+
123+
// Run score_journal seeder
124+
stats := &seeders.Stats{}
125+
slog.Info("Seeding score journal entries")
126+
if err := seeder.SeedScoreJournal(stats); err != nil {
127+
slog.Error("Failed to seed score journal", "error", err)
128+
os.Exit(1)
129+
}
130+
131+
slog.Info("Score journal seeding completed successfully")
132+
}
133+
134+
func countEvents(eventIDs map[string][]string) int {
135+
count := 0
136+
for _, events := range eventIDs {
137+
count += len(events)
138+
}
139+
return count
140+
}

backend/cmd/seed/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ func main() {
122122
os.Exit(1)
123123
}
124124

125+
slog.Info("Seeding score journal")
126+
if err := seeder.SeedScoreJournal(stats); err != nil {
127+
slog.Error("Failed to seed score journal", "error", err)
128+
os.Exit(1)
129+
}
130+
125131
// Print summary
126132
slog.Info("Seed completed successfully",
127133
"churches", stats.Churches,
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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+
}

backend/gqlgen.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,34 @@ models:
153153
StreakID:
154154
type: string
155155

156+
ScoreJournal:
157+
fields:
158+
project:
159+
resolver: true
160+
user:
161+
resolver: true
162+
event:
163+
resolver: true
164+
challenge:
165+
resolver: true
166+
source:
167+
resolver: true
168+
awardedBy:
169+
resolver: true
170+
extraFields:
171+
ProjectID:
172+
type: string
173+
UserID:
174+
type: string
175+
EventID:
176+
type: "*string"
177+
ChallengeID:
178+
type: "*string"
179+
SourceID:
180+
type: "*string"
181+
AwardedByID:
182+
type: "*string"
183+
156184
# Autobind - automatically bind Go types to GraphQL types if they match by name
157185
autobind:
158186
- github.com/bcc-media/wayfarer/internal/graph/api/model

0 commit comments

Comments
 (0)