diff --git a/veza-backend-api/Makefile b/veza-backend-api/Makefile index e4b024f8c..28f5f184b 100644 --- a/veza-backend-api/Makefile +++ b/veza-backend-api/Makefile @@ -274,9 +274,13 @@ migrate: ## Exécute les migrations de base de données db-migrate: migrate ## Alias pour migrate -db-seed: ## Peuple la base de données avec des données de test +db-seed: ## Peuple la base de données avec des données réalistes (1200 users, 5000 tracks) @echo "$(GREEN)🌱 Peuplement de la base de données...$(NC)" - @echo "$(YELLOW)⚠️ Seeding non implémenté$(NC)" + @go run ./cmd/tools/seed/ + +db-seed-minimal: ## Peuple la base avec un jeu réduit (50 users, 200 tracks) + @echo "$(GREEN)🌱 Peuplement réduit...$(NC)" + @go run ./cmd/tools/seed/ --minimal # Lab Environment migrate-lab: ## Applique les migrations en environnement Lab diff --git a/veza-backend-api/cmd/tools/seed/config.go b/veza-backend-api/cmd/tools/seed/config.go new file mode 100644 index 000000000..79a0750c2 --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/config.go @@ -0,0 +1,174 @@ +package main + +import "flag" + +// Config holds all seed volume parameters. +type Config struct { + // Users + TotalUsers int + NormalUsers int + Artists int + Labels int + Moderators int + Admins int + + // Content + Tracks int + Albums int + Playlists int + PlaylistMinTracks int + PlaylistMaxTracks int + + // Social + Follows int + TrackLikes int + TrackReposts int + Comments int + CommentLikes int + + // Chat + Conversations int + Messages int + + // Live + PastLiveStreams int + LiveStreams int + + // Marketplace + Products int + Orders int + ProductReviews int + + // Analytics + PlayEvents int + ProfileViews int + AnalyticsMonths int + + // Notifications + Notifications int + + // Files + FileEntries int + + // Misc + Groups int + SupportTickets int + APIKeys int + Announcements int + Reports int + DataExports int + GearItems int + Courses int +} + +// FullConfig returns the full-scale configuration (~1200 users, ~5000 tracks). +func FullConfig() Config { + return Config{ + TotalUsers: 1200, + NormalUsers: 1000, + Artists: 150, + Labels: 30, + Moderators: 15, + Admins: 5, + + Tracks: 5000, + Albums: 300, + Playlists: 800, + PlaylistMinTracks: 5, + PlaylistMaxTracks: 50, + + Follows: 15000, + TrackLikes: 30000, + TrackReposts: 5000, + Comments: 8000, + CommentLikes: 2000, + + Conversations: 500, + Messages: 10000, + + PastLiveStreams: 200, + LiveStreams: 5, + + Products: 400, + Orders: 600, + ProductReviews: 300, + + PlayEvents: 100000, + ProfileViews: 20000, + AnalyticsMonths: 6, + + Notifications: 15000, + + FileEntries: 6000, + + Groups: 40, + SupportTickets: 80, + APIKeys: 30, + Announcements: 10, + Reports: 60, + DataExports: 15, + GearItems: 200, + Courses: 25, + } +} + +// MinimalConfig returns a reduced configuration for fast dev iteration. +func MinimalConfig() Config { + return Config{ + TotalUsers: 50, + NormalUsers: 30, + Artists: 12, + Labels: 3, + Moderators: 2, + Admins: 3, + + Tracks: 200, + Albums: 25, + Playlists: 40, + PlaylistMinTracks: 3, + PlaylistMaxTracks: 15, + + Follows: 200, + TrackLikes: 500, + TrackReposts: 100, + Comments: 150, + CommentLikes: 50, + + Conversations: 20, + Messages: 200, + + PastLiveStreams: 10, + LiveStreams: 2, + + Products: 30, + Orders: 40, + ProductReviews: 20, + + PlayEvents: 2000, + ProfileViews: 400, + AnalyticsMonths: 2, + + Notifications: 300, + + FileEntries: 250, + + Groups: 5, + SupportTickets: 10, + APIKeys: 5, + Announcements: 3, + Reports: 8, + DataExports: 3, + GearItems: 20, + Courses: 5, + } +} + +// ParseFlags parses CLI flags and returns the appropriate config. +func ParseFlags() Config { + minimal := flag.Bool("minimal", false, "Use reduced volumes (50 users, 200 tracks) for fast dev") + flag.Parse() + if *minimal { + return MinimalConfig() + } + return FullConfig() +} diff --git a/veza-backend-api/cmd/tools/seed/fake.go b/veza-backend-api/cmd/tools/seed/fake.go new file mode 100644 index 000000000..de1a258f2 --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/fake.go @@ -0,0 +1,636 @@ +package main + +import ( + "fmt" + "math" + "math/rand" + "strings" + "time" + + "github.com/google/uuid" +) + +// Deterministic RNG — set once in main.go +var rng *rand.Rand + +// InitRNG initializes the deterministic RNG. +func InitRNG(seed int64) { + rng = rand.New(rand.NewSource(seed)) +} + +// ── UUID helpers ───────────────────────────────────────────────────────────── + +func newUUID() string { + // Use rng bytes to make UUIDs deterministic + var b [16]byte + for i := range b { + b[i] = byte(rng.Intn(256)) + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 1 + u, _ := uuid.FromBytes(b[:]) + return u.String() +} + +// ── Random primitives ──────────────────────────────────────────────────────── + +func randInt(min, max int) int { + if min >= max { + return min + } + return min + rng.Intn(max-min+1) +} + +func randFloat(min, max float64) float64 { + return min + rng.Float64()*(max-min) +} + +func randBool() bool { + return rng.Intn(2) == 0 +} + +func randChance(pct int) bool { + return rng.Intn(100) < pct +} + +func pick[T any](slice []T) T { + return slice[rng.Intn(len(slice))] +} + +func pickN[T any](slice []T, n int) []T { + if n >= len(slice) { + cp := make([]T, len(slice)) + copy(cp, slice) + return cp + } + perm := rng.Perm(len(slice)) + result := make([]T, n) + for i := 0; i < n; i++ { + result[i] = slice[perm[i]] + } + return result +} + +func pickWeighted(weights []float64) int { + total := 0.0 + for _, w := range weights { + total += w + } + r := rng.Float64() * total + cum := 0.0 + for i, w := range weights { + cum += w + if r <= cum { + return i + } + } + return len(weights) - 1 +} + +// ── Power-law distribution ─────────────────────────────────────────────────── + +// PowerLaw returns a value following a power-law distribution. +// Used for follower counts, play counts, etc. +func PowerLaw(min, max int, alpha float64) int { + u := rng.Float64() + x := math.Pow( + math.Pow(float64(max), alpha+1)-math.Pow(float64(min), alpha+1)*u+math.Pow(float64(min), alpha+1), + 1.0/(alpha+1), + ) + v := int(x) + if v < min { + v = min + } + if v > max { + v = max + } + return v +} + +// ── Temporal helpers ───────────────────────────────────────────────────────── + +var baseTime = time.Date(2024, 9, 1, 0, 0, 0, 0, time.UTC) + +func seedTimeRange() (time.Time, time.Time) { + return baseTime, time.Now() +} + +// RandomTimeBetween returns a random time between start and end. +func RandomTimeBetween(start, end time.Time) time.Time { + delta := end.Sub(start) + if delta <= 0 { + return start + } + offset := time.Duration(rng.Int63n(int64(delta))) + return start.Add(offset) +} + +// RandomTimeAfter returns a random time between after and now. +func RandomTimeAfter(after time.Time) time.Time { + return RandomTimeBetween(after, time.Now()) +} + +// RandomPastTime returns a random time within the seed period. +func RandomPastTime() time.Time { + start, end := seedTimeRange() + return RandomTimeBetween(start, end) +} + +// RandomRegistrationTime returns a time spread over 18 months with growth curve. +// Earlier months have fewer registrations, later months have more. +func RandomRegistrationTime(index, total int) time.Time { + start, end := seedTimeRange() + // Use a quadratic growth curve: more users registered recently + t := float64(index) / float64(total) + // Quadratic: t^1.5 gives accelerating growth + adjusted := math.Pow(t, 1.5) + offset := time.Duration(adjusted * float64(end.Sub(start))) + return start.Add(offset) +} + +// RealisticHour adjusts a time to a realistic hour (peak 18-23h, low 3-7h). +func RealisticHour(t time.Time) time.Time { + // Distribution weights for each hour (0-23) + hourWeights := []float64{ + 3, 2, 1.5, 1, 0.5, 0.5, 1, 2, // 00-07 + 4, 5, 6, 7, 7, 6, 6, 7, // 08-15 + 8, 10, 12, 14, 15, 13, 10, 6, // 16-23 + } + hour := pickWeighted(hourWeights) + minute := rng.Intn(60) + second := rng.Intn(60) + return time.Date(t.Year(), t.Month(), t.Day(), hour, minute, second, 0, t.Location()) +} + +// DaysAgo returns a time N days ago. +func DaysAgo(d int) time.Time { + return time.Now().Add(-time.Duration(d) * 24 * time.Hour) +} + +// ── Artist names ───────────────────────────────────────────────────────────── + +var artistPrefixes = []string{ + "DJ ", "MC ", "", "", "", "", "", "", "", "", // 80% no prefix +} + +var artistFirstNames = []string{ + "Luna", "Nova", "Ash", "Kai", "Rio", "Zara", "Milo", "Jade", "Axel", "Nina", + "Felix", "Aria", "Leo", "Maya", "Theo", "Isla", "Remy", "Cleo", "Hugo", "Vera", + "Soren", "Lyra", "Nico", "Freya", "Dante", "Suki", "Orion", "Yuna", "Blaze", "Kira", + "Samir", "Elara", "Raven", "Zion", "Atlas", "Sage", "Phoenix", "Echo", "Jasper", "Aurora", +} + +var artistLastParts = []string{ + "beats", "sound", "wave", "bass", "pulse", "tone", "flux", "vibe", "sonic", "audio", + "rhythm", "synth", "groove", "noise", "echo", "drift", "loop", "fade", "glitch", "static", +} + +var stylizedNames = []string{ + "KNTRL", "Nø Signal", "×void×", "fm.static", "lowkey", "BVSS", "h3lix", "wav.form", + "neo.soul", "ctrl+alt", "404.wav", "null.set", "bit.crush", "sub.zero", "hi.pass", + "vrtx", "DRMZ", "pxlgrid", "snthwv", "bassface", "deepstate", "skyline", + "midnight", "goldchain", "velvet", "crimson", "phantom", "spectra", "zenith", + "vertex", "cascade", "prism", "solstice", "tempest", "horizon", "meridian", +} + +var frenchNames = []string{ + "Amélie", "Baptiste", "Camille", "Dimitri", "Éloïse", "Florian", "Gaëlle", "Hugo", + "Inès", "Jules", "Katia", "Léo", "Manon", "Nathan", "Océane", "Pierre", + "Quentin", "Rose", "Sébastien", "Théo", "Ulysse", "Victoire", "William", "Xavier", + "Yasmine", "Zoé", "Adrien", "Bérénice", "Clément", "Diane", "Émile", "Fantine", +} + +var frenchLastNames = []string{ + "Dubois", "Martin", "Lefèvre", "Moreau", "Laurent", "Garcia", "Petit", "Roux", + "Bernard", "Robert", "Durand", "Simon", "Michel", "Richard", "Thomas", "Leroux", + "David", "Bertrand", "Fournier", "Girard", "Mercier", "Dupont", "Lambert", "Bonnet", +} + +// GenArtistName generates a realistic artist/stage name. +func GenArtistName(index int) string { + switch { + case index%5 == 0: + // Stylized name + return pick(stylizedNames) + case index%3 == 0: + // French full name + return pick(frenchNames) + " " + pick(frenchLastNames) + default: + // Prefix + first name + maybe suffix + prefix := pick(artistPrefixes) + name := pick(artistFirstNames) + if randChance(30) { + return prefix + name + " " + pick(artistLastParts) + } + return prefix + name + } +} + +// GenUsername generates a valid username (3-30 chars, [a-zA-Z0-9_]). +func GenUsername(displayName string, index int) string { + // Sanitize display name + clean := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { + return r + } + if r == ' ' || r == '.' || r == '-' { + return '_' + } + // Strip accents by dropping non-ASCII + return -1 + }, displayName) + + clean = strings.ToLower(clean) + // Remove consecutive underscores + for strings.Contains(clean, "__") { + clean = strings.ReplaceAll(clean, "__", "_") + } + clean = strings.Trim(clean, "_") + + if len(clean) < 3 { + clean = fmt.Sprintf("user_%d", index) + } + + // Ensure uniqueness with index suffix + if len(clean) > 20 { + clean = clean[:20] + } + return fmt.Sprintf("%s_%04d", clean, index) +} + +// ── Track titles ───────────────────────────────────────────────────────────── + +var trackAdjectives = []string{ + "Neon", "Midnight", "Golden", "Velvet", "Crystal", "Electric", "Digital", "Cosmic", + "Lunar", "Solar", "Deep", "Lost", "Broken", "Silent", "Frozen", "Burning", + "Faded", "Hollow", "Phantom", "Sacred", "Vivid", "Crimson", "Amber", "Sapphire", + "Endless", "Haunted", "Infinite", "Liquid", "Mystic", "Urban", "Ethereal", "Raw", +} + +var trackNouns = []string{ + "Dreams", "Waves", "Lights", "Rain", "Echo", "Pulse", "Horizon", "Shadow", + "Storm", "Flame", "Drift", "Spiral", "Signal", "Memory", "Voyage", "Whisper", + "Thunder", "Mirage", "Paradise", "Frequency", "Cascade", "Odyssey", "Nebula", "Prism", + "Skyline", "Orbit", "Labyrinth", "Phoenix", "Tempest", "Solitude", "Reverie", "Zenith", +} + +var trackTemplates = []string{ + "%s %s", // "Neon Dreams" + "%s %s", // "Midnight Waves" + "The %s", // "The Storm" + "%s", // "Pulse" + "%s & %s", // "Lights & Shadows" + "After %s", // "After Rain" + "Last %s", // "Last Signal" + "%s at Dawn", // "Waves at Dawn" + "%s Protocol", // "Midnight Protocol" + "%s Sessions", // "Deep Sessions" + "Into the %s", // "Into the Storm" + "%s Mode", // "Neon Mode" +} + +// GenTrackTitle generates a realistic track title. +func GenTrackTitle() string { + tmpl := pick(trackTemplates) + count := strings.Count(tmpl, "%s") + switch count { + case 0: + return tmpl + case 1: + if randBool() { + return fmt.Sprintf(tmpl, pick(trackAdjectives)) + } + return fmt.Sprintf(tmpl, pick(trackNouns)) + case 2: + return fmt.Sprintf(tmpl, pick(trackAdjectives), pick(trackNouns)) + } + return pick(trackAdjectives) + " " + pick(trackNouns) +} + +// ── Albums ─────────────────────────────────────────────────────────────────── + +var albumTemplates = []string{ + "%s EP", "%s Vol. %d", "%s Sessions", "The %s Collection", + "%s", "%s Tapes", "%s Archives", "Side %s", +} + +func GenAlbumTitle() string { + tmpl := pick(albumTemplates) + if strings.Contains(tmpl, "%d") { + return fmt.Sprintf(tmpl, pick(trackNouns), randInt(1, 3)) + } + if strings.Contains(tmpl, "%s") { + return fmt.Sprintf(tmpl, pick(trackAdjectives)) + } + return tmpl +} + +// ── Playlist names ─────────────────────────────────────────────────────────── + +var playlistNames = []string{ + "Chill Vibes 2025", "Workout Mix", "Late Night Sessions", "Morning Coffee", + "Deep Focus", "Road Trip Playlist", "Party Starters", "Rainy Day Beats", + "Sunset Grooves", "Study Sessions", "Pre-Game Hype", "Acoustic Chill", + "Underground Gems", "Throwback Jams", "Midnight Drive", "Sunday Morning", + "Bass Heavy", "Melodic Journey", "Lo-Fi & Chill", "Festival Favorites", + "New Discoveries", "Indie Gems", "Electronic Essentials", "Hip-Hop Rotation", + "Jazz Lounge", "Ambient Textures", "Funk & Soul", "Classical Focus", + "Drum & Bass Energy", "Techno Temple", "House Classics", "Reggae Vibes", + "Synthwave Dreams", "Post-Rock Journey", "Metal Mondays", "Blues Collection", + "Downtempo Drift", "Peak Time Bangers", "Warm Up Set", "After Hours", + "Bedroom Sessions", "Creative Flow", "Gym Power", "Cooking Tunes", + "Cleaning Motivation", "Meditation Sounds", "Nature Ambiance", "City Sounds", + "Vinyl Selections", "Hidden Treasures", "Fresh Finds Weekly", "Nostalgia Trip", +} + +func GenPlaylistName(index int) string { + if index < len(playlistNames) { + return playlistNames[index] + } + adj := pick(trackAdjectives) + noun := pick([]string{"Mix", "Playlist", "Collection", "Selection", "Vibes", "Beats", "Tunes", "Session"}) + return fmt.Sprintf("%s %s #%d", adj, noun, index) +} + +// ── Genres ──────────────────────────────────────────────────────────────────── + +type GenreInfo struct { + Name string + Slug string + BPMRange [2]int + Keys []string +} + +var Genres = []GenreInfo{ + {"Electronic", "electronic", [2]int{120, 140}, []string{"Am", "Cm", "Dm", "Em", "Fm"}}, + {"House", "house", [2]int{118, 130}, []string{"Am", "Cm", "Dm", "G", "F"}}, + {"Techno", "techno", [2]int{125, 150}, []string{"Am", "Dm", "Em", "Bm", "Cm"}}, + {"Ambient", "ambient", [2]int{60, 100}, []string{"C", "D", "F", "G", "Am"}}, + {"Drum & Bass", "drum-and-bass", [2]int{160, 180}, []string{"Am", "Dm", "Em", "Fm", "Gm"}}, + {"Dubstep", "dubstep", [2]int{135, 145}, []string{"Dm", "Em", "Fm", "Gm", "Am"}}, + {"Trance", "trance", [2]int{130, 150}, []string{"Am", "Dm", "Em", "Cm", "Fm"}}, + {"Jazz", "jazz", [2]int{80, 160}, []string{"Dm", "Gm", "Cm", "Fm", "Bb"}}, + {"Rock", "rock", [2]int{100, 160}, []string{"E", "A", "D", "G", "Em"}}, + {"Pop", "pop", [2]int{90, 130}, []string{"C", "G", "Am", "F", "D"}}, + {"Hip-Hop", "hip-hop", [2]int{70, 100}, []string{"Cm", "Fm", "Gm", "Dm", "Am"}}, + {"Classical", "classical", [2]int{40, 180}, []string{"C", "D", "G", "Am", "Em"}}, + {"Folk", "folk", [2]int{80, 130}, []string{"G", "C", "D", "Am", "Em"}}, + {"Reggae", "reggae", [2]int{60, 90}, []string{"Am", "Dm", "G", "C", "Em"}}, + {"Soul", "soul", [2]int{70, 110}, []string{"Dm", "Am", "Gm", "Cm", "Fm"}}, + {"Funk", "funk", [2]int{90, 120}, []string{"Em", "Am", "Dm", "G", "A"}}, + {"Blues", "blues", [2]int{60, 120}, []string{"E", "A", "B", "Am", "Em"}}, + {"Metal", "metal", [2]int{100, 200}, []string{"Em", "Am", "Dm", "E", "D"}}, + {"Indie", "indie", [2]int{90, 140}, []string{"C", "G", "Am", "F", "Em"}}, + {"Experimental", "experimental", [2]int{40, 200}, []string{"", "Am", "C", "Dm", ""}}, + {"World", "world", [2]int{70, 140}, []string{"Dm", "Am", "Em", "G", "C"}}, + {"Latin", "latin", [2]int{80, 140}, []string{"Am", "Dm", "G", "C", "Em"}}, + {"Lo-Fi", "lo-fi", [2]int{60, 90}, []string{"Am", "Dm", "Em", "C", "Fm"}}, +} + +// GenreForArtist returns 1-3 consistent genres for an artist. +func GenreForArtist(artistIndex int) []GenreInfo { + // Use artist index as seed for consistency + primary := artistIndex % len(Genres) + count := 1 + rng.Intn(3) + result := []GenreInfo{Genres[primary]} + // Add nearby genres + for i := 1; i < count && i < len(Genres); i++ { + next := (primary + i) % len(Genres) + result = append(result, Genres[next]) + } + return result +} + +// ── Bio generator ──────────────────────────────────────────────────────────── + +var bioTemplates = []string{ + "Producteur %s basé à %s. %s", + "%s artist exploring the boundaries of %s and %s.", + "Making %s since %d. %s", + "Born in %s, raised on %s. Currently working on new material.", + "%s producer & DJ. Resident at %s. %s", + "Independent %s artist. Available for collaborations.", + "Passionate about %s and sound design. %s", + "Multi-instrumentalist blending %s with %s. Based in %s.", + "Creating sonic landscapes between %s and %s.", + "🎵 %s | %s | Based in %s", +} + +var cities = []string{ + "Paris", "Lyon", "Marseille", "Toulouse", "Bordeaux", "Nantes", "Lille", "Strasbourg", + "Berlin", "London", "Amsterdam", "Barcelona", "Brussels", "Montreal", "New York", + "Los Angeles", "Tokyo", "Lagos", "São Paulo", "Melbourne", "Stockholm", "Vienna", +} + +var bioSuffixes = []string{ + "Open for collabs.", "New EP coming soon.", "Bookings: see website.", + "Label founder.", "Self-taught producer.", "Vinyl collector.", + "Always looking for new sounds.", "Music is a journey, not a destination.", + "", "", "", // Sometimes no suffix +} + +func GenBio(genre string) string { + tmpl := pick(bioTemplates) + city := pick(cities) + suffix := pick(bioSuffixes) + year := randInt(2008, 2023) + genre2 := pick(Genres).Name + + s := tmpl + s = strings.Replace(s, "%s", genre, 1) + s = strings.Replace(s, "%s", city, 1) + s = strings.Replace(s, "%s", suffix, 1) + s = strings.Replace(s, "%s", genre2, 1) + s = strings.Replace(s, "%s", city, 1) + s = strings.Replace(s, "%d", fmt.Sprintf("%d", year), 1) + // Clean up leftover %s + for strings.Contains(s, "%s") { + s = strings.Replace(s, "%s", pick([]string{genre, city, suffix}), 1) + } + return s +} + +// GenShortBio generates a 20-80 char bio for regular users. +func GenShortBio() string { + bios := []string{ + "Music lover.", "Just here for the vibes.", "Discovering new sounds daily.", + "Vinyl junkie.", "Festival goer. Playlist maker.", "Underground music enthusiast.", + "Bass head.", "Chillwave addict.", "Lo-fi and coffee.", "Night owl.", + "Always on repeat.", "Headphones on, world off.", "Eclectic listener.", + "Beatmaker wannabe.", "Melody seeker.", "Groove collector.", + "Sound explorer.", "Audio nerd.", "Rhythm is life.", + "Music is the answer.", "Curator of good vibes.", + } + return pick(bios) +} + +// ── Comment generator ──────────────────────────────────────────────────────── + +var commentTemplates = []string{ + "This track is incredible! 🔥", "Love the vibe on this one.", + "The production quality is amazing.", "Can't stop listening to this.", + "This hits different at night.", "Perfect track for my playlist.", + "The bass on this is insane.", "Beautiful melody, well done!", + "Discovered this today, instant favorite.", "The mix is so clean.", + "This deserves way more plays.", "Reminds me of early %s.", + "Your best work yet!", "How did you get that synth sound?", + "The drop at 1:30 is everything.", "Smooth production, respect.", + "This needs to be in a movie soundtrack.", "Played this 10 times today already.", + "The atmosphere on this track is unreal.", "Fire 🔥🔥🔥", + "Adding this to every playlist.", "The outro is hauntingly beautiful.", + "Chef's kiss on the mastering.", "This is what I needed today.", + "Absolute banger.", "So underrated.", "The groove never stops.", + "Your sound design skills are next level.", "Pure vibes.", + "This brought me back to my first rave.", "Incredible arrangement.", + "The percussion work is stellar.", "Love the vocal chops.", + "This gives me goosebumps every time.", "What DAW did you use?", + "Certified classic.", "Sharing this with everyone I know.", + "The transition at 2:45 is genius.", "Perfect driving music.", + "I could listen to this forever.", "The ambiance is just right.", +} + +func GenComment() string { + tmpl := pick(commentTemplates) + if strings.Contains(tmpl, "%s") { + return fmt.Sprintf(tmpl, pick(Genres).Name) + } + return tmpl +} + +// ── Message generator ──────────────────────────────────────────────────────── + +var chatMessages = []string{ + "Hey, what's up?", "Anyone heard the new release?", "Great set last night!", + "Looking for collab partners.", "What plugins do you recommend?", + "Just finished a new track, feedback welcome!", "Thanks for the follow!", + "Love your latest upload.", "When's your next live stream?", + "Check out my new playlist.", "The mix sounds great.", + "What DAW are you using?", "Ableton or FL Studio?", + "Anyone going to the festival this summer?", "Great community here.", + "Need help with mixing, any tips?", "The platform is awesome.", + "Just uploaded my first track!", "Who wants to do a remix swap?", + "Your sound design is incredible.", "How do you get that bass sound?", + "Thanks for sharing!", "Welcome to the group!", + "Let's set up a listening session.", "Perfect track for the weekend.", + "Anyone know good sample packs?", "Mastering tips?", + "Studio session later?", "New beat ready for vocals.", + "This community is fire.", "Appreciate the support everyone!", +} + +func GenMessage() string { + return pick(chatMessages) +} + +// ── Product names ──────────────────────────────────────────────────────────── + +var productTypes = []string{"beat", "sample-pack", "beat", "sample-pack", "beat"} +var productCategories = []string{ + "beats", "samples", "electronic", "house", "acoustic", "stems", + "hip-hop", "trap", "lo-fi", "ambient", "dj-tools", "sfx", "sync", +} + +func GenProductTitle(genre string) string { + templates := []string{ + "%s Beat Pack Vol. %d", "%s Essentials", "%s Sample Collection", + "%s Stems Bundle", "%s Loop Kit", "Premium %s Beats", + "%s Sound Design Pack", "%s Producer Kit", "%s One-Shots", + "%s Drum Kit", "%s Texture Pack", "%s Presets Collection", + } + tmpl := pick(templates) + if strings.Contains(tmpl, "%d") { + return fmt.Sprintf(tmpl, genre, randInt(1, 5)) + } + return fmt.Sprintf(tmpl, genre) +} + +// ── IP and User-Agent generators ───────────────────────────────────────────── + +func GenIP() string { + return fmt.Sprintf("%d.%d.%d.%d", randInt(1, 223), randInt(0, 255), randInt(0, 255), randInt(1, 254)) +} + +var userAgents = []string{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 Chrome/119.0.0.0", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", + "Mozilla/5.0 (Android 14; Mobile) AppleWebKit/537.36 Chrome/120.0.0.0", + "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15", +} + +func GenUserAgent() string { + return pick(userAgents) +} + +var countryCodes = []string{ + "FR", "FR", "FR", "FR", "FR", // 50% French + "US", "US", "DE", "GB", "BE", "CA", "CH", "ES", "IT", "NL", + "JP", "BR", "AU", "SE", "PT", "MA", "SN", "CI", "TN", "DZ", +} + +func GenCountry() string { + return pick(countryCodes) +} + +var sources = []string{"web", "web", "web", "mobile", "mobile", "api"} + +func GenSource() string { + return pick(sources) +} + +// ── Room names ─────────────────────────────────────────────────────────────── + +var roomNames = []string{ + "General", "Production Tips", "Beat Marketplace", "Feedback Corner", + "Mixing & Mastering", "Vinyl Talk", "Festival Chat", "Sample Swap", + "Sound Design Lab", "Artist Lounge", "New Releases", "Collab Hub", + "Genre Discussion", "Gear Talk", "Music Theory", "Business & Promo", + "Open Mic", "Challenge of the Week", "Studio Setup", "Intro Thread", +} + +// ── Gear ───────────────────────────────────────────────────────────────────── + +type GearTemplate struct { + Name, Category, Brand, Model string + Price float64 +} + +var gearTemplates = []GearTemplate{ + {"Ableton Push 3", "controller", "Ableton", "Push 3", 999}, + {"Focal Shape 65", "monitors", "Focal", "Shape 65", 599}, + {"RME Babyface Pro FS", "audio-interface", "RME", "Babyface Pro FS", 849}, + {"Akai MPC One+", "sampler", "Akai", "MPC One+", 699}, + {"Audio-Technica AT2020", "microphone", "Audio-Technica", "AT2020", 99}, + {"Beyerdynamic DT 770 Pro", "headphones", "Beyerdynamic", "DT 770 Pro", 159}, + {"Zoom H6", "recorder", "Zoom", "H6", 349}, + {"Sennheiser MKH 416", "microphone", "Sennheiser", "MKH 416", 999}, + {"Pioneer DDJ-1000", "dj-controller", "Pioneer", "DDJ-1000", 1199}, + {"Allen & Heath Xone:96", "mixer", "Allen & Heath", "Xone:96", 1899}, + {"Martin D-28", "guitar", "Martin", "D-28", 2999}, + {"Neumann U87", "microphone", "Neumann", "U87", 3199}, + {"Korg Minilogue XD", "synthesizer", "Korg", "Minilogue XD", 649}, + {"Arturia KeyLab 61 MkII", "controller", "Arturia", "KeyLab 61 MkII", 449}, + {"Universal Audio Apollo Twin", "audio-interface", "Universal Audio", "Apollo Twin", 899}, + {"Yamaha HS8", "monitors", "Yamaha", "HS8", 349}, + {"Shure SM7B", "microphone", "Shure", "SM7B", 399}, + {"Roland TR-8S", "drum-machine", "Roland", "TR-8S", 599}, + {"Moog Subsequent 37", "synthesizer", "Moog", "Subsequent 37", 1499}, + {"Native Instruments Maschine+", "sampler", "Native Instruments", "Maschine+", 1399}, + {"Audient iD14 MkII", "audio-interface", "Audient", "iD14 MkII", 249}, + {"KRK Rokit 5 G4", "monitors", "KRK", "Rokit 5 G4", 179}, + {"Rode NT1-A", "microphone", "Rode", "NT1-A", 229}, + {"Elektron Digitakt II", "sampler", "Elektron", "Digitakt II", 899}, + {"Teenage Engineering OP-1 Field", "synthesizer", "Teenage Engineering", "OP-1 Field", 1999}, +} + +// ── Live stream titles ─────────────────────────────────────────────────────── + +var liveStreamTitles = []string{ + "Friday Night Set", "Production Session — New EP Preview", "Beat Making LIVE", + "Acoustic Session — Unplugged", "Late Night Vinyl Mix", "Sunday Jazz Session", + "Studio Tour & Q&A", "Making a Track From Scratch", "Mixing Masterclass", + "Sample Flipping Challenge", "Ambient Soundscape Session", "Open Decks Night", + "Synth Jam Session", "Drum Programming Workshop", "Vocal Recording Session", + "Lo-Fi Beats to Study To — LIVE", "Deep House Sunday", "Techno Warehouse Set", + "Indie Acoustic Session", "Sound Design Exploration", +} diff --git a/veza-backend-api/cmd/tools/seed/main.go b/veza-backend-api/cmd/tools/seed/main.go index d358fa1e9..1f52a8e2e 100644 --- a/veza-backend-api/cmd/tools/seed/main.go +++ b/veza-backend-api/cmd/tools/seed/main.go @@ -4,688 +4,211 @@ import ( "database/sql" "fmt" "log" - "math/rand" "os" - "strings" "time" - "github.com/google/uuid" "github.com/joho/godotenv" _ "github.com/lib/pq" - "golang.org/x/crypto/bcrypt" ) -// ─── helpers ──────────────────────────────────────────────────────────────── -func must(err error, msg string) { - if err != nil { - log.Fatalf("%s: %v", msg, err) - } -} - -func tryExec(db *sql.DB, query string, args ...interface{}) { - _, _ = db.Exec(query, args...) -} - -func execOrWarn(db *sql.DB, label string, query string, args ...interface{}) { - if _, err := db.Exec(query, args...); err != nil { - log.Printf(" ⚠ %s: %v", label, err) - } -} - -func countRows(db *sql.DB, table string) int { - var n int - _ = db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&n) - return n -} - -func randBetween(min, max int) int { return min + rand.Intn(max-min+1) } - -func daysAgo(d int) time.Time { return time.Now().Add(-time.Duration(d) * 24 * time.Hour) } - -func hoursAgo(h int) time.Time { return time.Now().Add(-time.Duration(h) * time.Hour) } - -// ─── main ─────────────────────────────────────────────────────────────────── func main() { _ = godotenv.Load() + + cfg := ParseFlags() + + // Deterministic seed for reproducibility + InitRNG(42) + dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { log.Fatal("DATABASE_URL not set") } db, err := sql.Open("postgres", dbURL) - must(err, "DB connect") + if err != nil { + log.Fatalf("DB connect: %v", err) + } defer db.Close() - must(db.Ping(), "DB ping") + if err := db.Ping(); err != nil { + log.Fatalf("DB ping: %v", err) + } - fmt.Println("╔═══════════════════════════════════════════════╗") - fmt.Println("║ VEZA — Database Seed Script ║") - fmt.Println("╚═══════════════════════════════════════════════╝") + // Increase connection pool for bulk operations + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(5) + + startTime := time.Now() + + fmt.Println("╔═══════════════════════════════════════════════════════════╗") + fmt.Println("║ VEZA — Realistic Database Seeder v2 ║") + fmt.Println("╚═══════════════════════════════════════════════════════════╝") + fmt.Printf("\n Mode: %s\n", modeName(cfg)) + fmt.Printf(" Users: %d | Tracks: %d | Plays: %d\n\n", cfg.TotalUsers, cfg.Tracks, cfg.PlayEvents) + + // ── TRUNCATE all tables ────────────────────────────────────────────────── + fmt.Print("Truncating all tables... ") + if err := TruncateAll(db); err != nil { + log.Fatalf("truncate: %v", err) + } + fmt.Println("done") + + // ── SEED in dependency order ───────────────────────────────────────────── + + // Level 0: Users (no FK dependencies) + users, err := SeedUsers(db, cfg) + if err != nil { + log.Fatalf("seed users: %v", err) + } + + // Level 1: Tracks (depends on users) + tracks, err := SeedTracks(db, cfg, users) + if err != nil { + log.Fatalf("seed tracks: %v", err) + } + + // Level 2: Playlists (depends on users, tracks) + _, err = SeedPlaylists(db, cfg, users, tracks) + if err != nil { + log.Fatalf("seed playlists: %v", err) + } + + // Level 2: Social (depends on users, tracks) + if err := SeedSocial(db, cfg, users, tracks); err != nil { + log.Fatalf("seed social: %v", err) + } + + // Level 2: Chat (depends on users) + _, err = SeedChat(db, cfg, users) + if err != nil { + log.Fatalf("seed chat: %v", err) + } + + // Level 2: Live streams (depends on users) + if err := SeedLive(db, cfg, users, tracks); err != nil { + log.Fatalf("seed live: %v", err) + } + + // Level 2: Marketplace (depends on users, tracks) + _, err = SeedMarketplace(db, cfg, users, tracks) + if err != nil { + log.Fatalf("seed marketplace: %v", err) + } + + // Level 3: Analytics (depends on users, tracks — heaviest step) + if err := SeedAnalytics(db, cfg, users, tracks); err != nil { + log.Fatalf("seed analytics: %v", err) + } + + // Level 2: Content (depends on users, tracks) + if err := SeedContent(db, cfg, users, tracks); err != nil { + log.Fatalf("seed content: %v", err) + } + + // Level 2: Moderation (depends on users, tracks) + if err := SeedModeration(db, cfg, users, tracks); err != nil { + log.Fatalf("seed moderation: %v", err) + } + + // Level 2: Notifications (depends on users) + if err := SeedNotifications(db, cfg, users); err != nil { + log.Fatalf("seed notifications: %v", err) + } + + // Level 2: Misc (depends on users) + if err := SeedMisc(db, cfg, users); err != nil { + log.Fatalf("seed misc: %v", err) + } + + // ── VALIDATION ─────────────────────────────────────────────────────────── + fmt.Println("\n═══ VALIDATION ═══") + validate(db) + + // ── SUMMARY ────────────────────────────────────────────────────────────── + elapsed := time.Since(startTime) fmt.Println() - - // Hash a shared password (bcrypt cost 12) - hash, err := bcrypt.GenerateFromPassword([]byte("Password123!"), 12) - must(err, "bcrypt") - pw := string(hash) - - // ═════════════════════════════════════════════════════════════════════════ - // USERS (10) - // ═════════════════════════════════════════════════════════════════════════ - type user struct { - id, email, username, display, role, bio string - isAdmin bool - } - - var users []user - if countRows(db, "users") == 0 { - fmt.Print("Creating users... ") - users = []user{ - {uuid.NewString(), "admin@veza.fr", "admin_veza", "Admin Veza", "admin", "Platform administrator", true}, - {uuid.NewString(), "amelie@veza.fr", "amelie_dubois", "Amelie Dubois", "creator", "Productrice electro basee a Paris. Melodic techno & ambient.", false}, - {uuid.NewString(), "marcus@veza.fr", "marcus_beats", "Marcus Beats", "creator", "Beatmaker from Lyon. Hip-hop, trap, lo-fi.", false}, - {uuid.NewString(), "sakura@veza.fr", "sakura_sound", "Sakura Sound", "creator", "Sound designer & foley artist. Cinematic textures.", false}, - {uuid.NewString(), "djrenzo@veza.fr", "dj_renzo", "DJ Renzo", "creator", "House & disco edits. Paris nightlife.", false}, - {uuid.NewString(), "clara@veza.fr", "clara_voice", "Clara Voix", "creator", "Singer-songwriter. Indie folk & acoustic.", false}, - {uuid.NewString(), "listener1@veza.fr", "music_lover", "Music Lover", "user", "Just here for the vibes.", false}, - {uuid.NewString(), "listener2@veza.fr", "groove_hunter", "Groove Hunter", "user", "Always looking for fresh beats.", false}, - {uuid.NewString(), "listener3@veza.fr", "night_owl", "Night Owl", "premium", "Late night music sessions.", false}, - {uuid.NewString(), "mod@veza.fr", "moderator_veza", "Moderator", "moderator", "Community moderator.", false}, - } - for _, u := range users { - _, err := db.Exec(`INSERT INTO users (id, email, email_verified_at, password_hash, username, slug, display_name, - role, is_active, is_verified, is_admin, bio, created_at, updated_at) - VALUES ($1,$2,NOW(),$3,$4,$5,$6,$7,true,true,$8,$9,NOW()-interval '1 day'*$10,NOW())`, - u.id, u.email, pw, u.username, u.username, u.display, u.role, u.isAdmin, u.bio, randBetween(1, 60)) - must(err, "user "+u.username) - } - fmt.Printf("%d created\n", len(users)) - - // Profiles & settings - for _, u := range users { - tryExec(db, `INSERT INTO user_profiles (user_id,bio,tagline,language,theme,profile_visibility) VALUES ($1,$2,$3,'fr','auto','public')`, - u.id, u.bio, strings.Split(u.bio, ".")[0]) - tryExec(db, `INSERT INTO user_settings (user_id) VALUES ($1) ON CONFLICT DO NOTHING`, u.id) - } - // Roles - tryExec(db, `INSERT INTO user_roles (user_id,role_id) SELECT $1,id FROM roles WHERE name='admin' ON CONFLICT DO NOTHING`, users[0].id) - tryExec(db, `INSERT INTO user_roles (user_id,role_id) SELECT $1,id FROM roles WHERE name='moderator' ON CONFLICT DO NOTHING`, users[9].id) - } else { - fmt.Println("Users already exist — loading IDs...") - rows, _ := db.Query(`SELECT id,email,username,display_name,role,COALESCE(bio,''),is_admin FROM users ORDER BY created_at LIMIT 10`) - for rows != nil && rows.Next() { - var u user - _ = rows.Scan(&u.id, &u.email, &u.username, &u.display, &u.role, &u.bio, &u.isAdmin) - users = append(users, u) - } - if rows != nil { - rows.Close() - } - } - - if len(users) < 10 { - fmt.Println("⚠ Need at least 10 users for full seed. Exiting.") - os.Exit(0) - } - - amelieID := users[1].id - marcusID := users[2].id - sakuraID := users[3].id - renzoID := users[4].id - claraID := users[5].id - - // ═════════════════════════════════════════════════════════════════════════ - // TRACKS (22) - // ═════════════════════════════════════════════════════════════════════════ - type track struct { - id, creator, title, artist, album, genre, key, tags string - duration, bpm int - } - var tracks []track - - if countRows(db, "tracks") == 0 { - fmt.Print("Creating tracks... ") - tracks = []track{ - {uuid.NewString(), amelieID, "Neon Dreams", "Amelie Dubois", "Neon EP", "electronic", "Am", "{electronic,ambient,melodic}", 342, 128}, - {uuid.NewString(), amelieID, "Midnight Protocol", "Amelie Dubois", "Neon EP", "techno", "Dm", "{techno,dark,melodic}", 410, 132}, - {uuid.NewString(), amelieID, "Aurora Borealis", "Amelie Dubois", "Neon EP", "ambient", "C", "{ambient,atmospheric,chill}", 520, 90}, - {uuid.NewString(), amelieID, "Digital Rain", "Amelie Dubois", "Singles", "electronic", "Em", "{electronic,synth,progressive}", 285, 126}, - {uuid.NewString(), amelieID, "Pulse", "Amelie Dubois", "Singles", "techno", "Bm", "{techno,driving,peak}", 378, 134}, - {uuid.NewString(), marcusID, "Late Night Loops", "Marcus Beats", "Bedroom Sessions", "hip-hop", "Cm", "{lofi,chill,beats}", 198, 85}, - {uuid.NewString(), marcusID, "Concrete Jungle", "Marcus Beats", "Bedroom Sessions", "hip-hop", "Fm", "{hiphop,boom-bap,gritty}", 225, 90}, - {uuid.NewString(), marcusID, "Velvet Touch", "Marcus Beats", "Bedroom Sessions", "r&b", "Ab", "{rnb,smooth,lofi}", 240, 78}, - {uuid.NewString(), marcusID, "City Lights", "Marcus Beats", "Singles", "trap", "Gm", "{trap,melodic,urban}", 210, 140}, - {uuid.NewString(), marcusID, "Rainy Days", "Marcus Beats", "Singles", "lo-fi", "D", "{lofi,rain,relax}", 180, 72}, - {uuid.NewString(), sakuraID, "Forest Whispers", "Sakura Sound", "Nature Vol.1", "ambient", "F", "{nature,foley,cinematic}", 480, 60}, - {uuid.NewString(), sakuraID, "Ocean Depths", "Sakura Sound", "Nature Vol.1", "ambient", "Eb", "{water,deep,ambient}", 540, 55}, - {uuid.NewString(), sakuraID, "Thunder Plains", "Sakura Sound", "Nature Vol.1", "cinematic", "Bb", "{storm,epic,cinematic}", 360, 80}, - {uuid.NewString(), sakuraID, "Urban Field Recording", "Sakura Sound", "Singles", "experimental", "", "{field-recording,urban,experimental}", 300, 0}, - {uuid.NewString(), renzoID, "Saturday Night Edit", "DJ Renzo", "Club Cuts", "house", "G", "{house,disco,funky}", 420, 122}, - {uuid.NewString(), renzoID, "Funky Elevator", "DJ Renzo", "Club Cuts", "disco", "A", "{disco,funk,groovy}", 355, 118}, - {uuid.NewString(), renzoID, "Deep in the Club", "DJ Renzo", "Club Cuts", "deep house", "Dm", "{deephouse,minimal,late-night}", 480, 124}, - {uuid.NewString(), renzoID, "Sunrise Set", "DJ Renzo", "Singles", "house", "C", "{house,progressive,sunrise}", 600, 120}, - {uuid.NewString(), claraID, "Paper Boats", "Clara Voix", "Whisper", "folk", "G", "{folk,acoustic,indie}", 220, 95}, - {uuid.NewString(), claraID, "Morning Light", "Clara Voix", "Whisper", "indie", "D", "{indie,dreamy,morning}", 198, 100}, - {uuid.NewString(), claraID, "Letters Never Sent", "Clara Voix", "Whisper", "folk", "Em", "{folk,emotional,singer-songwriter}", 265, 88}, - {uuid.NewString(), claraID, "Wildflowers", "Clara Voix", "Singles", "acoustic", "C", "{acoustic,nature,gentle}", 185, 92}, - } - for i, t := range tracks { - createdAt := daysAgo(60 - i*2) - // file_path uses "audio/{Title_With_Underscores}.mp3" matching generated audio files - filePath := "audio/" + strings.ReplaceAll(t.title, " ", "_") + ".mp3" - _, err := db.Exec(`INSERT INTO tracks (id,creator_id,user_id,title,artist,album,genre,duration,bpm,musical_key, - visibility,is_public,is_downloadable,status,stream_status,play_count,like_count,tags,file_path,format,published_at,created_at,updated_at) - VALUES ($1,$2,$2,$3,$4,$5,$6,$7,$8,$9,'public',true,false,'completed','ready',$10,$11,$12::text[],$14,'mp3',$13,$13,$13)`, - t.id, t.creator, t.title, t.artist, t.album, t.genre, t.duration, t.bpm, t.key, - randBetween(10, 500), randBetween(2, 50), t.tags, createdAt, filePath) - must(err, "track "+t.title) - } - fmt.Printf("%d created\n", len(tracks)) - } else { - fmt.Println("Tracks already exist — loading IDs...") - rows, _ := db.Query(`SELECT id,creator_id,title,artist,COALESCE(album,''),COALESCE(genre,''),COALESCE(musical_key,''),'{}',duration,COALESCE(bpm,0) FROM tracks ORDER BY created_at LIMIT 22`) - for rows != nil && rows.Next() { - var t track - _ = rows.Scan(&t.id, &t.creator, &t.title, &t.artist, &t.album, &t.genre, &t.key, &t.tags, &t.duration, &t.bpm) - tracks = append(tracks, t) - } - if rows != nil { - rows.Close() - } - } - - // ═════════════════════════════════════════════════════════════════════════ - // PLAYLISTS (6) - // ═════════════════════════════════════════════════════════════════════════ - type playlist struct{ id, user, name, desc string } - var playlists []playlist - - if countRows(db, "playlists") < 6 { - fmt.Print("Creating playlists... ") - playlists = []playlist{ - {uuid.NewString(), amelieID, "Late Night Techno", "My favorite tracks for late sessions"}, - {uuid.NewString(), marcusID, "Chill Beats Study", "Perfect background music for focus"}, - {uuid.NewString(), renzoID, "Weekend Warm-Up", "Pre-party essentials"}, - {uuid.NewString(), claraID, "Acoustic Mornings", "Gentle wake-up tracks"}, - {uuid.NewString(), users[6].id, "Discovery Mix", "New finds from this month"}, - {uuid.NewString(), users[7].id, "Workout Energy", "High-BPM motivation"}, - } - for _, p := range playlists { - tryExec(db, `INSERT INTO playlists (id,user_id,name,title,description,visibility,is_public,is_collaborative) VALUES ($1,$2,$3,$3,$4,'public',true,false)`, - p.id, p.user, p.name, p.desc) - } - fmt.Printf("%d created\n", len(playlists)) - - // Playlist tracks - ptMap := []struct{ pi int; ti []int }{ - {0, []int{0, 1, 4, 14, 16, 17}}, {1, []int{5, 7, 9, 18, 19}}, - {2, []int{14, 15, 16, 3, 4}}, {3, []int{18, 19, 20, 21, 10}}, - {4, []int{0, 5, 10, 14, 18, 8}}, {5, []int{1, 4, 8, 14, 15, 16}}, - } - for _, pt := range ptMap { - for pos, ti := range pt.ti { - if ti < len(tracks) { - tryExec(db, `INSERT INTO playlist_tracks (playlist_id,track_id,position,added_by) VALUES ($1,$2,$3,$4)`, - playlists[pt.pi].id, tracks[ti].id, pos, playlists[pt.pi].user) - } - } - tryExec(db, `UPDATE playlists SET track_count=(SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id=$1) WHERE id=$1`, playlists[pt.pi].id) - } - } else { - fmt.Println("Playlists already exist — skipping") - } - - // ═════════════════════════════════════════════════════════════════════════ - // FOLLOWS (18) - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "follows") < 10 { - fmt.Print("Creating follows... ") - follows := [][2]int{ - // Listeners follow creators - {6, 1}, {6, 2}, {6, 4}, {7, 1}, {7, 3}, {7, 4}, {7, 2}, - {8, 1}, {8, 2}, {8, 3}, {8, 4}, {8, 5}, - // Creators follow each other - {1, 2}, {2, 1}, {1, 4}, {4, 1}, {3, 1}, {5, 2}, {5, 3}, - // Admin follows all creators (so feed has content) - {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5}, - } - c := 0 - for _, f := range follows { - if _, err := db.Exec(`INSERT INTO follows (follower_id,followed_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, users[f[0]].id, users[f[1]].id); err == nil { - c++ - } - } - fmt.Printf("%d created\n", c) - } - - // ═════════════════════════════════════════════════════════════════════════ - // CHAT ROOMS & MESSAGES - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "rooms") == 0 { - fmt.Print("Creating chat rooms & messages... ") - roomIDs := [3]string{uuid.NewString(), uuid.NewString(), uuid.NewString()} - roomData := []struct{ name, owner string }{ - {"General", users[0].id}, {"Production Tips", amelieID}, {"Beat Marketplace", marcusID}, - } - for i, r := range roomData { - tryExec(db, `INSERT INTO rooms (id,name,owner_id,creator_id,room_type,is_private,created_at,updated_at) VALUES ($1,$2,$3,$3,'group',false,NOW(),NOW())`, roomIDs[i], r.name, r.owner) - } - for _, rid := range roomIDs { - for _, u := range users[:8] { - tryExec(db, `INSERT INTO room_members (room_id,user_id,role) VALUES ($1,$2,'member') ON CONFLICT DO NOTHING`, rid, u.id) - } - } - msgs := []struct{ r, s int; c string }{ - {0, 1, "Hey everyone! Welcome to Veza."}, {0, 2, "Glad to be here. Just uploaded some new beats!"}, - {0, 6, "Love the vibes on this platform."}, {0, 3, "Anyone interested in some cinematic samples?"}, - {0, 4, "Weekend set coming soon, stay tuned!"}, {1, 1, "What DAW is everyone using?"}, - {1, 2, "Ableton all the way. FL Studio for quick ideas."}, {1, 3, "Pro Tools for recording, Reaper for mixing."}, - {1, 5, "Logic Pro X here. Love the stock plugins."}, {2, 2, "New beat pack dropping this weekend. 10 beats, all original."}, - {2, 7, "How much for exclusive rights?"}, {2, 2, "DM me for pricing on exclusives!"}, - } - for i, m := range msgs { - ts := hoursAgo(len(msgs) - i) - tryExec(db, `INSERT INTO messages (room_id,sender_id,user_id,content,message_type,created_at,updated_at) VALUES ($1,$2,$2,$3,'text',$4,$4)`, - roomIDs[m.r], users[m.s].id, m.c, ts) - } - fmt.Printf("3 rooms, %d messages\n", len(msgs)) - } - - // ═════════════════════════════════════════════════════════════════════════ - // TRACK PLAYS (analytics — fixed column names) - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "track_plays") == 0 { - fmt.Print("Creating play history... ") - c := 0 - listeners := []int{6, 7, 8} - sources := []string{"web", "mobile", "api"} - countries := []string{"FR", "US", "DE", "GB", "JP", "BR", "CA"} - for _, li := range listeners { - for _, t := range tracks { - // Each listener plays ~70% of tracks, some multiple times - plays := 0 - if rand.Intn(10) < 7 { - plays = 1 - } - if rand.Intn(10) < 3 { - plays = randBetween(2, 5) // replay - } - for p := 0; p < plays; p++ { - ts := daysAgo(randBetween(0, 45)) - dur := t.duration * randBetween(60, 100) / 100 // 60-100% of track - tryExec(db, `INSERT INTO track_plays (track_id,user_id,duration,played_at,source,country_code,created_at,updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$4,$4)`, - t.id, users[li].id, dur, ts, sources[rand.Intn(len(sources))], countries[rand.Intn(len(countries))]) - c++ - } - } - } - fmt.Printf("%d plays recorded\n", c) - } - - // ═════════════════════════════════════════════════════════════════════════ - // TRACK LIKES - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "track_likes") < 20 { - fmt.Print("Creating likes... ") - c := 0 - for _, li := range []int{6, 7, 8} { - for i, t := range tracks { - if i%3 == 0 || i%5 == 0 || rand.Intn(4) == 0 { - if _, err := db.Exec(`INSERT INTO track_likes (track_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, t.id, users[li].id); err == nil { - c++ - } - } - } - } - fmt.Printf("%d likes\n", c) - } - - // ═════════════════════════════════════════════════════════════════════════ - // COMMENTS - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "comments") == 0 { - fmt.Print("Creating comments... ") - commentData := []struct{ track, user int; content string }{ - {0, 6, "This track is incredible, the synth work is amazing!"}, {0, 7, "Perfect for late night coding sessions."}, - {0, 8, "Amelie never disappoints. 🔥"}, {1, 7, "Dark and moody, love it."}, - {5, 6, "These loops are so clean."}, {5, 8, "Could listen to this on repeat all day."}, - {7, 6, "Smooth R&B vibes, exactly what I needed."}, {10, 8, "Beautiful nature sounds, so calming."}, - {14, 7, "DJ Renzo always brings the groove!"}, {14, 6, "This one gets the party started!"}, - {18, 6, "Clara your voice is so beautiful."}, {18, 8, "Acoustic perfection."}, - {20, 7, "This made me emotional, beautiful songwriting."}, {9, 6, "Lo-fi perfection for rainy days."}, - {3, 8, "The production quality is top notch."}, {16, 7, "Deep house at its finest."}, - {11, 6, "I can hear the ocean in my headphones."}, {19, 8, "Morning Light is my alarm song now."}, - {4, 7, "Peak time techno! Need this in a set."}, {15, 6, "Funky Elevator is an instant classic."}, - } - for _, cm := range commentData { - if cm.track < len(tracks) { - tryExec(db, `INSERT INTO comments (user_id,target_id,target_type,content,created_at,updated_at) VALUES ($1,$2,'track',$3,$4,$4)`, - users[cm.user].id, tracks[cm.track].id, cm.content, daysAgo(randBetween(0, 30))) - } - } - fmt.Printf("%d comments\n", len(commentData)) - } - - // ═════════════════════════════════════════════════════════════════════════ - // NOTIFICATIONS (fixed: column "read" not "is_read", "content" not "message") - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "notifications") == 0 { - fmt.Print("Creating notifications... ") - notifs := []struct{ user int; ntype, title, content string }{ - {1, "follow", "New follower", "music_lover started following you"}, - {1, "follow", "New follower", "groove_hunter started following you"}, - {2, "follow", "New follower", "night_owl started following you"}, - {1, "like", "Track liked", "Someone liked your track Neon Dreams"}, - {2, "like", "Track liked", "Someone liked your track Late Night Loops"}, - {3, "like", "Track liked", "Someone liked your track Forest Whispers"}, - {4, "comment", "New comment", "music_lover commented on Saturday Night Edit"}, - {5, "comment", "New comment", "night_owl commented on Paper Boats"}, - {1, "system", "Welcome", "Welcome to Veza! Start by uploading your first track."}, - {6, "system", "Welcome", "Welcome to Veza! Discover amazing music from independent artists."}, - {7, "system", "Welcome", "Welcome to Veza! Follow your favorite artists to see their new releases."}, - {0, "system", "Admin alert", "New user registrations this week: 5"}, - {2, "milestone", "Milestone reached", "Your track Late Night Loops just hit 100 plays!"}, - {1, "milestone", "Milestone reached", "You now have 5 followers!"}, - } - for _, n := range notifs { - tryExec(db, `INSERT INTO notifications (user_id,type,title,content,read,created_at,updated_at) VALUES ($1,$2,$3,$4,false,$5,$5)`, - users[n.user].id, n.ntype, n.title, n.content, daysAgo(randBetween(0, 14))) - } - fmt.Printf("%d created\n", len(notifs)) - } - - // ═════════════════════════════════════════════════════════════════════════ - // PRODUCTS (marketplace — 12 products from creators) - // ═════════════════════════════════════════════════════════════════════════ - type product struct{ id, seller, title, desc, ptype, license, category string; price float64; trackIdx int; bpm int; key string } - var products []product - - if countRows(db, "products") == 0 { - fmt.Print("Creating marketplace products... ") - products = []product{ - {uuid.NewString(), marcusID, "Lo-Fi Beats Pack Vol.1", "10 royalty-free lo-fi beats for content creators", "sample-pack", "non-exclusive", "beats", 29.99, -1, 80, "Cm"}, - {uuid.NewString(), marcusID, "Trap Essentials", "5 hard-hitting trap beats ready to use", "sample-pack", "non-exclusive", "beats", 19.99, -1, 140, "Gm"}, - {uuid.NewString(), amelieID, "Neon Dreams — Exclusive License", "Full exclusive rights to Neon Dreams", "beat", "exclusive", "electronic", 299.99, 0, 128, "Am"}, - {uuid.NewString(), amelieID, "Synth Textures Pack", "50 custom synth one-shots and loops", "sample-pack", "non-exclusive", "samples", 14.99, -1, 0, ""}, - {uuid.NewString(), amelieID, "Techno Stems — Midnight Protocol", "Individual stems for remix", "beat", "non-exclusive", "stems", 49.99, 1, 132, "Dm"}, - {uuid.NewString(), renzoID, "Disco Edits Bundle", "3 disco edits ready for DJ sets", "sample-pack", "non-exclusive", "dj-tools", 24.99, -1, 120, "G"}, - {uuid.NewString(), renzoID, "Saturday Night Edit — License", "Non-exclusive license for streaming", "beat", "non-exclusive", "house", 39.99, 14, 122, "G"}, - {uuid.NewString(), sakuraID, "Cinematic Foley Collection", "200+ foley sounds from nature recordings", "sample-pack", "non-exclusive", "sfx", 34.99, -1, 0, ""}, - {uuid.NewString(), sakuraID, "Ambient Textures Vol.1", "Layered ambient textures for film scoring", "sample-pack", "non-exclusive", "ambient", 19.99, -1, 0, ""}, - {uuid.NewString(), claraID, "Acoustic Guitar Loops", "15 acoustic guitar loops in various keys", "sample-pack", "non-exclusive", "acoustic", 12.99, -1, 95, "G"}, - {uuid.NewString(), claraID, "Paper Boats — Sync License", "Sync license for film/TV/ads", "beat", "non-exclusive", "sync", 149.99, 18, 95, "G"}, - {uuid.NewString(), marcusID, "City Lights — Lease", "Standard lease for City Lights beat", "beat", "non-exclusive", "trap", 49.99, 8, 140, "Gm"}, - } - for _, p := range products { - var trackID interface{} - if p.trackIdx >= 0 && p.trackIdx < len(tracks) { - trackID = tracks[p.trackIdx].id - } - tryExec(db, `INSERT INTO products (id,seller_id,title,description,price,currency,status,product_type,track_id,license_type,bpm,musical_key,category,created_at,updated_at) - VALUES ($1,$2,$3,$4,$5,'EUR','active',$6,$7,$8,$9,$10,$11,NOW()-interval '1 day'*$12,NOW())`, - p.id, p.seller, p.title, p.desc, p.price, p.ptype, trackID, p.license, p.bpm, p.key, p.category, randBetween(1, 30)) - } - fmt.Printf("%d products\n", len(products)) - } else { - fmt.Println("Products already exist — loading IDs...") - rows, _ := db.Query(`SELECT id,seller_id,title FROM products ORDER BY created_at LIMIT 12`) - for rows != nil && rows.Next() { - var p product - _ = rows.Scan(&p.id, &p.seller, &p.title) - products = append(products, p) - } - if rows != nil { - rows.Close() - } - } - - // ═════════════════════════════════════════════════════════════════════════ - // ORDERS & ORDER ITEMS (4 completed purchases) - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "orders") == 0 && len(products) >= 6 { - fmt.Print("Creating orders... ") - orderData := []struct{ buyer int; productIdxs []int; total float64 }{ - {6, []int{0, 3}, 44.98}, // music_lover buys lo-fi pack + synth textures - {7, []int{1, 5}, 44.98}, // groove_hunter buys trap essentials + disco edits - {8, []int{6, 9}, 52.98}, // night_owl buys saturday night license + acoustic loops - {6, []int{7}, 34.99}, // music_lover buys foley collection - } - for _, o := range orderData { - oid := uuid.NewString() - tryExec(db, `INSERT INTO orders (id,buyer_id,total_amount,currency,status,created_at,updated_at) VALUES ($1,$2,$3,'EUR','completed',$4,$4)`, - oid, users[o.buyer].id, o.total, daysAgo(randBetween(1, 20))) - for _, pi := range o.productIdxs { - if pi < len(products) { - tryExec(db, `INSERT INTO order_items (order_id,product_id,price) VALUES ($1,$2,$3)`, oid, products[pi].id, products[pi].price) - } - } - } - fmt.Printf("%d orders\n", len(orderData)) - } - - // ═════════════════════════════════════════════════════════════════════════ - // DAILY TRACK STATS (last 30 days for top tracks) - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "daily_track_stats") == 0 { - fmt.Print("Creating daily track stats... ") - c := 0 - for _, t := range tracks[:10] { // top 10 tracks - for d := 0; d < 30; d++ { - date := daysAgo(d).Format("2006-01-02") - plays := randBetween(1, 25) - uniq := randBetween(1, plays) - complete := randBetween(0, uniq) - totalTime := plays * t.duration * randBetween(60, 100) / 100 - avgCompl := float64(randBetween(50, 95)) / 100 - tryExec(db, `INSERT INTO daily_track_stats (track_id,date,total_plays,unique_listeners,complete_listens,total_play_time,avg_completion_rate) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`, - t.id, date, plays, uniq, complete, totalTime, avgCompl) - c++ - } - } - fmt.Printf("%d stat rows\n", c) - } - - // ═════════════════════════════════════════════════════════════════════════ - // COURSES & LESSONS (education) - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "courses") == 0 { - fmt.Print("Creating courses & lessons... ") - courseData := []struct{ creator, title, slug, desc, category, level string; price int; lessonCount int }{ - {amelieID, "Introduction to Music Production", "intro-music-production", "Learn the basics of music production with Ableton Live. From your first beat to a finished track.", "production", "beginner", 0, 8}, - {amelieID, "Melodic Techno Masterclass", "melodic-techno-masterclass", "Deep dive into melodic techno production techniques, sound design, and arrangement.", "production", "intermediate", 4999, 12}, - {marcusID, "Hip-Hop Beat Making 101", "hiphop-beatmaking-101", "Learn to make hard-hitting hip-hop beats from scratch. Sampling, drum programming, mixing.", "production", "beginner", 2999, 10}, - {sakuraID, "Field Recording & Sound Design", "field-recording-sound-design", "Capture the world around you and turn it into cinematic soundscapes.", "sound-design", "intermediate", 3999, 6}, - {claraID, "Songwriting for Beginners", "songwriting-beginners", "Find your voice, write meaningful lyrics, and structure your songs.", "songwriting", "beginner", 0, 5}, - } - for _, cd := range courseData { - cid := uuid.NewString() - status := "published" - var publishedAt interface{} = daysAgo(randBetween(5, 40)) - tryExec(db, `INSERT INTO courses (id,creator_id,title,slug,description,category,tags,price_cents,currency,pricing_model,status,level,language,lesson_count,published_at,created_at,updated_at) - VALUES ($1,$2,$3,$4,$5,$6,ARRAY['music','production'],$7,'EUR','fixed',$8,$9,'fr',$10,$11,$12,$12)`, - cid, cd.creator, cd.title, cd.slug, cd.desc, cd.category, cd.price, status, cd.level, cd.lessonCount, publishedAt, daysAgo(randBetween(10, 50))) - - // Create lessons for this course - lessonTitles := []string{ - "Getting Started", "Setting Up Your DAW", "Understanding Audio Basics", "Your First Beat", - "Melody and Harmony", "Sound Design Fundamentals", "Arrangement Techniques", "Mixing Basics", - "EQ and Compression", "Effects and Processing", "Mastering Your Track", "Final Project", - } - for li := 0; li < cd.lessonCount && li < len(lessonTitles); li++ { - tryExec(db, `INSERT INTO lessons (course_id,order_index,title,description,duration_seconds,is_preview_free,transcoding_status) VALUES ($1,$2,$3,$4,$5,$6,'completed')`, - cid, li, lessonTitles[li], fmt.Sprintf("Lesson %d of %s", li+1, cd.title), randBetween(300, 1800), li < 2) - } - } - fmt.Printf("%d courses\n", len(courseData)) - - // Enroll some users - rows, _ := db.Query(`SELECT id FROM courses LIMIT 5`) - var courseIDs []string - for rows != nil && rows.Next() { - var id string - _ = rows.Scan(&id) - courseIDs = append(courseIDs, id) - } - if rows != nil { - rows.Close() - } - for _, cid := range courseIDs { - for _, ui := range []int{6, 7, 8} { - if rand.Intn(3) == 0 { - tryExec(db, `INSERT INTO course_enrollments (user_id,course_id,status,purchased_at) VALUES ($1,$2,'active',NOW()-interval '1 day'*$3) ON CONFLICT DO NOTHING`, - users[ui].id, cid, randBetween(1, 20)) - } - } - } - } - - // ═════════════════════════════════════════════════════════════════════════ - // GEAR ITEMS (creator equipment) - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "gear_items") == 0 { - fmt.Print("Creating gear inventory... ") - gearData := []struct{ user int; name, cat, brand, model, status, condition string; price float64 }{ - {1, "Ableton Push 3", "controller", "Ableton", "Push 3", "Active", "Excellent", 999}, - {1, "Focal Shape 65", "monitors", "Focal", "Shape 65", "Active", "Good", 599}, - {1, "RME Babyface Pro FS", "audio-interface", "RME", "Babyface Pro FS", "Active", "Excellent", 849}, - {2, "Akai MPC One+", "sampler", "Akai", "MPC One+", "Active", "Good", 699}, - {2, "Audio-Technica AT2020", "microphone", "Audio-Technica", "AT2020", "Active", "Good", 99}, - {2, "Beyerdynamic DT 770 Pro", "headphones", "Beyerdynamic", "DT 770 Pro", "Active", "Fair", 159}, - {3, "Zoom H6", "recorder", "Zoom", "H6", "Active", "Excellent", 349}, - {3, "Sennheiser MKH 416", "microphone", "Sennheiser", "MKH 416", "Active", "Good", 999}, - {4, "Pioneer DDJ-1000", "dj-controller", "Pioneer", "DDJ-1000", "Active", "Good", 1199}, - {4, "Allen & Heath Xone:96", "mixer", "Allen & Heath", "Xone:96", "Active", "Excellent", 1899}, - {5, "Martin D-28", "guitar", "Martin", "D-28", "Active", "Good", 2999}, - {5, "Neumann U87", "microphone", "Neumann", "U87", "Active", "Excellent", 3199}, - } - for _, g := range gearData { - tryExec(db, `INSERT INTO gear_items (user_id,name,category,brand,model,status,condition,purchase_price,currency,purchase_date,is_public,created_at,updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'EUR',$9,true,NOW(),NOW())`, - users[g.user].id, g.name, g.cat, g.brand, g.model, g.status, g.condition, g.price, daysAgo(randBetween(30, 365)).Format("2006-01-02")) - } - fmt.Printf("%d items\n", len(gearData)) - } - - // ═════════════════════════════════════════════════════════════════════════ - // LIVE STREAMS (scheduled + past) - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "live_streams") == 0 { - fmt.Print("Creating live streams... ") - liveData := []struct{ user int; title, desc, cat string; isLive bool; viewers int }{ - {4, "Friday Night Disco Set", "Live disco & house set from my studio", "dj-set", false, 0}, - {1, "Production Session — New EP Preview", "Working on new melodic techno tracks live", "production", false, 0}, - {2, "Beat Making LIVE — Taking Requests", "Making beats on the spot, drop your ideas in chat", "production", false, 0}, - {5, "Acoustic Session — Unplugged", "Playing some originals and covers", "performance", false, 0}, - } - for _, l := range liveData { - tryExec(db, `INSERT INTO live_streams (user_id,title,description,category,streamer_name,is_live,viewer_count,tags,scheduled_at,created_at,updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,'[]'::jsonb,$8,NOW(),NOW())`, - users[l.user].id, l.title, l.desc, l.cat, users[l.user].display, l.isLive, l.viewers, - daysAgo(-randBetween(1, 14))) // future scheduled - } - fmt.Printf("%d streams\n", len(liveData)) - } - - // ═════════════════════════════════════════════════════════════════════════ - // ANNOUNCEMENTS - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "announcements") == 0 { - fmt.Print("Creating announcements... ") - annData := []struct{ title, content, atype string }{ - {"Welcome to Veza!", "We're thrilled to launch Veza — an ethical music platform built for artists and listeners. Explore, create, and connect.", "info"}, - {"Marketplace Now Open", "Buy and sell beats, samples, and presets directly on the platform. Fair pricing, transparent licensing.", "feature"}, - {"Scheduled Maintenance", "Brief maintenance window planned for Sunday 3am-5am CET. Streams may be briefly interrupted.", "warning"}, - } - for _, a := range annData { - tryExec(db, `INSERT INTO announcements (title,content,type,is_active,starts_at,created_by,created_at) VALUES ($1,$2,$3,true,NOW(),$4,NOW())`, - a.title, a.content, a.atype, users[0].id) - } - fmt.Printf("%d announcements\n", len(annData)) - } - - // ═════════════════════════════════════════════════════════════════════════ - // SUPPORT TICKETS - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "support_tickets") == 0 { - fmt.Print("Creating support tickets... ") - ticketData := []struct{ user int; email, subject, msg, cat, status string }{ - {6, "listener1@veza.fr", "Cannot upload profile picture", "I keep getting an error when trying to upload my avatar. File is a 2MB JPEG.", "technical", "open"}, - {7, "listener2@veza.fr", "How to create a playlist?", "I'm new here, how do I create a collaborative playlist?", "general", "resolved"}, - {2, "marcus@veza.fr", "Payment not received for beat sale", "Sold a beat 5 days ago but haven't received the payout yet.", "billing", "open"}, - {8, "listener3@veza.fr", "Feature request: dark mode scheduler", "Would love to have dark mode auto-switch at sunset.", "feature", "open"}, - } - for _, t := range ticketData { - tryExec(db, `INSERT INTO support_tickets (user_id,email,subject,message,category,status,created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)`, - users[t.user].id, t.email, t.subject, t.msg, t.cat, t.status, daysAgo(randBetween(0, 10))) - } - fmt.Printf("%d tickets\n", len(ticketData)) - } - - // ═════════════════════════════════════════════════════════════════════════ - // API KEYS (developer portal) - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "api_keys") == 0 { - fmt.Print("Creating API keys... ") - apiKeyData := []struct{ user int; name string; scopes string }{ - {1, "Amelie Production Bot", "{read,write,tracks}"}, - {2, "Marcus Beat Distributor", "{read,tracks,marketplace}"}, - } - for _, k := range apiKeyData { - prefix := fmt.Sprintf("veza_%s", uuid.NewString()[:8]) - hashedKey, _ := bcrypt.GenerateFromPassword([]byte(uuid.NewString()), 10) - tryExec(db, `INSERT INTO api_keys (user_id,name,prefix,hashed_key,scopes,created_at) VALUES ($1,$2,$3,$4,$5::text[],NOW())`, - users[k.user].id, k.name, prefix, string(hashedKey), k.scopes) - } - fmt.Printf("%d keys\n", len(apiKeyData)) - } - - // ═════════════════════════════════════════════════════════════════════════ - // ANALYTICS EVENTS (general platform events) - // ═════════════════════════════════════════════════════════════════════════ - if countRows(db, "analytics_events") == 0 { - fmt.Print("Creating analytics events... ") - c := 0 - eventTypes := []string{"page_view", "track_play", "search", "playlist_create", "follow", "signup", "login"} - for d := 0; d < 14; d++ { - numEvents := randBetween(20, 80) - for e := 0; e < numEvents; e++ { - userIdx := rand.Intn(len(users)) - evt := eventTypes[rand.Intn(len(eventTypes))] - tryExec(db, `INSERT INTO analytics_events (event_name,user_id,payload,created_at) VALUES ($1,$2,$3,$4)`, - evt, users[userIdx].id, fmt.Sprintf(`{"source":"web","page":"/dashboard","session_id":"%s"}`, uuid.NewString()[:8]), - daysAgo(d).Add(time.Duration(randBetween(0, 86400))*time.Second)) - c++ - } - } - fmt.Printf("%d events\n", c) - } - - // ═════════════════════════════════════════════════════════════════════════ - // SUMMARY - // ═════════════════════════════════════════════════════════════════════════ - fmt.Println() - fmt.Println("╔═══════════════════════════════════════════════╗") - fmt.Println("║ Seed Complete! ║") - fmt.Println("╚═══════════════════════════════════════════════╝") + fmt.Println("╔═══════════════════════════════════════════════════════════╗") + fmt.Println("║ Seed Complete! ║") + fmt.Println("╚═══════════════════════════════════════════════════════════╝") fmt.Println() tables := []string{ - "users", "tracks", "playlists", "follows", "rooms", "messages", - "track_plays", "track_likes", "comments", "notifications", - "products", "orders", "order_items", "daily_track_stats", - "courses", "lessons", "course_enrollments", "gear_items", - "live_streams", "announcements", "support_tickets", "api_keys", "analytics_events", - } - for _, t := range tables { - fmt.Printf(" %-24s %d rows\n", t, countRows(db, t)) + "users", "user_profiles", "user_settings", "user_roles", + "tracks", "track_genres", "track_tags", + "playlists", "playlist_tracks", "playlist_follows", + "follows", "track_likes", "track_reposts", "track_comments", "comments", "user_blocks", + "rooms", "room_members", "messages", + "live_streams", "co_listening_sessions", + "products", "orders", "order_items", "product_reviews", + "seller_stripe_accounts", "seller_balances", + "track_plays", "playback_history", "daily_track_stats", + "geographic_play_stats", "analytics_events", + "files", "user_storage_quotas", + "courses", "lessons", "course_enrollments", + "gear_items", + "groups", "group_members", + "reports", "moderation_actions", "user_strikes", "user_suspensions", + "notifications", "notification_preferences", + "support_tickets", "api_keys", "announcements", + "data_exports", "login_history", "user_preferences", } + totalRows := 0 + for _, t := range tables { + n := CountRows(db, t) + totalRows += n + fmt.Printf(" %-30s %7d rows\n", t, n) + } + + fmt.Printf("\n %-30s %7d rows\n", "TOTAL", totalRows) + fmt.Printf(" %-30s %s\n", "Duration", elapsed.Round(time.Millisecond)) + + fmt.Println("\n--- Test Accounts ---") + for _, ta := range testAccounts { + fmt.Printf(" %-12s %-25s password: %s\n", ta.Role, ta.Email, ta.Password) + } fmt.Println() - fmt.Println("--- Login Credentials (Password123! for all) ---") - fmt.Println(" Admin: admin@veza.fr") - fmt.Println(" Creator: amelie@veza.fr / marcus@veza.fr / sakura@veza.fr") - fmt.Println(" Creator: djrenzo@veza.fr / clara@veza.fr") - fmt.Println(" Listener: listener1@veza.fr / listener2@veza.fr / listener3@veza.fr") - fmt.Println(" Moderator: mod@veza.fr") - fmt.Println() - fmt.Println(" Dashboard: http://veza.fr:5173/dashboard") +} + +func modeName(cfg Config) string { + if cfg.TotalUsers <= 100 { + return "MINIMAL" + } + return "FULL" +} + +// validate checks data integrity. +func validate(db *sql.DB) { + checks := []struct { + name string + query string + }{ + {"test accounts exist", "SELECT COUNT(*) FROM users WHERE email IN ('admin@veza.music','artist@veza.music','user@veza.music','mod@veza.music','new@veza.music')"}, + {"no orphan track_plays", "SELECT COUNT(*) FROM track_plays tp LEFT JOIN tracks t ON tp.track_id = t.id WHERE t.id IS NULL"}, + {"no orphan follows", "SELECT COUNT(*) FROM follows f LEFT JOIN users u ON f.follower_id = u.id WHERE u.id IS NULL"}, + {"no orphan playlist_tracks", "SELECT COUNT(*) FROM playlist_tracks pt LEFT JOIN playlists p ON pt.playlist_id = p.id WHERE p.id IS NULL"}, + {"no orphan messages", "SELECT COUNT(*) FROM messages m LEFT JOIN rooms r ON m.room_id = r.id WHERE r.id IS NULL"}, + {"no orphan order_items", "SELECT COUNT(*) FROM order_items oi LEFT JOIN orders o ON oi.order_id = o.id WHERE o.id IS NULL"}, + } + + for _, c := range checks { + var n int + err := db.QueryRow(c.query).Scan(&n) + if err != nil { + fmt.Printf(" ⚠ %s: query error: %v\n", c.name, err) + continue + } + if c.name == "test accounts exist" { + if n == 5 { + fmt.Printf(" ✓ %s: %d/5\n", c.name, n) + } else { + fmt.Printf(" ✗ %s: only %d/5 found!\n", c.name, n) + } + } else { + if n == 0 { + fmt.Printf(" ✓ %s: OK\n", c.name) + } else { + fmt.Printf(" ✗ %s: %d orphans found!\n", c.name, n) + } + } + } } diff --git a/veza-backend-api/cmd/tools/seed/seed_analytics.go b/veza-backend-api/cmd/tools/seed/seed_analytics.go new file mode 100644 index 000000000..3ca57eba7 --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_analytics.go @@ -0,0 +1,211 @@ +package main + +import ( + "database/sql" + "fmt" + "time" +) + +// SeedAnalytics creates track_plays, playback_history, daily_track_stats, +// geographic_play_stats, and analytics_events. +func SeedAnalytics(db *sql.DB, cfg Config, users []SeededUser, tracks []SeededTrack) error { + fmt.Println("\n═══ ANALYTICS ═══") + + if len(tracks) == 0 || len(users) == 0 { + return nil + } + + // ── 1. Track plays (the big one) ───────────────────────────────────────── + p := NewProgress("track_plays", cfg.PlayEvents) + playRows := make([][]interface{}, 0, cfg.PlayEvents) + + monthsBack := cfg.AnalyticsMonths + startDate := time.Now().AddDate(0, -monthsBack, 0) + + for i := 0; i < cfg.PlayEvents; i++ { + user := users[rng.Intn(len(users))] + // Power-law: popular tracks get way more plays + track := tracks[PowerLaw(0, len(tracks)-1, 1.5)] + + playedAt := RandomTimeBetween(startDate, time.Now()) + playedAt = RealisticHour(playedAt) + + // Duration: 30-100% of track (most listen to 60%+) + pctListened := randInt(30, 100) + duration := track.Duration * pctListened / 100 + if duration < 1 { + duration = 1 + } + + playRows = append(playRows, []interface{}{ + newUUID(), track.ID, user.ID, duration, + playedAt, nil, nil, GenUserAgent(), + GenSource(), GenCountry(), + playedAt, playedAt, nil, + }) + } + + _, err := BulkInsert(db, "track_plays", + "id, track_id, user_id, duration, played_at, device, ip_address, user_agent, source, country_code, created_at, updated_at, deleted_at", + playRows) + if err != nil { + return fmt.Errorf("insert track_plays: %w", err) + } + p.Update(cfg.PlayEvents) + p.Done() + + // ── 2. Playback history ────────────────────────────────────────────────── + historyCount := cfg.PlayEvents / 5 // 20% of plays recorded in history + p = NewProgress("playback_history", historyCount) + historyRows := make([][]interface{}, 0, historyCount) + + for i := 0; i < historyCount; i++ { + user := users[rng.Intn(len(users))] + track := tracks[rng.Intn(len(tracks))] + playedAt := RandomTimeBetween(startDate, time.Now()) + duration := randInt(30, track.Duration) + completion := 0 + if track.Duration > 0 { + completion = duration * 100 / track.Duration + } + if completion > 100 { + completion = 100 + } + source := GenSource() + device := pick([]string{"desktop", "mobile", "tablet"}) + + historyRows = append(historyRows, []interface{}{ + newUUID(), user.ID, track.ID, + duration, completion, source, nil, device, playedAt, + }) + } + + _, err = BulkInsert(db, "playback_history", + "id, user_id, track_id, played_duration, completion_percentage, source, source_id, device_type, played_at", + historyRows) + if err != nil { + return fmt.Errorf("insert playback_history: %w", err) + } + p.Update(historyCount) + p.Done() + + // ── 3. Daily track stats ───────────────────────────────────────────────── + // Top 200 tracks, 180 days of stats + topTrackCount := len(tracks) / 5 + if topTrackCount > 200 { + topTrackCount = 200 + } + days := monthsBack * 30 + statsCount := topTrackCount * days + p = NewProgress("daily_track_stats", statsCount) + statsRows := make([][]interface{}, 0, statsCount) + + for ti := 0; ti < topTrackCount; ti++ { + track := tracks[ti] + for d := 0; d < days; d++ { + date := time.Now().AddDate(0, 0, -d).Format("2006-01-02") + // Growth curve: more plays for newer days + dayMultiplier := float64(days-d) / float64(days) + basePlays := PowerLaw(0, 50, 1.0) + totalPlays := int(float64(basePlays) * (0.5 + dayMultiplier)) + if totalPlays < 0 { + totalPlays = 0 + } + uniqueListeners := totalPlays * randInt(50, 90) / 100 + if uniqueListeners < 0 { + uniqueListeners = 0 + } + completeListens := uniqueListeners * randInt(40, 80) / 100 + totalPlayTime := totalPlays * track.Duration * randInt(60, 100) / 100 + avgCompletion := randFloat(0.4, 0.95) + + statsRows = append(statsRows, []interface{}{ + track.ID, date, totalPlays, uniqueListeners, + completeListens, totalPlayTime, avgCompletion, + }) + } + } + + _, err = BulkInsertRaw(db, "daily_track_stats", + "track_id, date, total_plays, unique_listeners, complete_listens, total_play_time, avg_completion_rate", + statsRows, "ON CONFLICT (track_id, date) DO NOTHING") + if err != nil { + return fmt.Errorf("insert daily_track_stats: %w", err) + } + p.Update(len(statsRows)) + p.Done() + + // ── 4. Geographic play stats ───────────────────────────────────────────── + geoCount := topTrackCount * 10 // ~10 countries per top track + p = NewProgress("geographic_play_stats", geoCount) + geoRows := make([][]interface{}, 0, geoCount) + + for ti := 0; ti < topTrackCount; ti++ { + track := tracks[ti] + countries := pickN(countryCodes, randInt(3, 10)) + for _, cc := range countries { + date := time.Now().AddDate(0, 0, -randInt(0, days)).Format("2006-01-02") + geoRows = append(geoRows, []interface{}{ + newUUID(), track.ID, cc, "", date, + int64(randInt(1, 5000)), int64(randInt(1, 3000)), + time.Now(), time.Now(), + }) + } + } + + _, _ = BulkInsert(db, "geographic_play_stats", + "id, track_id, country_code, region, date, play_count, unique_listeners, created_at, updated_at", + geoRows) + p.Update(len(geoRows)) + p.Done() + + // ── 5. Analytics events ────────────────────────────────────────────────── + eventCount := cfg.PlayEvents / 4 + p = NewProgress("analytics_events", eventCount) + eventRows := make([][]interface{}, 0, eventCount) + eventTypes := []string{ + "page_view", "track_play", "search", "playlist_create", + "follow", "signup", "login", "track_upload", "profile_view", + "playlist_view", "marketplace_view", "product_view", + } + + for i := 0; i < eventCount; i++ { + user := users[rng.Intn(len(users))] + evt := pick(eventTypes) + createdAt := RandomTimeBetween(startDate, time.Now()) + createdAt = RealisticHour(createdAt) + payload := fmt.Sprintf(`{"source":"%s","page":"/dashboard","session_id":"%s"}`, GenSource(), newUUID()[:8]) + + eventRows = append(eventRows, []interface{}{ + newUUID(), evt, user.ID, payload, createdAt, + }) + } + + _, err = BulkInsert(db, "analytics_events", + "id, event_name, user_id, payload, created_at", + eventRows) + if err != nil { + return fmt.Errorf("insert analytics_events: %w", err) + } + p.Update(eventCount) + p.Done() + + // ── 6. Update track play/like counts ───────────────────────────────────── + p = NewProgress("update track counts", 1) + _, _ = db.Exec("UPDATE tracks SET play_count = (SELECT COUNT(*) FROM track_plays WHERE track_plays.track_id = tracks.id)") + _, _ = db.Exec("UPDATE tracks SET like_count = (SELECT COUNT(*) FROM track_likes WHERE track_likes.track_id = tracks.id)") + _, _ = db.Exec("UPDATE tracks SET comment_count = (SELECT COUNT(*) FROM track_comments WHERE track_comments.track_id = tracks.id)") + p.Update(1) + p.Done() + + // ── 7. Update user profile counts ──────────────────────────────────────── + p = NewProgress("update profile counts", 1) + _, _ = db.Exec("UPDATE user_profiles SET follower_count = (SELECT COUNT(*) FROM follows WHERE follows.followed_id = user_profiles.user_id)") + _, _ = db.Exec("UPDATE user_profiles SET following_count = (SELECT COUNT(*) FROM follows WHERE follows.follower_id = user_profiles.user_id)") + _, _ = db.Exec("UPDATE user_profiles SET track_count = (SELECT COUNT(*) FROM tracks WHERE tracks.creator_id = user_profiles.user_id AND tracks.deleted_at IS NULL)") + _, _ = db.Exec("UPDATE user_profiles SET playlist_count = (SELECT COUNT(*) FROM playlists WHERE playlists.user_id = user_profiles.user_id AND playlists.deleted_at IS NULL)") + p.Update(1) + p.Done() + + return nil +} diff --git a/veza-backend-api/cmd/tools/seed/seed_chat.go b/veza-backend-api/cmd/tools/seed/seed_chat.go new file mode 100644 index 000000000..00492a845 --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_chat.go @@ -0,0 +1,169 @@ +package main + +import ( + "database/sql" + "fmt" + "time" +) + +// SeededRoom holds room data. +type SeededRoom struct { + ID string + CreatorID string + Name string +} + +// SeedChat creates rooms, room_members, messages, read_receipts, and message_reactions. +func SeedChat(db *sql.DB, cfg Config, users []SeededUser) ([]SeededRoom, error) { + fmt.Println("\n═══ CHAT ═══") + + rooms := make([]SeededRoom, 0, cfg.Conversations) + allUsers := users + + // ── 1. Create rooms ────────────────────────────────────────────────────── + // Mix of group rooms and DM rooms + groupCount := cfg.Conversations / 5 // 20% group rooms + dmCount := cfg.Conversations - groupCount // 80% DMs + + p := NewProgress("rooms", cfg.Conversations) + roomRows := make([][]interface{}, 0, cfg.Conversations) + + // Group rooms + for i := 0; i < groupCount; i++ { + id := newUUID() + creator := allUsers[rng.Intn(len(allUsers))] + name := pick(roomNames) + if i >= len(roomNames) { + name = fmt.Sprintf("%s #%d", pick(roomNames), i) + } + slug := fmt.Sprintf("room-%d", i) + createdAt := RandomTimeAfter(creator.CreatedAt) + + r := SeededRoom{ID: id, CreatorID: creator.ID, Name: name} + rooms = append(rooms, r) + + roomRows = append(roomRows, []interface{}{ + id, name, slug, fmt.Sprintf("Group chat: %s", name), + "group", false, nil, 50, // room_type, is_private, password_hash, max_members + creator.ID, 0, 0, creator.ID, true, + createdAt, createdAt, nil, + }) + } + + // DM rooms + for i := 0; i < dmCount; i++ { + id := newUUID() + user1 := allUsers[rng.Intn(len(allUsers))] + user2 := allUsers[rng.Intn(len(allUsers))] + if user1.ID == user2.ID { + continue + } + slug := fmt.Sprintf("dm-%d", i) + createdAt := RandomTimeAfter(user1.CreatedAt) + + r := SeededRoom{ID: id, CreatorID: user1.ID, Name: "DM"} + rooms = append(rooms, r) + + roomRows = append(roomRows, []interface{}{ + id, nil, slug, nil, + "direct", true, nil, 2, + user1.ID, 0, 0, user1.ID, true, + createdAt, createdAt, nil, + }) + } + + _, err := BulkInsert(db, "rooms", + "id, name, slug, description, room_type, is_private, password_hash, max_members, creator_id, member_count, message_count, owner_id, is_active, created_at, updated_at, deleted_at", + roomRows) + if err != nil { + return nil, fmt.Errorf("insert rooms: %w", err) + } + p.Update(len(roomRows)) + p.Done() + + // ── 2. Room members ────────────────────────────────────────────────────── + p = NewProgress("room_members", len(rooms)*5) + memberRows := make([][]interface{}, 0, len(rooms)*5) + memberSeen := make(map[string]bool) + + for ri, room := range rooms { + // Always add the creator as owner + key := room.ID + ":" + room.CreatorID + if !memberSeen[key] { + memberSeen[key] = true + memberRows = append(memberRows, []interface{}{ + newUUID(), room.ID, room.CreatorID, "owner", false, false, nil, + time.Now(), time.Now(), time.Now(), nil, + }) + } + + // Add members + var memberCount int + if ri < groupCount { + memberCount = randInt(3, 20) // group rooms + } else { + memberCount = 1 // DM: just the other person + } + + for j := 0; j < memberCount; j++ { + member := allUsers[rng.Intn(len(allUsers))] + key = room.ID + ":" + member.ID + if memberSeen[key] { + continue + } + memberSeen[key] = true + memberRows = append(memberRows, []interface{}{ + newUUID(), room.ID, member.ID, "member", false, false, nil, + time.Now(), time.Now(), time.Now(), nil, + }) + } + } + + _, err = BulkInsert(db, "room_members", + "id, room_id, user_id, role, is_banned, is_muted, last_read_at, joined_at, created_at, updated_at, deleted_at", + memberRows) + if err != nil { + return nil, fmt.Errorf("insert room_members: %w", err) + } + p.Update(len(memberRows)) + p.Done() + + // ── 3. Messages ────────────────────────────────────────────────────────── + p = NewProgress("messages", cfg.Messages) + msgRows := make([][]interface{}, 0, cfg.Messages) + + // Distribute messages across rooms (more active rooms get more messages) + for i := 0; i < cfg.Messages; i++ { + room := rooms[PowerLaw(0, len(rooms)-1, 1.0)] + sender := allUsers[rng.Intn(len(allUsers))] + content := GenMessage() + + // Space messages over time + baseTime := DaysAgo(180) + msgTime := RandomTimeBetween(baseTime, time.Now()) + msgTime = RealisticHour(msgTime) + + msgRows = append(msgRows, []interface{}{ + newUUID(), room.ID, sender.ID, content, + "text", nil, nil, // message_type, attachment_file_id, reply_to_id + false, nil, false, false, nil, // is_edited, edited_at, is_deleted, is_pinned, metadata + sender.ID, nil, // user_id, parent_id + msgTime, nil, msgTime, + }) + } + + _, err = BulkInsert(db, "messages", + "id, room_id, sender_id, content, message_type, attachment_file_id, reply_to_id, is_edited, edited_at, is_deleted, is_pinned, metadata, user_id, parent_id, created_at, deleted_at, updated_at", + msgRows) + if err != nil { + return nil, fmt.Errorf("insert messages: %w", err) + } + p.Update(len(msgRows)) + p.Done() + + // Update room message_count and member_count + _, _ = db.Exec("UPDATE rooms SET message_count = (SELECT COUNT(*) FROM messages WHERE messages.room_id = rooms.id)") + _, _ = db.Exec("UPDATE rooms SET member_count = (SELECT COUNT(*) FROM room_members WHERE room_members.room_id = rooms.id)") + + return rooms, nil +} diff --git a/veza-backend-api/cmd/tools/seed/seed_content.go b/veza-backend-api/cmd/tools/seed/seed_content.go new file mode 100644 index 000000000..3655db775 --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_content.go @@ -0,0 +1,288 @@ +package main + +import ( + "database/sql" + "fmt" + "time" +) + +// SeedContent creates files, user_storage_quotas, courses, lessons, gear_items, groups. +func SeedContent(db *sql.DB, cfg Config, users []SeededUser, tracks []SeededTrack) error { + fmt.Println("\n═══ CONTENT ═══") + + artists := GetArtists(users) + + // ── 1. Files (simulated entries for tracks and avatars) ────────────────── + p := NewProgress("files", cfg.FileEntries) + fileRows := make([][]interface{}, 0, cfg.FileEntries) + + // Audio files for tracks + for i, t := range tracks { + if i >= cfg.FileEntries*2/3 { + break + } + fileSize := int64(t.Duration) * int64(randInt(16000, 32000)) + storagePath := fmt.Sprintf("audio/%s.mp3", t.ID) + url := fmt.Sprintf("https://cdn.veza.music/%s", storagePath) + + fileRows = append(fileRows, []interface{}{ + newUUID(), t.CreatorID, + fmt.Sprintf("%s.mp3", t.ID), fmt.Sprintf("%s.mp3", t.Title), + "audio/mpeg", fileSize, storagePath, "s3", + "veza-audio", url, nil, // thumbnail_url + fmt.Sprintf("%x", rng.Int63()), nil, // file_hash, metadata + true, time.Now(), nil, // is_processed, processed_at, processing_error + true, "clean", time.Now(), // virus_scanned, virus_scan_result, virus_scanned_at + false, // is_public + time.Now(), time.Now(), nil, + }) + } + + // Avatar files for users + avatarCount := cfg.FileEntries / 3 + for i := 0; i < avatarCount && i < len(users); i++ { + u := users[i] + fileSize := int64(randInt(50000, 500000)) // 50KB - 500KB + storagePath := fmt.Sprintf("avatars/%s.jpg", u.ID) + url := fmt.Sprintf("https://cdn.veza.music/%s", storagePath) + + fileRows = append(fileRows, []interface{}{ + newUUID(), u.ID, + fmt.Sprintf("%s.jpg", u.ID), "avatar.jpg", + "image/jpeg", fileSize, storagePath, "s3", + "veza-avatars", url, nil, + fmt.Sprintf("%x", rng.Int63()), nil, + true, time.Now(), nil, + true, "clean", time.Now(), + true, + u.CreatedAt, u.CreatedAt, nil, + }) + } + + _, err := BulkInsert(db, "files", + "id, user_id, filename, original_filename, mime_type, file_size, storage_path, storage_provider, bucket_name, url, thumbnail_url, file_hash, metadata, is_processed, processed_at, processing_error, virus_scanned, virus_scan_result, virus_scanned_at, is_public, created_at, updated_at, deleted_at", + fileRows) + if err != nil { + return fmt.Errorf("insert files: %w", err) + } + p.Update(len(fileRows)) + p.Done() + + // ── 2. User storage quotas ─────────────────────────────────────────────── + p = NewProgress("user_storage_quotas", len(artists)) + quotaRows := make([][]interface{}, 0, len(artists)) + for _, a := range artists { + usedBytes := int64(randInt(100, 5000)) * 1024 * 1024 // 100MB - 5GB + maxBytes := int64(10) * 1024 * 1024 * 1024 // 10GB + quotaRows = append(quotaRows, []interface{}{ + newUUID(), a.ID, usedBytes, maxBytes, time.Now(), time.Now(), + }) + } + _, _ = BulkInsert(db, "user_storage_quotas", + "id, user_id, used_bytes, max_bytes, created_at, updated_at", + quotaRows) + p.Update(len(quotaRows)) + p.Done() + + // ── 3. Courses & Lessons ───────────────────────────────────────────────── + p = NewProgress("courses", cfg.Courses) + courseRows := make([][]interface{}, 0, cfg.Courses) + type courseInfo struct { + id string + lessonCount int + } + courses := make([]courseInfo, 0, cfg.Courses) + + courseCategories := []string{"production", "mixing", "sound-design", "songwriting", "djing", "mastering"} + levels := []string{"beginner", "intermediate", "advanced"} + lessonTitles := []string{ + "Getting Started", "Setting Up Your DAW", "Understanding Audio Basics", + "Your First Beat", "Melody and Harmony", "Sound Design Fundamentals", + "Arrangement Techniques", "Mixing Basics", "EQ and Compression", + "Effects and Processing", "Mastering Your Track", "Final Project", + "Advanced Techniques", "Creative Workflows", "Industry Standards", + } + + for i := 0; i < cfg.Courses && i < len(artists); i++ { + id := newUUID() + artist := artists[i%len(artists)] + title := fmt.Sprintf("%s Masterclass — %s", GenreForArtist(i)[0].Name, pick([]string{"From Zero to Pro", "Complete Guide", "Deep Dive", "Essentials", "Workshop"})) + slug := fmt.Sprintf("course-%d", i) + category := pick(courseCategories) + level := pick(levels) + price := randInt(0, 9999) // 0 = free + lessonCount := randInt(5, 15) + createdAt := RandomTimeAfter(artist.CreatedAt) + + var publishedAt interface{} = createdAt + status := "published" + if randChance(10) { + status = "draft" + publishedAt = nil + } + + c := courseInfo{id: id, lessonCount: lessonCount} + courses = append(courses, c) + + courseRows = append(courseRows, []interface{}{ + id, artist.ID, title, slug, + fmt.Sprintf("Learn %s with %s. Comprehensive course covering everything from basics to advanced techniques.", category, artist.DisplayName), + category, fmt.Sprintf("{music,production,%s}", category), + price, "EUR", "fixed", status, level, "fr", + lessonCount, publishedAt, createdAt, createdAt, + }) + } + + _, err = BulkInsert(db, "courses", + "id, creator_id, title, slug, description, category, tags, price_cents, currency, pricing_model, status, level, language, lesson_count, published_at, created_at, updated_at", + courseRows) + if err != nil { + return fmt.Errorf("insert courses: %w", err) + } + p.Update(len(courseRows)) + p.Done() + + // Lessons + totalLessons := 0 + for _, c := range courses { + totalLessons += c.lessonCount + } + p = NewProgress("lessons", totalLessons) + lessonRows := make([][]interface{}, 0, totalLessons) + for _, c := range courses { + for li := 0; li < c.lessonCount; li++ { + title := lessonTitles[li%len(lessonTitles)] + lessonRows = append(lessonRows, []interface{}{ + newUUID(), c.id, li, title, + fmt.Sprintf("Lesson %d: %s", li+1, title), + randInt(300, 1800), // duration 5-30min + li < 2, // first 2 lessons free preview + "completed", + }) + } + } + _, _ = BulkInsert(db, "lessons", + "id, course_id, order_index, title, description, duration_seconds, is_preview_free, transcoding_status", + lessonRows) + p.Update(totalLessons) + p.Done() + + // Course enrollments + enrollCount := cfg.Courses * 5 + p = NewProgress("course_enrollments", enrollCount) + enrollRows := make([][]interface{}, 0, enrollCount) + enrollSeen := make(map[string]bool) + for i := 0; i < enrollCount*2 && len(enrollRows) < enrollCount; i++ { + c := courses[rng.Intn(len(courses))] + u := users[rng.Intn(len(users))] + key := c.id + ":" + u.ID + if enrollSeen[key] { + continue + } + enrollSeen[key] = true + enrollRows = append(enrollRows, []interface{}{ + newUUID(), u.ID, c.id, "active", time.Now(), + }) + } + _, _ = BulkInsert(db, "course_enrollments", + "id, user_id, course_id, status, purchased_at", + enrollRows) + p.Update(len(enrollRows)) + p.Done() + + // ── 4. Gear items ──────────────────────────────────────────────────────── + p = NewProgress("gear_items", cfg.GearItems) + gearRows := make([][]interface{}, 0, cfg.GearItems) + conditions := []string{"Excellent", "Good", "Fair"} + + for i := 0; i < cfg.GearItems; i++ { + artist := artists[rng.Intn(len(artists))] + gear := pick(gearTemplates) + purchaseDate := RandomTimeAfter(artist.CreatedAt).Format("2006-01-02") + gearRows = append(gearRows, []interface{}{ + newUUID(), artist.ID, gear.Name, gear.Category, gear.Brand, gear.Model, + "Active", pick(conditions), gear.Price, "EUR", purchaseDate, + true, time.Now(), time.Now(), + }) + } + _, _ = BulkInsert(db, "gear_items", + "id, user_id, name, category, brand, model, status, condition, purchase_price, currency, purchase_date, is_public, created_at, updated_at", + gearRows) + p.Update(cfg.GearItems) + p.Done() + + // ── 5. Groups ──────────────────────────────────────────────────────────── + p = NewProgress("groups", cfg.Groups) + groupRows := make([][]interface{}, 0, cfg.Groups) + type groupInfo struct { + id string + creatorID string + } + groups := make([]groupInfo, 0, cfg.Groups) + + groupNames := []string{ + "Electronic Producers", "Hip-Hop Collective", "Ambient Sound Lab", + "Jazz Fusion Circle", "Indie Artists Network", "Vinyl Enthusiasts", + "Sound Design Guild", "Remix Community", "Songwriters Hub", + "Studio Gear Talk", "Music Theory Nerds", "Live Performance Group", + "Lo-Fi Producers", "Techno Underground", "Acoustic Sessions", + } + + for i := 0; i < cfg.Groups; i++ { + id := newUUID() + creator := users[rng.Intn(len(users))] + name := groupNames[i%len(groupNames)] + if i >= len(groupNames) { + name = fmt.Sprintf("%s #%d", pick(groupNames), i) + } + createdAt := RandomTimeAfter(creator.CreatedAt) + g := groupInfo{id: id, creatorID: creator.ID} + groups = append(groups, g) + + groupRows = append(groupRows, []interface{}{ + id, name, fmt.Sprintf("A community for %s enthusiasts", name), + creator.ID, nil, true, 1, createdAt, createdAt, nil, + }) + } + _, _ = BulkInsert(db, "groups", + "id, name, description, creator_id, avatar_url, is_public, member_count, created_at, updated_at, deleted_at", + groupRows) + p.Update(cfg.Groups) + p.Done() + + // Group members + memberCount := cfg.Groups * 15 + p = NewProgress("group_members", memberCount) + gmRows := make([][]interface{}, 0, memberCount) + gmSeen := make(map[string]bool) + for _, g := range groups { + // Add creator + key := g.id + ":" + g.creatorID + gmSeen[key] = true + gmRows = append(gmRows, []interface{}{ + newUUID(), g.id, g.creatorID, "admin", time.Now(), time.Now(), + }) + // Add random members + count := randInt(5, 30) + for j := 0; j < count; j++ { + u := users[rng.Intn(len(users))] + key = g.id + ":" + u.ID + if gmSeen[key] { + continue + } + gmSeen[key] = true + gmRows = append(gmRows, []interface{}{ + newUUID(), g.id, u.ID, "member", time.Now(), time.Now(), + }) + } + } + _, _ = BulkInsert(db, "group_members", + "id, group_id, user_id, role, joined_at, created_at", + gmRows) + // Update member counts + _, _ = db.Exec("UPDATE groups SET member_count = (SELECT COUNT(*) FROM group_members WHERE group_members.group_id = groups.id)") + p.Update(len(gmRows)) + p.Done() + + return nil +} diff --git a/veza-backend-api/cmd/tools/seed/seed_live.go b/veza-backend-api/cmd/tools/seed/seed_live.go new file mode 100644 index 000000000..5d68770ec --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_live.go @@ -0,0 +1,91 @@ +package main + +import ( + "database/sql" + "fmt" + "time" +) + +// SeedLive creates live_streams and co_listening_sessions. +func SeedLive(db *sql.DB, cfg Config, users []SeededUser, tracks []SeededTrack) error { + fmt.Println("\n═══ LIVE STREAMS ═══") + + artists := GetArtists(users) + if len(artists) == 0 { + return nil + } + + // ── 1. Past live streams ───────────────────────────────────────────────── + p := NewProgress("live_streams (past)", cfg.PastLiveStreams) + liveRows := make([][]interface{}, 0, cfg.PastLiveStreams+cfg.LiveStreams) + + for i := 0; i < cfg.PastLiveStreams; i++ { + artist := artists[rng.Intn(len(artists))] + title := pick(liveStreamTitles) + desc := fmt.Sprintf("Live session by %s", artist.DisplayName) + category := pick([]string{"dj-set", "production", "performance", "tutorial", "q&a"}) + + startTime := RandomTimeAfter(artist.CreatedAt) + viewerCount := PowerLaw(1, 500, 1.5) + + liveRows = append(liveRows, []interface{}{ + newUUID(), artist.ID, title, desc, category, + artist.DisplayName, false, viewerCount, + "[]", // tags (jsonb) + startTime, startTime, startTime, + }) + } + p.Update(cfg.PastLiveStreams) + p.Done() + + // ── 2. Current live streams ────────────────────────────────────────────── + p = NewProgress("live_streams (active)", cfg.LiveStreams) + for i := 0; i < cfg.LiveStreams && i < len(artists); i++ { + artist := artists[i] + title := pick(liveStreamTitles) + desc := fmt.Sprintf("LIVE NOW: %s", artist.DisplayName) + category := pick([]string{"dj-set", "production", "performance"}) + startTime := time.Now().Add(-time.Duration(randInt(5, 90)) * time.Minute) + viewerCount := randInt(5, 150) + + liveRows = append(liveRows, []interface{}{ + newUUID(), artist.ID, title, desc, category, + artist.DisplayName, true, viewerCount, + "[]", + startTime, startTime, startTime, + }) + } + p.Update(cfg.LiveStreams) + p.Done() + + _, err := BulkInsert(db, "live_streams", + "id, user_id, title, description, category, streamer_name, is_live, viewer_count, tags, scheduled_at, created_at, updated_at", + liveRows) + if err != nil { + return fmt.Errorf("insert live_streams: %w", err) + } + + // ── 3. Co-listening sessions ───────────────────────────────────────────── + coCount := cfg.PastLiveStreams / 10 + if coCount < 5 { + coCount = 5 + } + p = NewProgress("co_listening_sessions", coCount) + coRows := make([][]interface{}, 0, coCount) + for i := 0; i < coCount && len(tracks) > 0; i++ { + host := users[rng.Intn(len(users))] + track := tracks[rng.Intn(len(tracks))] + createdAt := RandomTimeAfter(host.CreatedAt) + expiresAt := createdAt.Add(4 * time.Hour) + coRows = append(coRows, []interface{}{ + newUUID(), host.ID, track.ID, createdAt, expiresAt, + }) + } + _, _ = BulkInsert(db, "co_listening_sessions", + "id, host_id, track_id, created_at, expires_at", + coRows) + p.Update(coCount) + p.Done() + + return nil +} diff --git a/veza-backend-api/cmd/tools/seed/seed_marketplace.go b/veza-backend-api/cmd/tools/seed/seed_marketplace.go new file mode 100644 index 000000000..41a2b2fcc --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_marketplace.go @@ -0,0 +1,225 @@ +package main + +import ( + "database/sql" + "fmt" + "time" +) + +// SeededProduct holds product data. +type SeededProduct struct { + ID string + SellerID string + Title string + Price float64 +} + +// SeedMarketplace creates products, orders, order_items, product_reviews, +// seller_stripe_accounts, and seller_balances. +func SeedMarketplace(db *sql.DB, cfg Config, users []SeededUser, tracks []SeededTrack) ([]SeededProduct, error) { + fmt.Println("\n═══ MARKETPLACE ═══") + + artists := GetArtists(users) + listeners := GetListeners(users) + if len(artists) == 0 { + return nil, nil + } + + products := make([]SeededProduct, 0, cfg.Products) + + // ── 1. Products ────────────────────────────────────────────────────────── + p := NewProgress("products", cfg.Products) + productRows := make([][]interface{}, 0, cfg.Products) + + for i := 0; i < cfg.Products; i++ { + id := newUUID() + seller := artists[rng.Intn(len(artists))] + genres := GenreForArtist(rng.Intn(len(artists))) + genre := genres[0] + title := GenProductTitle(genre.Name) + desc := fmt.Sprintf("Professional %s content by %s", genre.Slug, seller.DisplayName) + price := float64(randInt(999, 29999)) / 100.0 // €9.99 - €299.99 + ptype := pick(productTypes) + license := pick([]string{"non-exclusive", "exclusive", "non-exclusive"}) + category := pick(productCategories) + bpm := randInt(genre.BPMRange[0], genre.BPMRange[1]) + key := "" + if len(genre.Keys) > 0 { + key = pick(genre.Keys) + } + + // Optionally link to a track + var trackID interface{} + if ptype == "beat" && len(tracks) > 0 && randChance(50) { + // Find a track by this seller + for _, t := range tracks { + if t.CreatorID == seller.ID { + trackID = t.ID + break + } + } + } + + createdAt := RandomTimeAfter(seller.CreatedAt) + prod := SeededProduct{ID: id, SellerID: seller.ID, Title: title, Price: price} + products = append(products, prod) + + productRows = append(productRows, []interface{}{ + id, seller.ID, title, desc, price, "EUR", "active", + ptype, trackID, license, bpm, key, category, + createdAt, createdAt, + }) + } + + _, err := BulkInsert(db, "products", + "id, seller_id, title, description, price, currency, status, product_type, track_id, license_type, bpm, musical_key, category, created_at, updated_at", + productRows) + if err != nil { + return nil, fmt.Errorf("insert products: %w", err) + } + p.Update(len(productRows)) + p.Done() + + // ── 2. Orders ──────────────────────────────────────────────────────────── + p = NewProgress("orders + order_items", cfg.Orders) + orderRows := make([][]interface{}, 0, cfg.Orders) + itemRows := make([][]interface{}, 0, cfg.Orders*2) + + allBuyers := append(listeners, users...) // Mix of listeners and all users + statuses := []string{"completed", "completed", "completed", "completed", "pending", "cancelled"} + + for i := 0; i < cfg.Orders; i++ { + orderID := newUUID() + buyer := allBuyers[rng.Intn(len(allBuyers))] + status := pick(statuses) + createdAt := RandomTimeAfter(buyer.CreatedAt) + + // 1-3 items per order + itemCount := randInt(1, 3) + totalAmount := 0.0 + selectedProducts := pickN(products, itemCount) + + for _, prod := range selectedProducts { + totalAmount += prod.Price + itemRows = append(itemRows, []interface{}{ + newUUID(), orderID, prod.ID, prod.Price, + }) + } + + orderRows = append(orderRows, []interface{}{ + orderID, buyer.ID, totalAmount, "EUR", status, + createdAt, createdAt, + }) + } + + _, err = BulkInsert(db, "orders", + "id, buyer_id, total_amount, currency, status, created_at, updated_at", + orderRows) + if err != nil { + return nil, fmt.Errorf("insert orders: %w", err) + } + + _, err = BulkInsert(db, "order_items", + "id, order_id, product_id, price", + itemRows) + if err != nil { + return nil, fmt.Errorf("insert order_items: %w", err) + } + p.Update(cfg.Orders) + p.Done() + + // ── 3. Product reviews ─────────────────────────────────────────────────── + p = NewProgress("product_reviews", cfg.ProductReviews) + reviewRows := make([][]interface{}, 0, cfg.ProductReviews) + reviewSeen := make(map[string]bool) + + reviewContents := []string{ + "Excellent quality, exactly what I needed!", "Great sounds, well organized.", + "Good value for the price.", "Professional quality samples.", + "Perfect for my latest project.", "Would buy again!", + "Decent pack, some gems in there.", "Not what I expected but still useful.", + "Top-notch production quality.", "These beats are fire!", + "Clean mix, ready to use.", "Amazing variety in this pack.", + } + + for i := 0; i < cfg.ProductReviews*2 && len(reviewRows) < cfg.ProductReviews; i++ { + prod := products[rng.Intn(len(products))] + reviewer := allBuyers[rng.Intn(len(allBuyers))] + if reviewer.ID == prod.SellerID { + continue + } + key := prod.ID + ":" + reviewer.ID + if reviewSeen[key] { + continue + } + reviewSeen[key] = true + reviewRows = append(reviewRows, []interface{}{ + newUUID(), prod.ID, reviewer.ID, + randInt(3, 5), // rating 3-5 (realistic positive bias) + pick(reviewContents), + time.Now(), time.Now(), + }) + } + + _, _ = BulkInsert(db, "product_reviews", + "id, product_id, user_id, rating, content, created_at, updated_at", + reviewRows) + p.Update(len(reviewRows)) + p.Done() + + // ── 4. Seller stripe accounts ──────────────────────────────────────────── + p = NewProgress("seller_stripe_accounts", len(artists)) + sellerRows := make([][]interface{}, 0, len(artists)) + + for _, artist := range artists { + if !randChance(60) { + continue + } + sellerRows = append(sellerRows, []interface{}{ + newUUID(), artist.ID, + fmt.Sprintf("acct_%s", newUUID()[:16]), + true, // is_onboarded + true, // payouts_enabled + true, // charges_enabled + "verified", // kyc_status + nil, nil, // kyc_verification_session_id, kyc_verified_at + nil, // kyc_last_error + time.Now(), time.Now(), + }) + } + + _, _ = BulkInsert(db, "seller_stripe_accounts", + "id, user_id, stripe_account_id, is_onboarded, payouts_enabled, charges_enabled, kyc_status, kyc_verification_session_id, kyc_verified_at, kyc_last_error, created_at, updated_at", + sellerRows) + p.Update(len(sellerRows)) + p.Done() + + // ── 5. Seller balances ─────────────────────────────────────────────────── + p = NewProgress("seller_balances", len(artists)) + balanceRows := make([][]interface{}, 0, len(artists)) + for _, artist := range artists { + if !randChance(50) { + continue + } + totalEarned := int64(randInt(0, 100000)) + totalPaid := totalEarned * int64(randInt(30, 80)) / 100 + available := totalEarned - totalPaid - int64(randInt(0, 10000)) + if available < 0 { + available = 0 + } + pending := int64(randInt(0, 10000)) + + balanceRows = append(balanceRows, []interface{}{ + newUUID(), artist.ID, + available, pending, totalEarned, totalPaid, + "EUR", time.Now(), + }) + } + _, _ = BulkInsert(db, "seller_balances", + "id, seller_id, available_cents, pending_cents, total_earned_cents, total_paid_out_cents, currency, updated_at", + balanceRows) + p.Update(len(balanceRows)) + p.Done() + + return products, nil +} diff --git a/veza-backend-api/cmd/tools/seed/seed_misc.go b/veza-backend-api/cmd/tools/seed/seed_misc.go new file mode 100644 index 000000000..6a1ad159e --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_misc.go @@ -0,0 +1,195 @@ +package main + +import ( + "database/sql" + "fmt" + "time" +) + +// SeedMisc creates support_tickets, api_keys, announcements, data_exports, +// login_history, audit_logs, and user_preferences. +func SeedMisc(db *sql.DB, cfg Config, users []SeededUser) error { + fmt.Println("\n═══ MISC ═══") + + artists := GetArtists(users) + admins := GetAdmins(users) + + // ── 1. Support tickets ─────────────────────────────────────────────────── + p := NewProgress("support_tickets", cfg.SupportTickets) + ticketRows := make([][]interface{}, 0, cfg.SupportTickets) + ticketCategories := []string{"technical", "billing", "general", "feature", "account"} + ticketStatuses := []string{"open", "open", "in_progress", "resolved", "closed"} + ticketSubjects := []string{ + "Cannot upload audio file", "Payment not received", "Account access issue", + "Feature request: dark mode", "Playlist sync problem", "Mobile app crash", + "Stream quality issues", "Profile picture upload failed", "How to sell beats?", + "Collaboration feature not working", "Missing analytics data", "Copyright claim dispute", + "Email notifications not arriving", "Two-factor auth setup", "Delete my account", + } + + for i := 0; i < cfg.SupportTickets; i++ { + user := users[rng.Intn(len(users))] + subject := pick(ticketSubjects) + ticketRows = append(ticketRows, []interface{}{ + newUUID(), user.ID, user.Email, + subject, fmt.Sprintf("Details about: %s. Please help!", subject), + pick(ticketCategories), pick(ticketStatuses), + RandomTimeAfter(user.CreatedAt), + }) + } + _, _ = BulkInsert(db, "support_tickets", + "id, user_id, email, subject, message, category, status, created_at", + ticketRows) + p.Update(cfg.SupportTickets) + p.Done() + + // ── 2. API keys ────────────────────────────────────────────────────────── + p = NewProgress("api_keys", cfg.APIKeys) + apiRows := make([][]interface{}, 0, cfg.APIKeys) + apiNames := []string{ + "Production Bot", "Beat Distributor", "Analytics Dashboard", + "Auto-Upload Script", "Playlist Sync", "Stream Monitor", + } + + for i := 0; i < cfg.APIKeys && i < len(artists); i++ { + artist := artists[i%len(artists)] + prefix := fmt.Sprintf("veza_%s", newUUID()[:8]) + hashedKey := fmt.Sprintf("$2a$10$%s", newUUID()) // Placeholder hash + name := apiNames[i%len(apiNames)] + scopes := pick([]string{"{read,write,tracks}", "{read,tracks,marketplace}", "{read}", "{read,write}"}) + + apiRows = append(apiRows, []interface{}{ + newUUID(), artist.ID, name, prefix, hashedKey, scopes, time.Now(), + }) + } + _, _ = BulkInsert(db, "api_keys", + "id, user_id, name, prefix, hashed_key, scopes, created_at", + apiRows) + p.Update(len(apiRows)) + p.Done() + + // ── 3. Announcements ───────────────────────────────────────────────────── + p = NewProgress("announcements", cfg.Announcements) + annRows := make([][]interface{}, 0, cfg.Announcements) + annData := []struct{ title, content, atype string }{ + {"Welcome to Veza!", "We're thrilled to launch Veza — an ethical music platform built for artists and listeners.", "info"}, + {"Marketplace Now Open", "Buy and sell beats, samples, and presets directly on the platform.", "feature"}, + {"New: Live Streaming", "Stream your sessions live to your followers. Available now for all creators.", "feature"}, + {"Scheduled Maintenance", "Brief maintenance window planned for this weekend. Streams may be briefly interrupted.", "warning"}, + {"Community Guidelines Updated", "We've updated our community guidelines. Please review them.", "info"}, + {"Mobile App Beta", "The mobile app is now in beta. Sign up to be a tester!", "feature"}, + {"Holiday Sale", "Special discounts on marketplace products this week.", "info"}, + {"New Audio Quality Options", "Hi-Res audio streaming is now available for premium users.", "feature"}, + {"Creator Fund Launched", "Earn more from your music with our new creator support fund.", "info"}, + {"Platform Update v2.0", "Major platform update with improved UI, faster streaming, and new features.", "feature"}, + } + + adminID := "" + if len(admins) > 0 { + adminID = admins[0].ID + } else { + adminID = users[0].ID + } + + for i := 0; i < cfg.Announcements && i < len(annData); i++ { + a := annData[i] + annRows = append(annRows, []interface{}{ + newUUID(), a.title, a.content, a.atype, + true, time.Now(), adminID, DaysAgo(cfg.Announcements - i), + }) + } + _, _ = BulkInsert(db, "announcements", + "id, title, content, type, is_active, starts_at, created_by, created_at", + annRows) + p.Update(len(annRows)) + p.Done() + + // ── 4. Data exports (RGPD) ─────────────────────────────────────────────── + p = NewProgress("data_exports", cfg.DataExports) + exportRows := make([][]interface{}, 0, cfg.DataExports) + exportStatuses := []string{"completed", "completed", "pending", "processing"} + + for i := 0; i < cfg.DataExports; i++ { + user := users[rng.Intn(len(users))] + status := pick(exportStatuses) + createdAt := RandomTimeAfter(user.CreatedAt) + expiresAt := createdAt.Add(7 * 24 * time.Hour) // 7 day expiry + + var completedAt interface{} + var s3Key interface{} + var fileSize interface{} + if status == "completed" { + completedAt = RandomTimeAfter(createdAt) + s3Key = fmt.Sprintf("exports/%s/%s.zip", user.ID, newUUID()[:8]) + fs := int64(randInt(1000000, 50000000)) // 1MB - 50MB + fileSize = fs + } + + exportRows = append(exportRows, []interface{}{ + newUUID(), user.ID, status, s3Key, fileSize, + expiresAt, createdAt, completedAt, nil, + }) + } + _, _ = BulkInsert(db, "data_exports", + "id, user_id, status, s3_key, file_size_bytes, expires_at, created_at, completed_at, error_message", + exportRows) + p.Update(cfg.DataExports) + p.Done() + + // ── 5. Login history ───────────────────────────────────────────────────── + loginCount := len(users) * 3 // ~3 login entries per user on average + p = NewProgress("login_history", loginCount) + loginRows := make([][]interface{}, 0, loginCount) + for i := 0; i < loginCount; i++ { + user := users[rng.Intn(len(users))] + loginAt := RandomTimeAfter(user.CreatedAt) + loginAt = RealisticHour(loginAt) + success := randChance(95) // 95% success rate + reason := "" + if !success { + reason = pick([]string{"invalid_password", "account_locked", "expired_session"}) + } + + loginRows = append(loginRows, []interface{}{ + newUUID(), user.ID, GenIP(), GenUserAgent(), + success, reason, loginAt, + }) + } + _, _ = BulkInsert(db, "login_history", + "id, user_id, ip_address, user_agent, success, reason, created_at", + loginRows) + p.Update(loginCount) + p.Done() + + // ── 6. User preferences ────────────────────────────────────────────────── + prefCount := len(users) / 3 // ~33% have explicit preferences + p = NewProgress("user_preferences", prefCount) + prefRows := make([][]interface{}, 0, prefCount) + themes := []string{"light", "dark", "auto"} + langs := []string{"fr", "en", "de", "es"} + contrasts := []string{"normal", "high"} + densities := []string{"comfortable", "compact", "spacious"} + + prefSeen := make(map[string]bool) + for i := 0; i < prefCount; i++ { + user := users[rng.Intn(len(users))] + if prefSeen[user.ID] { + continue + } + prefSeen[user.ID] = true + prefRows = append(prefRows, []interface{}{ + user.ID, pick(themes), pick(langs), "UTC", + "{}", "{}", `{"quality":"high","autoplay":true}`, + pick(contrasts), pick(densities), + randInt(180, 280), randInt(14, 20), + time.Now(), + }) + } + _, _ = BulkInsert(db, "user_preferences", + "user_id, theme, language, timezone, notifications, privacy, audio, contrast, density, accent_hue, font_size, updated_at", + prefRows) + p.Update(len(prefRows)) + p.Done() + + return nil +} diff --git a/veza-backend-api/cmd/tools/seed/seed_moderation.go b/veza-backend-api/cmd/tools/seed/seed_moderation.go new file mode 100644 index 000000000..51d267661 --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_moderation.go @@ -0,0 +1,158 @@ +package main + +import ( + "database/sql" + "fmt" + "time" +) + +// SeedModeration creates reports, moderation_actions, user_strikes, user_suspensions. +func SeedModeration(db *sql.DB, cfg Config, users []SeededUser, tracks []SeededTrack) error { + fmt.Println("\n═══ MODERATION ═══") + + mods := GetModerators(users) + admins := GetAdmins(users) + moderators := append(mods, admins...) + if len(moderators) == 0 { + return nil + } + + // ── 1. Reports ─────────────────────────────────────────────────────────── + p := NewProgress("reports", cfg.Reports) + reportRows := make([][]interface{}, 0, cfg.Reports) + + reportReasons := []string{ + "spam", "harassment", "copyright", "inappropriate_content", + "misleading", "hate_speech", "violence", "impersonation", + } + reportStatuses := []string{"pending", "pending", "reviewed", "resolved", "dismissed"} + + type reportInfo struct { + id string + } + reports := make([]reportInfo, 0, cfg.Reports) + + for i := 0; i < cfg.Reports; i++ { + id := newUUID() + reporter := users[rng.Intn(len(users))] + reason := pick(reportReasons) + status := pick(reportStatuses) + createdAt := RandomTimeAfter(reporter.CreatedAt) + + // Report target: user or track + var reportedUserID interface{} + var contentType string + var contentID interface{} + if randChance(60) && len(tracks) > 0 { + t := tracks[rng.Intn(len(tracks))] + contentType = "track" + contentID = t.ID + } else { + u := users[rng.Intn(len(users))] + reportedUserID = u.ID + contentType = "user" + } + + reports = append(reports, reportInfo{id: id}) + reportRows = append(reportRows, []interface{}{ + id, reporter.ID, reportedUserID, contentType, contentID, + fmt.Sprintf("Reported for %s", reason), + status, nil, nil, // resolved_by, resolved_at + createdAt, + pick(reportReasons), "normal", "", "", nil, // category, priority, resolution_note, resolution_action, assigned_to + createdAt, + }) + } + + _, err := BulkInsert(db, "reports", + "id, reporter_id, reported_user_id, content_type, content_id, reason, status, resolved_by, resolved_at, created_at, category, priority, resolution_note, resolution_action, assigned_to, updated_at", + reportRows) + if err != nil { + return fmt.Errorf("insert reports: %w", err) + } + p.Update(cfg.Reports) + p.Done() + + // ── 2. Moderation actions ──────────────────────────────────────────────── + actionCount := cfg.Reports / 2 // ~50% of reports get an action + p = NewProgress("moderation_actions", actionCount) + actionRows := make([][]interface{}, 0, actionCount) + actionTypes := []string{"warn", "mute", "suspend", "ban", "content_removal", "dismiss"} + + for i := 0; i < actionCount && i < len(reports); i++ { + mod := moderators[rng.Intn(len(moderators))] + action := pick(actionTypes) + targetUser := users[rng.Intn(len(users))] + + actionRows = append(actionRows, []interface{}{ + newUUID(), mod.ID, targetUser.ID, + nil, nil, // target_content_type, target_content_id + action, fmt.Sprintf("Action: %s — reviewed by moderator", action), + "{}", time.Now(), + }) + } + + _, _ = BulkInsert(db, "moderation_actions", + "id, moderator_id, target_user_id, target_content_type, target_content_id, action, reason, metadata, created_at", + actionRows) + p.Update(len(actionRows)) + p.Done() + + // ── 3. User strikes ────────────────────────────────────────────────────── + strikeCount := cfg.Reports / 5 + p = NewProgress("user_strikes", strikeCount) + strikeRows := make([][]interface{}, 0, strikeCount) + strikeReasons := []string{"spam", "harassment", "copyright_violation", "inappropriate_content"} + + for i := 0; i < strikeCount; i++ { + targetUser := users[rng.Intn(len(users))] + mod := moderators[rng.Intn(len(moderators))] + reason := pick(strikeReasons) + severity := pick([]string{"warning", "minor", "major", "critical"}) + expiresAt := time.Now().AddDate(0, 3, 0) // 3 months + + strikeRows = append(strikeRows, []interface{}{ + newUUID(), targetUser.ID, nil, // report_id + reason, severity, mod.ID, + true, false, nil, false, nil, nil, nil, // is_active, appealed, appeal_text, appeal_resolved, appeal_result, appeal_resolved_by, appeal_resolved_at + expiresAt, time.Now(), time.Now(), + }) + } + + _, _ = BulkInsert(db, "user_strikes", + "id, user_id, report_id, reason, severity, issued_by, is_active, appealed, appeal_text, appeal_resolved, appeal_result, appeal_resolved_by, appeal_resolved_at, expires_at, created_at, updated_at", + strikeRows) + p.Update(len(strikeRows)) + p.Done() + + // ── 4. User suspensions (very few) ─────────────────────────────────────── + suspCount := strikeCount / 3 + if suspCount < 2 { + suspCount = 2 + } + p = NewProgress("user_suspensions", suspCount) + suspRows := make([][]interface{}, 0, suspCount) + + for i := 0; i < suspCount; i++ { + targetUser := users[randInt(len(users)/2, len(users)-1)] // Pick from second half (less important) + mod := moderators[rng.Intn(len(moderators))] + reason := pick(strikeReasons) + suspendedUntil := time.Now().Add(time.Duration(randInt(1, 30)) * 24 * time.Hour) + + suspRows = append(suspRows, []interface{}{ + newUUID(), targetUser.ID, + fmt.Sprintf("Suspended for %s", reason), + mod.ID, suspendedUntil, + true, nil, nil, // is_active, lifted_by, lifted_at + time.Now(), + }) + } + + _, _ = BulkInsert(db, "user_suspensions", + "id, user_id, reason, suspended_by, suspended_until, is_active, lifted_by, lifted_at, created_at", + suspRows) + p.Update(len(suspRows)) + p.Done() + + return nil +} diff --git a/veza-backend-api/cmd/tools/seed/seed_notifications.go b/veza-backend-api/cmd/tools/seed/seed_notifications.go new file mode 100644 index 000000000..3b3580159 --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_notifications.go @@ -0,0 +1,94 @@ +package main + +import ( + "database/sql" + "fmt" + "time" +) + +// SeedNotifications creates notifications and notification_preferences. +func SeedNotifications(db *sql.DB, cfg Config, users []SeededUser) error { + fmt.Println("\n═══ NOTIFICATIONS ═══") + + // ── 1. Notifications ───────────────────────────────────────────────────── + p := NewProgress("notifications", cfg.Notifications) + notifRows := make([][]interface{}, 0, cfg.Notifications) + + notifTypes := []string{ + "follow", "like", "comment", "message", "system", + "milestone", "track_upload", "mention", "playlist_follow", + } + + notifTemplates := map[string][2]string{ + "follow": {"New follower", "Someone started following you"}, + "like": {"Track liked", "Someone liked your track"}, + "comment": {"New comment", "New comment on your track"}, + "message": {"New message", "You have a new message"}, + "system": {"System", "Welcome to Veza!"}, + "milestone": {"Milestone", "Your track reached 100 plays!"}, + "track_upload": {"New release", "An artist you follow uploaded a new track"}, + "mention": {"Mentioned", "You were mentioned in a comment"}, + "playlist_follow": {"Playlist followed", "Someone followed your playlist"}, + } + + for i := 0; i < cfg.Notifications; i++ { + user := users[rng.Intn(len(users))] + ntype := pick(notifTypes) + tmpl := notifTemplates[ntype] + createdAt := RandomTimeAfter(user.CreatedAt) + createdAt = RealisticHour(createdAt) + + // 70% read, 30% unread + isRead := randChance(70) + var readAt interface{} + if isRead { + readAt = RandomTimeAfter(createdAt) + } + + notifRows = append(notifRows, []interface{}{ + newUUID(), user.ID, ntype, tmpl[0], tmpl[1], + nil, isRead, createdAt, createdAt, readAt, + }) + } + + _, err := BulkInsert(db, "notifications", + "id, user_id, type, title, content, link, read, created_at, updated_at, read_at", + notifRows) + if err != nil { + return fmt.Errorf("insert notifications: %w", err) + } + p.Update(cfg.Notifications) + p.Done() + + // ── 2. Notification preferences ────────────────────────────────────────── + // Give ~30% of users custom notification preferences + prefCount := len(users) * 30 / 100 + p = NewProgress("notification_preferences", prefCount) + prefRows := make([][]interface{}, 0, prefCount) + npSeen := make(map[string]bool) + + for i := 0; i < prefCount && i < len(users); i++ { + user := users[rng.Intn(len(users))] + if npSeen[user.ID] { + continue + } + npSeen[user.ID] = true + prefRows = append(prefRows, []interface{}{ + user.ID, + randChance(80), // push_follow + randChance(80), // push_like + randChance(80), // push_comment + randChance(90), // push_message + randChance(70), // push_mention + time.Now(), time.Now(), + }) + } + + _, _ = BulkInsert(db, "notification_preferences", + "user_id, push_follow, push_like, push_comment, push_message, push_mention, created_at, updated_at", + prefRows) + p.Update(len(prefRows)) + p.Done() + + return nil +} diff --git a/veza-backend-api/cmd/tools/seed/seed_playlists.go b/veza-backend-api/cmd/tools/seed/seed_playlists.go new file mode 100644 index 000000000..c9bf4e2eb --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_playlists.go @@ -0,0 +1,133 @@ +package main + +import ( + "database/sql" + "fmt" + "time" +) + +// SeededPlaylist holds playlist data. +type SeededPlaylist struct { + ID string + UserID string + Name string +} + +// SeedPlaylists creates playlists and populates playlist_tracks, playlist_follows. +func SeedPlaylists(db *sql.DB, cfg Config, users []SeededUser, tracks []SeededTrack) ([]SeededPlaylist, error) { + fmt.Println("\n═══ PLAYLISTS ═══") + + allUsers := users + playlists := make([]SeededPlaylist, 0, cfg.Playlists) + + // ── 1. Create playlists ────────────────────────────────────────────────── + p := NewProgress("playlists", cfg.Playlists) + playlistRows := make([][]interface{}, 0, cfg.Playlists) + + for i := 0; i < cfg.Playlists; i++ { + id := newUUID() + user := allUsers[rng.Intn(len(allUsers))] + name := GenPlaylistName(i) + desc := fmt.Sprintf("Curated by %s", user.DisplayName) + createdAt := RandomTimeAfter(user.CreatedAt) + + visibility := "public" + isPublic := true + if randChance(15) { + visibility = "private" + isPublic = false + } + isCollab := randChance(10) + + pl := SeededPlaylist{ID: id, UserID: user.ID, Name: name} + playlists = append(playlists, pl) + + playlistRows = append(playlistRows, []interface{}{ + id, user.ID, name, desc, nil, // cover_url + visibility, isCollab, 0, 0, 0, // track_count, duration_seconds, follower_count + name, isPublic, + createdAt, createdAt, nil, + }) + } + + _, err := BulkInsert(db, "playlists", + "id, user_id, name, description, cover_url, visibility, is_collaborative, track_count, duration_seconds, follower_count, title, is_public, created_at, updated_at, deleted_at", + playlistRows) + if err != nil { + return nil, fmt.Errorf("insert playlists: %w", err) + } + p.Update(cfg.Playlists) + p.Done() + + // ── 2. Populate playlist_tracks ────────────────────────────────────────── + if len(tracks) == 0 { + return playlists, nil + } + + p = NewProgress("playlist_tracks", cfg.Playlists) + ptRows := make([][]interface{}, 0, cfg.Playlists*15) + seen := make(map[string]bool) // prevent duplicate (playlist_id, track_id) + + for _, pl := range playlists { + trackCount := randInt(cfg.PlaylistMinTracks, cfg.PlaylistMaxTracks) + if trackCount > len(tracks) { + trackCount = len(tracks) + } + selectedTracks := pickN(tracks, trackCount) + for pos, t := range selectedTracks { + key := pl.ID + ":" + t.ID + if seen[key] { + continue + } + seen[key] = true + ptRows = append(ptRows, []interface{}{ + newUUID(), pl.ID, t.ID, pos, pl.UserID, time.Now(), + }) + } + } + + _, err = BulkInsert(db, "playlist_tracks", + "id, playlist_id, track_id, position, added_by, added_at", + ptRows) + if err != nil { + return nil, fmt.Errorf("insert playlist_tracks: %w", err) + } + p.Update(len(ptRows)) + p.Done() + + // ── 3. Update track_count on playlists ─────────────────────────────────── + _, _ = db.Exec("UPDATE playlists SET track_count = (SELECT COUNT(*) FROM playlist_tracks WHERE playlist_tracks.playlist_id = playlists.id)") + + // ── 4. Playlist follows ────────────────────────────────────────────────── + followCount := len(playlists) * 3 // ~3 follows per playlist on average + p = NewProgress("playlist_follows", followCount) + pfRows := make([][]interface{}, 0, followCount) + pfSeen := make(map[string]bool) + + for i := 0; i < followCount; i++ { + pl := playlists[rng.Intn(len(playlists))] + user := allUsers[rng.Intn(len(allUsers))] + if user.ID == pl.UserID { + continue // Don't follow own playlist + } + key := pl.ID + ":" + user.ID + if pfSeen[key] { + continue + } + pfSeen[key] = true + pfRows = append(pfRows, []interface{}{ + newUUID(), pl.ID, user.ID, time.Now(), time.Now(), nil, + }) + } + + _, err = BulkInsert(db, "playlist_follows", + "id, playlist_id, user_id, created_at, updated_at, deleted_at", + pfRows) + if err != nil { + return nil, fmt.Errorf("insert playlist_follows: %w", err) + } + p.Update(len(pfRows)) + p.Done() + + return playlists, nil +} diff --git a/veza-backend-api/cmd/tools/seed/seed_social.go b/veza-backend-api/cmd/tools/seed/seed_social.go new file mode 100644 index 000000000..a442a8ef2 --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_social.go @@ -0,0 +1,248 @@ +package main + +import ( + "database/sql" + "fmt" + "time" +) + +// SeedSocial creates follows, track_likes, track_reposts, comments, and user_blocks. +func SeedSocial(db *sql.DB, cfg Config, users []SeededUser, tracks []SeededTrack) error { + fmt.Println("\n═══ SOCIAL ═══") + + artists := GetArtists(users) + allUsers := users + + // ── 1. Follows (scale-free distribution) ───────────────────────────────── + p := NewProgress("follows", cfg.Follows) + followRows := make([][]interface{}, 0, cfg.Follows) + followSeen := make(map[string]bool) + + // Build popularity weights: artists are much more likely to be followed + artistSet := make(map[string]bool) + for _, a := range artists { + artistSet[a.ID] = true + } + + for i := 0; i < cfg.Follows*2 && len(followRows) < cfg.Follows; i++ { + follower := allUsers[rng.Intn(len(allUsers))] + + // 70% chance to follow an artist, 30% random user + var followed SeededUser + if randChance(70) && len(artists) > 0 { + // Power-law: top artists get followed more + idx := PowerLaw(0, len(artists)-1, 1.5) + followed = artists[idx] + } else { + followed = allUsers[rng.Intn(len(allUsers))] + } + + if follower.ID == followed.ID { + continue + } + key := follower.ID + ":" + followed.ID + if followSeen[key] { + continue + } + followSeen[key] = true + + createdAt := RandomTimeAfter(follower.CreatedAt) + if followed.CreatedAt.After(follower.CreatedAt) { + createdAt = RandomTimeAfter(followed.CreatedAt) + } + + followRows = append(followRows, []interface{}{ + newUUID(), follower.ID, followed.ID, createdAt, createdAt, + }) + } + + // Ensure test artist@veza.music has many followers + testArtist := findUser(users, "artist@veza.music") + if testArtist != nil { + for _, u := range users[:min(200, len(users))] { + if u.ID == testArtist.ID { + continue + } + key := u.ID + ":" + testArtist.ID + if followSeen[key] { + continue + } + followSeen[key] = true + followRows = append(followRows, []interface{}{ + newUUID(), u.ID, testArtist.ID, RandomTimeAfter(u.CreatedAt), time.Now(), + }) + } + } + + _, err := BulkInsert(db, "follows", + "id, follower_id, followed_id, created_at, updated_at", + followRows) + if err != nil { + return fmt.Errorf("insert follows: %w", err) + } + p.Update(len(followRows)) + p.Done() + + // ── 2. Track likes ─────────────────────────────────────────────────────── + if len(tracks) == 0 { + return nil + } + + p = NewProgress("track_likes", cfg.TrackLikes) + likeRows := make([][]interface{}, 0, cfg.TrackLikes) + likeSeen := make(map[string]bool) + + for i := 0; i < cfg.TrackLikes*2 && len(likeRows) < cfg.TrackLikes; i++ { + user := allUsers[rng.Intn(len(allUsers))] + // Power-law: popular tracks get more likes + track := tracks[PowerLaw(0, len(tracks)-1, 1.2)] + key := user.ID + ":" + track.ID + if likeSeen[key] { + continue + } + likeSeen[key] = true + likeRows = append(likeRows, []interface{}{ + newUUID(), user.ID, track.ID, time.Now(), + }) + } + + _, err = BulkInsert(db, "track_likes", + "id, user_id, track_id, created_at", + likeRows) + if err != nil { + return fmt.Errorf("insert track_likes: %w", err) + } + p.Update(len(likeRows)) + p.Done() + + // ── 3. Track reposts ───────────────────────────────────────────────────── + p = NewProgress("track_reposts", cfg.TrackReposts) + repostRows := make([][]interface{}, 0, cfg.TrackReposts) + repostSeen := make(map[string]bool) + + for i := 0; i < cfg.TrackReposts*2 && len(repostRows) < cfg.TrackReposts; i++ { + user := allUsers[rng.Intn(len(allUsers))] + track := tracks[PowerLaw(0, len(tracks)-1, 1.2)] + if user.ID == track.CreatorID { + continue // Don't repost own tracks + } + key := user.ID + ":" + track.ID + if repostSeen[key] { + continue + } + repostSeen[key] = true + repostRows = append(repostRows, []interface{}{ + newUUID(), user.ID, track.ID, time.Now(), + }) + } + + _, err = BulkInsert(db, "track_reposts", + "id, user_id, track_id, created_at", + repostRows) + if err != nil { + return fmt.Errorf("insert track_reposts: %w", err) + } + p.Update(len(repostRows)) + p.Done() + + // ── 4. Comments (track_comments + generic comments) ────────────────────── + p = NewProgress("track_comments", cfg.Comments) + commentRows := make([][]interface{}, 0, cfg.Comments) + + for i := 0; i < cfg.Comments; i++ { + user := allUsers[rng.Intn(len(allUsers))] + track := tracks[PowerLaw(0, len(tracks)-1, 1.0)] + content := GenComment() + createdAt := RandomTimeAfter(user.CreatedAt) + + // Timed comment: position within the track + var timestampSec *int + if randChance(40) { + ts := randInt(0, track.Duration) + timestampSec = &ts + } + + commentRows = append(commentRows, []interface{}{ + newUUID(), track.ID, user.ID, content, + nil, // parent_comment_id + timestampSec, false, false, nil, + createdAt, createdAt, nil, + }) + } + + _, err = BulkInsert(db, "track_comments", + "id, track_id, user_id, content, parent_comment_id, timestamp_seconds, is_edited, is_deleted, parent_id, created_at, updated_at, deleted_at", + commentRows) + if err != nil { + return fmt.Errorf("insert track_comments: %w", err) + } + p.Update(len(commentRows)) + p.Done() + + // Also populate the generic 'comments' table (used for track comments via target_type) + p = NewProgress("comments (generic)", cfg.Comments/2) + genCommentRows := make([][]interface{}, 0, cfg.Comments/2) + for i := 0; i < cfg.Comments/2; i++ { + user := allUsers[rng.Intn(len(allUsers))] + track := tracks[rng.Intn(len(tracks))] + content := GenComment() + createdAt := RandomTimeAfter(user.CreatedAt) + genCommentRows = append(genCommentRows, []interface{}{ + newUUID(), user.ID, track.ID, "track", content, createdAt, createdAt, + }) + } + _, _ = BulkInsert(db, "comments", + "id, user_id, target_id, target_type, content, created_at, updated_at", + genCommentRows) + p.Update(len(genCommentRows)) + p.Done() + + // ── 5. User blocks (small number) ──────────────────────────────────────── + blockCount := len(users) / 50 // ~2% of users have blocked someone + if blockCount < 5 { + blockCount = 5 + } + p = NewProgress("user_blocks", blockCount) + blockRows := make([][]interface{}, 0, blockCount) + blockSeen := make(map[string]bool) + + for i := 0; i < blockCount; i++ { + blocker := allUsers[rng.Intn(len(allUsers))] + blocked := allUsers[rng.Intn(len(allUsers))] + if blocker.ID == blocked.ID { + continue + } + key := blocker.ID + ":" + blocked.ID + if blockSeen[key] { + continue + } + blockSeen[key] = true + blockRows = append(blockRows, []interface{}{ + newUUID(), blocker.ID, blocked.ID, nil, time.Now(), time.Now(), + }) + } + + _, _ = BulkInsert(db, "user_blocks", + "id, blocker_id, blocked_id, reason, created_at, updated_at", + blockRows) + p.Update(len(blockRows)) + p.Done() + + return nil +} + +func findUser(users []SeededUser, email string) *SeededUser { + for i := range users { + if users[i].Email == email { + return &users[i] + } + } + return nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/veza-backend-api/cmd/tools/seed/seed_tracks.go b/veza-backend-api/cmd/tools/seed/seed_tracks.go new file mode 100644 index 000000000..aac49ab01 --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_tracks.go @@ -0,0 +1,224 @@ +package main + +import ( + "database/sql" + "fmt" + "math" + "strings" +) + +// SeededTrack holds track data for cross-referencing. +type SeededTrack struct { + ID string + CreatorID string + Title string + Artist string + Genre string + Duration int + BPM int + Key string + AlbumID string + CreatedAt string // RFC3339 +} + +// SeededAlbum holds album data. +type SeededAlbum struct { + ID string + CreatorID string + Title string +} + +// SeedTracks creates tracks with power-law distribution across artists. +func SeedTracks(db *sql.DB, cfg Config, users []SeededUser) ([]SeededTrack, error) { + fmt.Println("\n═══ TRACKS ═══") + + artists := GetArtists(users) + if len(artists) == 0 { + return nil, fmt.Errorf("no artists found") + } + + // ── 1. Distribute tracks across artists using power law ────────────────── + // Top artists get many more tracks than bottom ones + trackCounts := distributeTracksToArtists(cfg.Tracks, len(artists)) + + // ── 2. Generate tracks ─────────────────────────────────────────────────── + tracks := make([]SeededTrack, 0, cfg.Tracks) + trackRows := make([][]interface{}, 0, cfg.Tracks) + + p := NewProgress("tracks", cfg.Tracks) + for ai, artist := range artists { + count := trackCounts[ai] + genres := GenreForArtist(ai) + primaryGenre := genres[0] + + for ti := 0; ti < count; ti++ { + id := newUUID() + title := GenTrackTitle() + genre := primaryGenre + if len(genres) > 1 && randChance(30) { + genre = genres[rng.Intn(len(genres))] + } + + duration := randInt(30, 720) // 30s to 12min + // Majority 2-5min + if randChance(60) { + duration = randInt(120, 300) + } + + bpm := randInt(genre.BPMRange[0], genre.BPMRange[1]) + key := "" + if len(genre.Keys) > 0 { + key = pick(genre.Keys) + } + + createdAt := RandomTimeAfter(artist.CreatedAt) + createdAt = RealisticHour(createdAt) + filePath := fmt.Sprintf("audio/%s/%s.mp3", artist.Username, strings.ReplaceAll(strings.ToLower(title), " ", "_")) + fileSize := int64(duration) * int64(randInt(16000, 32000)) // ~128-256 kbps + + tags := fmt.Sprintf("{%s,%s}", genre.Slug, pick([]string{"chill", "energetic", "dark", "melodic", "atmospheric", "groovy", "deep", "raw", "smooth", "heavy"})) + + t := SeededTrack{ + ID: id, + CreatorID: artist.ID, + Title: title, + Artist: artist.DisplayName, + Genre: genre.Slug, + Duration: duration, + BPM: bpm, + Key: key, + CreatedAt: createdAt.Format("2006-01-02T15:04:05Z"), + } + tracks = append(tracks, t) + + trackRows = append(trackRows, []interface{}{ + id, artist.ID, artist.ID, // creator_id, user_id + title, nil, // description + artist.DisplayName, nil, genre.Slug, // artist, album, genre + 0, duration, bpm, key, + "public", false, // visibility, is_downloadable + nil, nil, // cover_art_file_id, waveform_data + 0, 0, 0, 0, // counts (will be updated) + filePath, fileSize, "mp3", 320, 44100, // file details + nil, nil, // waveform_path, cover_art_path + "completed", nil, "ready", nil, // status, stream_status + true, tags, createdAt, createdAt, createdAt, nil, + }) + } + } + + _, err := BulkInsert(db, "tracks", + "id, creator_id, user_id, title, description, artist, album, genre, year, duration, bpm, musical_key, visibility, is_downloadable, cover_art_file_id, waveform_data, play_count, like_count, comment_count, download_count, file_path, file_size, format, bitrate, sample_rate, waveform_path, cover_art_path, status, status_message, stream_status, stream_manifest_url, is_public, tags, published_at, created_at, updated_at, deleted_at", + trackRows) + if err != nil { + return nil, fmt.Errorf("insert tracks: %w", err) + } + p.Update(len(trackRows)) + p.Done() + + // ── 3. Link tracks to genres (track_genres table) ──────────────────────── + p = NewProgress("track_genres", len(tracks)) + // First, fetch genre IDs + genreMap := make(map[string]string) + rows, err := db.Query("SELECT id, slug FROM genres") + if err == nil { + for rows.Next() { + var id, slug string + _ = rows.Scan(&id, &slug) + genreMap[slug] = id + } + rows.Close() + } + + if len(genreMap) > 0 { + tgRows := make([][]interface{}, 0, len(tracks)) + for _, t := range tracks { + if gid, ok := genreMap[t.Genre]; ok { + tgRows = append(tgRows, []interface{}{t.ID, gid}) + } + } + _, _ = BulkInsert(db, "track_genres", "track_id, genre_id", tgRows) + } + p.Update(len(tracks)) + p.Done() + + // ── 4. Link tracks to tags (track_tags table) ──────────────────────────── + p = NewProgress("track_tags", len(tracks)) + // Fetch tag IDs + tagMap := make(map[string]string) + rows, err = db.Query("SELECT id, name FROM tags") + if err == nil { + for rows.Next() { + var id, name string + _ = rows.Scan(&id, &name) + tagMap[strings.ToLower(name)] = id + } + rows.Close() + } + + if len(tagMap) > 0 { + ttRows := make([][]interface{}, 0, len(tracks)*2) + for _, t := range tracks { + if tid, ok := tagMap[t.Genre]; ok { + ttRows = append(ttRows, []interface{}{t.ID, tid}) + } + } + if len(ttRows) > 0 { + _, _ = BulkInsert(db, "track_tags", "track_id, tag_id", ttRows) + } + } + p.Update(len(tracks)) + p.Done() + + return tracks, nil +} + +// distributeTracksToArtists distributes totalTracks across numArtists +// using a power-law distribution. Top artists get many more tracks. +func distributeTracksToArtists(totalTracks, numArtists int) []int { + counts := make([]int, numArtists) + + // Generate power-law weights + weights := make([]float64, numArtists) + for i := range weights { + // Zipf-like: weight = 1/(rank^0.8) + weights[i] = 1.0 / math.Pow(float64(i+1), 0.8) + } + + // Normalize to sum to totalTracks + totalWeight := 0.0 + for _, w := range weights { + totalWeight += w + } + + assigned := 0 + for i := range counts { + counts[i] = int(float64(totalTracks) * weights[i] / totalWeight) + if counts[i] < 1 { + counts[i] = 1 + } + assigned += counts[i] + } + + // Adjust to hit exact total + diff := totalTracks - assigned + for i := 0; diff > 0; i = (i + 1) % numArtists { + counts[i]++ + diff-- + } + for i := 0; diff < 0; i = (i + 1) % numArtists { + if counts[i] > 1 { + counts[i]-- + diff++ + } + } + + // Cap at 80 tracks per artist + for i := range counts { + if counts[i] > 80 { + counts[i] = 80 + } + } + + return counts +} diff --git a/veza-backend-api/cmd/tools/seed/seed_users.go b/veza-backend-api/cmd/tools/seed/seed_users.go new file mode 100644 index 000000000..73dd80e20 --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/seed_users.go @@ -0,0 +1,376 @@ +package main + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// TestAccount represents a predefined test account. +type TestAccount struct { + Email string + Password string + Username string + DisplayName string + Role string + IsAdmin bool + Bio string +} + +var testAccounts = []TestAccount{ + {"admin@veza.music", "Admin123!", "admin_veza", "Admin Veza", "admin", true, "Platform administrator. Managing all the things."}, + {"artist@veza.music", "Artist123!", "top_artist", "Luna Dubois", "creator", false, "Productrice electro basée à Paris. Melodic techno & ambient. Label founder."}, + {"user@veza.music", "User123!", "music_fan", "Music Fan", "user", false, "Music lover. Always discovering new sounds. Playlist curator."}, + {"mod@veza.music", "Mod123!", "mod_veza", "Moderator Veza", "moderator", false, "Community moderator. Keeping the vibes positive."}, + {"new@veza.music", "New123!", "new_user", "New User", "user", false, "Just joined!"}, +} + +// SeededUser holds user data for cross-referencing in other seeders. +type SeededUser struct { + ID string + Email string + Username string + DisplayName string + Role string + IsAdmin bool + CreatedAt time.Time +} + +// SeedUsers creates all users, profiles, settings, and role assignments. +// Returns the full list of seeded users for use by other seeders. +func SeedUsers(db *sql.DB, cfg Config) ([]SeededUser, error) { + fmt.Println("\n═══ USERS ═══") + + // Pre-hash passwords (bcrypt cost 12) + defaultHash, err := bcrypt.GenerateFromPassword([]byte("Password123!"), 12) + if err != nil { + return nil, fmt.Errorf("bcrypt default: %w", err) + } + + // Hash each test account password + testHashes := make([]string, len(testAccounts)) + for i, ta := range testAccounts { + h, err := bcrypt.GenerateFromPassword([]byte(ta.Password), 12) + if err != nil { + return nil, fmt.Errorf("bcrypt test account %s: %w", ta.Email, err) + } + testHashes[i] = string(h) + } + + users := make([]SeededUser, 0, cfg.TotalUsers) + + // ── 1. Create test accounts first ──────────────────────────────────────── + p := NewProgress("users (test accounts)", len(testAccounts)) + testRows := make([][]interface{}, 0, len(testAccounts)) + for i, ta := range testAccounts { + id := newUUID() + createdAt := DaysAgo(365 + (len(testAccounts)-i)*30) // Staggered creation + u := SeededUser{ + ID: id, + Email: ta.Email, + Username: ta.Username, + DisplayName: ta.DisplayName, + Role: ta.Role, + IsAdmin: ta.IsAdmin, + CreatedAt: createdAt, + } + users = append(users, u) + testRows = append(testRows, []interface{}{ + id, ta.Email, createdAt, // email_verified_at = created_at + testHashes[i], ta.Username, ta.Username, + ta.DisplayName, nil, nil, // first_name, last_name + ta.Bio, nil, // location + ta.Role, true, true, false, ta.IsAdmin, true, + 0, nil, createdAt, 0, nil, nil, + createdAt, createdAt, nil, "{}", + }) + } + _, err = BulkInsert(db, "users", + "id, email, email_verified_at, password_hash, username, slug, display_name, first_name, last_name, bio, location, role, is_active, is_verified, is_banned, is_admin, is_public, token_version, last_password_change_at, last_login_at, login_count, last_login_ip, username_changed_at, created_at, updated_at, deleted_at, social_links", + testRows) + if err != nil { + return nil, fmt.Errorf("insert test users: %w", err) + } + p.Update(len(testAccounts)) + p.Done() + + // ── 2. Generate remaining users ────────────────────────────────────────── + remaining := cfg.TotalUsers - len(testAccounts) + if remaining <= 0 { + return users, nil + } + + // Distribute roles + artistCount := cfg.Artists - 1 // -1 for test artist + labelCount := cfg.Labels + modCount := cfg.Moderators - 1 // -1 for test mod + adminCount := cfg.Admins - 1 // -1 for test admin + normalCount := remaining - artistCount - labelCount - modCount - adminCount + + type roleAssignment struct { + role string + isAdmin bool + count int + } + assignments := []roleAssignment{ + {"admin", true, adminCount}, + {"moderator", false, modCount}, + {"creator", false, artistCount}, + {"creator", false, labelCount}, // Labels are creators too + {"user", false, normalCount}, + } + + pwHash := string(defaultHash) + userIdx := len(testAccounts) + allRows := make([][]interface{}, 0, remaining) + + for _, ra := range assignments { + p = NewProgress(fmt.Sprintf("users (%s)", ra.role), ra.count) + for i := 0; i < ra.count; i++ { + id := newUUID() + createdAt := RandomRegistrationTime(userIdx, cfg.TotalUsers) + createdAt = RealisticHour(createdAt) + + var displayName, bio, location string + var firstName, lastName *string + + if ra.role == "creator" { + displayName = GenArtistName(userIdx) + genres := GenreForArtist(userIdx) + bio = GenBio(genres[0].Name) + location = pick(cities) + fn := pick(frenchNames) + ln := pick(frenchLastNames) + firstName = &fn + lastName = &ln + } else { + fn := pick(frenchNames) + ln := pick(frenchLastNames) + displayName = fn + " " + ln + firstName = &fn + lastName = &ln + if randChance(60) { + bio = GenShortBio() + } + if randChance(40) { + location = pick(cities) + } + } + + username := GenUsername(displayName, userIdx) + email := fmt.Sprintf("%s@veza-user.local", username) + + // Email verification: 90% verified + var emailVerifiedAt interface{} + isVerified := randChance(90) + if isVerified { + emailVerifiedAt = RandomTimeAfter(createdAt) + } + + var socialLinks string + if ra.role == "creator" && randChance(70) { + socialLinks = fmt.Sprintf(`{"website":"https://%s.com","instagram":"@%s"}`, username, username) + } else { + socialLinks = "{}" + } + + var lastLoginAt interface{} + loginCount := 0 + if randChance(80) { + lastLoginAt = RandomTimeAfter(createdAt) + loginCount = randInt(1, 200) + } + + u := SeededUser{ + ID: id, + Email: email, + Username: username, + DisplayName: displayName, + Role: ra.role, + IsAdmin: ra.isAdmin, + CreatedAt: createdAt, + } + users = append(users, u) + + allRows = append(allRows, []interface{}{ + id, email, emailVerifiedAt, + pwHash, username, username, + displayName, firstName, lastName, + bio, location, + ra.role, true, isVerified, false, ra.isAdmin, true, + 0, nil, lastLoginAt, loginCount, nil, nil, + createdAt, createdAt, nil, socialLinks, + }) + userIdx++ + } + p.Update(ra.count) + p.Done() + } + + p = NewProgress("users (bulk insert)", len(allRows)) + _, err = BulkInsert(db, "users", + "id, email, email_verified_at, password_hash, username, slug, display_name, first_name, last_name, bio, location, role, is_active, is_verified, is_banned, is_admin, is_public, token_version, last_password_change_at, last_login_at, login_count, last_login_ip, username_changed_at, created_at, updated_at, deleted_at, social_links", + allRows) + if err != nil { + return nil, fmt.Errorf("insert generated users: %w", err) + } + p.Update(len(allRows)) + p.Done() + + // ── 3. User profiles ───────────────────────────────────────────────────── + p = NewProgress("user_profiles", len(users)) + profileRows := make([][]interface{}, 0, len(users)) + for _, u := range users { + var bio, tagline, website, bannerURL, avatarURL *string + language := "fr" + if randChance(30) { + language = "en" + } + theme := pick([]string{"auto", "light", "dark"}) + visibility := "public" + + if u.Role == "creator" { + b := GenBio(GenreForArtist(0)[0].Name) + bio = &b + t := strings.Split(b, ".")[0] + tagline = &t + w := fmt.Sprintf("https://%s.com", u.Username) + website = &w + bn := fmt.Sprintf("https://cdn.veza.music/banners/%s.jpg", u.ID) + bannerURL = &bn + } + av := fmt.Sprintf("https://cdn.veza.music/avatars/%s.jpg", u.ID) + avatarURL = &av + + profileRows = append(profileRows, []interface{}{ + newUUID(), u.ID, bio, tagline, nil, website, nil, nil, + avatarURL, bannerURL, + language, "UTC", theme, visibility, + false, true, + 0, 0, 0, 0, // counts will be updated + u.CreatedAt, u.CreatedAt, + }) + } + _, err = BulkInsert(db, "user_profiles", + "id, user_id, bio, tagline, location, website_url, birthdate, gender, avatar_url, banner_url, language, timezone, theme, profile_visibility, show_email, show_location, follower_count, following_count, track_count, playlist_count, created_at, updated_at", + profileRows) + if err != nil { + return nil, fmt.Errorf("insert profiles: %w", err) + } + p.Update(len(users)) + p.Done() + + // ── 4. User settings ───────────────────────────────────────────────────── + p = NewProgress("user_settings", len(users)) + settingsRows := make([][]interface{}, 0, len(users)) + for _, u := range users { + settingsRows = append(settingsRows, []interface{}{ + newUUID(), u.ID, + true, true, true, true, true, true, true, true, + false, true, true, false, true, + u.CreatedAt, u.CreatedAt, + }) + } + _, err = BulkInsert(db, "user_settings", + "id, user_id, email_notifications, push_notifications, browser_notifications, email_on_follow, email_on_like, email_on_comment, email_on_message, email_on_mention, email_marketing, allow_search_indexing, show_activity, explicit_content, autoplay, created_at, updated_at", + settingsRows) + if err != nil { + return nil, fmt.Errorf("insert settings: %w", err) + } + p.Update(len(users)) + p.Done() + + // ── 5. User roles (via roles table) ────────────────────────────────────── + p = NewProgress("user_roles", len(users)) + // First, fetch role IDs from the roles table + roleMap := make(map[string]string) + rows, err := db.Query("SELECT id, name FROM roles") + if err != nil { + return nil, fmt.Errorf("fetch roles: %w", err) + } + for rows.Next() { + var id, name string + _ = rows.Scan(&id, &name) + roleMap[name] = id + } + rows.Close() + + roleRows := make([][]interface{}, 0, len(users)) + for _, u := range users { + roleName := u.Role + roleID, ok := roleMap[roleName] + if !ok { + // Try mapping 'creator' to a role in the table + if roleName == "creator" { + if rid, ok2 := roleMap["creator"]; ok2 { + roleID = rid + } else { + continue + } + } else { + continue + } + } + roleRows = append(roleRows, []interface{}{ + newUUID(), u.ID, roleID, nil, roleName, true, nil, nil, + u.CreatedAt, nil, true, u.CreatedAt, + }) + } + _, err = BulkInsert(db, "user_roles", + "id, user_id, role_id, assigned_by, role, verified, verified_at, verified_by, assigned_at, expires_at, is_active, created_at", + roleRows) + if err != nil { + return nil, fmt.Errorf("insert user_roles: %w", err) + } + p.Update(len(users)) + p.Done() + + return users, nil +} + +// GetArtists returns only users with role "creator". +func GetArtists(users []SeededUser) []SeededUser { + var artists []SeededUser + for _, u := range users { + if u.Role == "creator" { + artists = append(artists, u) + } + } + return artists +} + +// GetListeners returns non-creator, non-admin, non-moderator users. +func GetListeners(users []SeededUser) []SeededUser { + var listeners []SeededUser + for _, u := range users { + if u.Role == "user" || u.Role == "premium" { + listeners = append(listeners, u) + } + } + return listeners +} + +// GetModerators returns users with moderator role. +func GetModerators(users []SeededUser) []SeededUser { + var mods []SeededUser + for _, u := range users { + if u.Role == "moderator" { + mods = append(mods, u) + } + } + return mods +} + +// GetAdmins returns users with admin role. +func GetAdmins(users []SeededUser) []SeededUser { + var admins []SeededUser + for _, u := range users { + if u.IsAdmin { + admins = append(admins, u) + } + } + return admins +} diff --git a/veza-backend-api/cmd/tools/seed/utils.go b/veza-backend-api/cmd/tools/seed/utils.go new file mode 100644 index 000000000..69a618ad5 --- /dev/null +++ b/veza-backend-api/cmd/tools/seed/utils.go @@ -0,0 +1,251 @@ +package main + +import ( + "database/sql" + "fmt" + "strings" + "time" +) + +// BulkInsert performs a multi-row INSERT for maximum performance. +// columns: comma-separated column list. rows: slice of value slices. +// Returns number of rows inserted. +func BulkInsert(db *sql.DB, table string, columns string, rows [][]interface{}) (int, error) { + if len(rows) == 0 { + return 0, nil + } + + cols := strings.Split(columns, ",") + for i := range cols { + cols[i] = strings.TrimSpace(cols[i]) + } + numCols := len(cols) + + // Batch in chunks of 500 rows to stay within PG parameter limit (65535) + batchSize := 500 + if numCols > 20 { + batchSize = 200 + } + if numCols > 40 { + batchSize = 100 + } + + total := 0 + for start := 0; start < len(rows); start += batchSize { + end := start + batchSize + if end > len(rows) { + end = len(rows) + } + batch := rows[start:end] + + var sb strings.Builder + sb.WriteString("INSERT INTO ") + sb.WriteString(table) + sb.WriteString(" (") + sb.WriteString(strings.Join(cols, ",")) + sb.WriteString(") VALUES ") + + args := make([]interface{}, 0, len(batch)*numCols) + paramIdx := 1 + + for i, row := range batch { + if i > 0 { + sb.WriteByte(',') + } + sb.WriteByte('(') + for j := 0; j < numCols; j++ { + if j > 0 { + sb.WriteByte(',') + } + fmt.Fprintf(&sb, "$%d", paramIdx) + paramIdx++ + if j < len(row) { + args = append(args, row[j]) + } else { + args = append(args, nil) + } + } + sb.WriteByte(')') + } + + sb.WriteString(" ON CONFLICT DO NOTHING") + + _, err := db.Exec(sb.String(), args...) + if err != nil { + return total, fmt.Errorf("bulk insert into %s (batch at offset %d): %w", table, start, err) + } + total += len(batch) + } + return total, nil +} + +// BulkInsertRaw is like BulkInsert but with a raw suffix instead of ON CONFLICT DO NOTHING. +func BulkInsertRaw(db *sql.DB, table string, columns string, rows [][]interface{}, suffix string) (int, error) { + if len(rows) == 0 { + return 0, nil + } + + cols := strings.Split(columns, ",") + for i := range cols { + cols[i] = strings.TrimSpace(cols[i]) + } + numCols := len(cols) + + batchSize := 500 + if numCols > 20 { + batchSize = 200 + } + if numCols > 40 { + batchSize = 100 + } + + total := 0 + for start := 0; start < len(rows); start += batchSize { + end := start + batchSize + if end > len(rows) { + end = len(rows) + } + batch := rows[start:end] + + var sb strings.Builder + sb.WriteString("INSERT INTO ") + sb.WriteString(table) + sb.WriteString(" (") + sb.WriteString(strings.Join(cols, ",")) + sb.WriteString(") VALUES ") + + args := make([]interface{}, 0, len(batch)*numCols) + paramIdx := 1 + + for i, row := range batch { + if i > 0 { + sb.WriteByte(',') + } + sb.WriteByte('(') + for j := 0; j < numCols; j++ { + if j > 0 { + sb.WriteByte(',') + } + fmt.Fprintf(&sb, "$%d", paramIdx) + paramIdx++ + if j < len(row) { + args = append(args, row[j]) + } else { + args = append(args, nil) + } + } + sb.WriteByte(')') + } + + if suffix != "" { + sb.WriteByte(' ') + sb.WriteString(suffix) + } + + _, err := db.Exec(sb.String(), args...) + if err != nil { + return total, fmt.Errorf("bulk insert into %s (batch at offset %d): %w", table, start, err) + } + total += len(batch) + } + return total, nil +} + +// TruncateAll truncates all seedable tables in correct FK order. +func TruncateAll(db *sql.DB) error { + // TRUNCATE CASCADE handles FK ordering for us + tables := []string{ + // Level 3 (deepest dependents first) + "order_items", "product_previews", "product_images", "product_licenses", + "product_reviews", "product_views", "licenses", + "read_receipts", "delivered_status", "message_reactions", + "cloud_file_versions", "cloud_file_shares", + "shared_queue_items", "queue_sessions", + "lesson_progress", "course_enrollments", "certificates", "course_reviews", + "track_distribution_status_history", "external_streaming_royalties", + // Level 2 + "playlist_tracks", "playlist_collaborators", "playlist_follows", "playlist_share_links", + "track_versions", "track_plays", "track_likes", "track_comments", "track_shares", + "track_history", "track_lyrics", "track_stems", "track_tags", "track_genres", + "track_reposts", "track_distributions", "daily_track_stats", "geographic_play_stats", + "track_discovery_sources", "track_segment_stats", "audio_fingerprints", + "playback_history", "playback_analytics", "hls_streams", "hls_transcode_queue", + "file_metadata", "file_conversions", "user_files", + "room_members", "messages", "room_invitations", + "group_members", "group_join_requests", "group_invitations", + "comments", "likes", "webhook_failures", + "gear_images", "gear_documents", "gear_repairs", + "products", "orders", + "queue_items", "analytics_events", "moderation_actions", + "subscription_invoices", "lessons", + "seller_transfers", "seller_payouts", + // Level 1 + "user_profiles", "user_settings", "user_roles", "user_preferences", "user_presence", + "user_blocks", "user_storage_quotas", "user_folders", "user_genre_follows", + "user_tag_follows", "notification_preferences", "push_subscriptions", + "federated_identities", "refresh_tokens", "password_reset_tokens", + "email_verification_tokens", "user_sessions", + "follows", "tracks", "playlists", "groups", "posts", "rooms", + "files", "file_uploads", "live_streams", "gear_items", + "api_keys", "webhooks", "reports", "announcements", "support_tickets", + "data_exports", "seller_stripe_accounts", "seller_balances", + "co_listening_sessions", "user_subscriptions", "notifications", + "password_history", "login_history", "sms_verification_codes", + "webauthn_credentials", "metric_alerts", "metric_alert_preferences", + "user_strikes", "user_suspensions", "spam_detections", "queues", + "courses", + // Level 0 + "users", "audit_logs", + } + + for _, t := range tables { + _, err := db.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", t)) + if err != nil { + // Table might not exist yet, skip silently + continue + } + } + return nil +} + +// CountRows returns the row count for a table. +func CountRows(db *sql.DB, table string) int { + var n int + _ = db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&n) + return n +} + +// Progress tracks and displays progress for a seeding step. +type Progress struct { + label string + total int + current int + startTime time.Time +} + +// NewProgress creates a new progress tracker. +func NewProgress(label string, total int) *Progress { + return &Progress{ + label: label, + total: total, + startTime: time.Now(), + } +} + +// Update advances the progress counter. +func (p *Progress) Update(n int) { + p.current += n +} + +// Done prints the completion message with timing. +func (p *Progress) Done() { + elapsed := time.Since(p.startTime) + fmt.Printf(" %-30s %6d rows (%s)\n", p.label, p.current, elapsed.Round(time.Millisecond)) +} + +// SeedResult stores the result of a seeding operation. +type SeedResult struct { + Table string + Count int + Duration time.Duration +} diff --git a/veza-backend-api/seed-v2 b/veza-backend-api/seed-v2 new file mode 100755 index 000000000..86c2604c7 Binary files /dev/null and b/veza-backend-api/seed-v2 differ