250 lines
8.2 KiB
Go
250 lines
8.2 KiB
Go
package seed
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"pargar/backend/internal/models"
|
|
"pargar/backend/internal/store"
|
|
)
|
|
|
|
var demoEmails = []string{
|
|
"student@pargar.dev",
|
|
"mentor@pargar.dev",
|
|
"admin@pargar.dev",
|
|
}
|
|
|
|
// Run ensures the bootstrap admin exists, removes legacy demo accounts,
|
|
// and optionally seeds an idempotent curriculum when the database is empty.
|
|
func Run(ctx context.Context, st *store.Store, allowCurriculumSeed bool) error {
|
|
if err := purgeDemoUsers(ctx, st); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureBootstrapAdmin(ctx, st); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !allowCurriculumSeed {
|
|
return nil
|
|
}
|
|
|
|
chapters, _ := st.ListChapters(ctx)
|
|
if len(chapters) > 0 {
|
|
return nil
|
|
}
|
|
|
|
if err := seedCurriculum(ctx, st); err != nil {
|
|
return err
|
|
}
|
|
return seedEvents(ctx, st)
|
|
}
|
|
|
|
func purgeDemoUsers(ctx context.Context, st *store.Store) error {
|
|
for _, email := range demoEmails {
|
|
u, err := st.GetUserByEmail(ctx, email)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if err := st.DeleteUser(ctx, u.ID); err != nil {
|
|
return err
|
|
}
|
|
log.Printf("removed legacy demo user %s", email)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureBootstrapAdmin(ctx context.Context, st *store.Store) error {
|
|
name := envOr("ADMIN_NAME", "Admin")
|
|
username := strings.ToLower(envOr("ADMIN_USERNAME", "admin"))
|
|
email := strings.ToLower(envOr("ADMIN_EMAIL", "admin@example.com"))
|
|
password := os.Getenv("ADMIN_PASSWORD")
|
|
if password == "" {
|
|
if strings.EqualFold(envOr("DEV_MODE", "false"), "false") {
|
|
return fmt.Errorf("ADMIN_PASSWORD must be set when DEV_MODE is false")
|
|
}
|
|
password = "dev-only-admin-password"
|
|
}
|
|
|
|
// Never re-apply ADMIN_PASSWORD (or profile fields) on restart — that undoes
|
|
// password changes made in the admin UI. Only create when missing.
|
|
if _, err := st.GetUserByEmail(ctx, email); err == nil {
|
|
return nil
|
|
}
|
|
if _, err := st.GetUserByUsername(ctx, username); err == nil {
|
|
return nil
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := st.CreateUser(ctx, name, email, username, string(hash), "", "", "", models.RoleAdmin); err != nil {
|
|
return err
|
|
}
|
|
log.Printf("bootstrap admin ready: %s (%s)", email, username)
|
|
return nil
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func seedCurriculum(ctx context.Context, st *store.Store) error {
|
|
chapters := []struct {
|
|
title, desc, icon string
|
|
order int
|
|
}{
|
|
{"هفتهٔ ۱ · مبانی", "ترمینال، گیت و ذهنیت مهندسی.", "terminal", 1},
|
|
{"هفتهٔ ۲ · سامانههای اصلی", "سازوکار درونی کد و ماشینها.", "chip", 2},
|
|
{"هفتهٔ ۳ · ساخت و انتشار", "کار روی پروژه، استقرار و بازبینی.", "rocket", 3},
|
|
}
|
|
var chapterIDs []int64
|
|
for _, c := range chapters {
|
|
ch := &models.Chapter{Title: c.title, Description: c.desc, Icon: c.icon, SortOrder: c.order}
|
|
id, err := st.CreateChapter(ctx, ch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
chapterIDs = append(chapterIDs, id)
|
|
}
|
|
|
|
type lessonDef struct {
|
|
chapter int
|
|
title string
|
|
desc string
|
|
requires int64 // 0 = root
|
|
x int
|
|
y int
|
|
xp int
|
|
}
|
|
defs := []lessonDef{
|
|
{0, "به پرگار خوش آمدید", "فلسفهٔ دوره، برنامهٔ زمانی و پیمان نظم.", 0, 0, 0, 50},
|
|
{0, "مبانی شل و گیت", "کار با ترمینال و کنترل نسخه در نخستین مخزن شما.", 1, 1, 0, 60},
|
|
{0, "مبانی شبکه", "رایانهها چگونه با هم ارتباط میگیرند: TCP/IP، DNS و HTTP.", 1, 1, 1, 60},
|
|
{1, "ساختمان داده ۱۰۱", "آرایهها، هشها و هنر انتخاب گزینهٔ درست.", 2, 2, 0, 70},
|
|
{1, "راهاندازی ابزارها", "فایلهای تنظیمات، ویرایشگرها و محیط توسعهٔ تکرارپذیر.", 2, 0, 2, 70},
|
|
{2, "پروژه: انتشار یک CLI", "از مشخصات پروژه تا ابزار خط فرمان آمادهٔ انتشار.", 4, 3, 1, 100},
|
|
}
|
|
|
|
lessonIDs := map[int64]int64{}
|
|
for i, d := range defs {
|
|
var req *int64
|
|
if d.requires != 0 {
|
|
rid := lessonIDs[d.requires]
|
|
req = &rid
|
|
}
|
|
l := &models.Lesson{
|
|
ChapterID: chapterIDs[d.chapter],
|
|
Title: d.title,
|
|
Description: d.desc,
|
|
VideoKey: "sample.mp4",
|
|
DurationSeconds: 45,
|
|
CompletionThresholdPct: 85,
|
|
RequiresLessonID: req,
|
|
X: d.x, Y: d.y,
|
|
SortOrder: d.y,
|
|
XPReward: d.xp,
|
|
IsActive: true,
|
|
}
|
|
id, err := st.CreateLesson(ctx, l)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lessonIDs[int64(i)+1] = id
|
|
if err := seedQuestions(ctx, st, id, d.title); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func seedQuestions(ctx context.Context, st *store.Store, lessonID int64, lessonTitle string) error {
|
|
templates := []struct {
|
|
q string
|
|
opts []string
|
|
answer int
|
|
explain string
|
|
}{
|
|
{"درس " + quote(lessonTitle) + " چه چیزی به شما میآموزد؟", []string{
|
|
"تماشای منفعلانهٔ محتوا",
|
|
"مهارت عملی و تأییدشده از مسیر نظم",
|
|
"پرش از بخشها در هر زمان ممکن",
|
|
"هیچچیز؛ این فقط یک جای خالی است",
|
|
}, 1, "پرگار بر توانمندی عملی و قابلسنجش تمرکز دارد، نه تماشای منفعلانه."},
|
|
{"چرا پیش از باز شدن آزمونها، زمان تماشای تأییدشده لازم است؟", []string{
|
|
"تا عمداً سرعت پیشرفت هنرجو کم شود",
|
|
"تا مطمئن شویم واقعاً با محتوای درس درگیر شدهاید",
|
|
"چون ویدیوها خستهکنندهاند",
|
|
"برای فروش جان بیشتر",
|
|
}, 1, "زمان تماشای تأییدشده نشان میدهد پیش از آزمون واقعاً با محتوا کار کردهاید."},
|
|
{"وقتی هر ۳ جان خود را از دست بدهید چه اتفاقی میافتد؟", []string{
|
|
"هیچ؛ جانها بلافاصله برمیگردند",
|
|
"امتیاز اضافه میگیرید",
|
|
"حساب شما تا بررسی متصدی محدود میشود",
|
|
"یک درس مخفی برایتان باز میشود",
|
|
}, 2, "با تمام شدن جانها، حساب تا بررسی متصدی محدود میشود."},
|
|
}
|
|
for pos, t := range templates {
|
|
q := &models.MCQQuestion{
|
|
LessonID: lessonID,
|
|
Position: pos + 1,
|
|
Question: t.q,
|
|
Options: t.opts,
|
|
AnswerIndex: t.answer,
|
|
Explanation: t.explain,
|
|
}
|
|
if _, err := st.CreateQuestion(ctx, q); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func quote(s string) string {
|
|
return "\"" + s + "\""
|
|
}
|
|
|
|
func seedEvents(ctx context.Context, st *store.Store) error {
|
|
now := time.Now()
|
|
events := []models.Event{
|
|
{
|
|
Title: "آغاز زنده — هفتهٔ ۱", Description: "جلسهٔ معارفه، نقشهٔ مسیر دوره و انتظارات.",
|
|
EventType: "workshop", ExternalURL: "https://meet.google.com/pargar-kickoff",
|
|
StartsAt: now.Add(2 * time.Hour), EndsAt: now.Add(3 * time.Hour), IsActive: true,
|
|
},
|
|
{
|
|
Title: "ساعت گفتگوی منتور", Description: "برای پرسش، بازبینی کد و رفع محدودیت حساب وارد شوید.",
|
|
EventType: "meet", ExternalURL: "https://meet.google.com/pargar-office",
|
|
StartsAt: now.Add(24 * time.Hour), EndsAt: now.Add(25 * time.Hour), IsActive: true,
|
|
},
|
|
{
|
|
Title: "کارگاه پروژه: انتشار یک CLI", Description: "ساخت زندهٔ نخستین پروژه با همراهی منتور.",
|
|
EventType: "zoom", ExternalURL: "https://meet.google.com/pargar-cli",
|
|
StartsAt: now.Add(96 * time.Hour), EndsAt: now.Add(98 * time.Hour), IsActive: true,
|
|
},
|
|
}
|
|
adminEmail := strings.ToLower(envOr("ADMIN_EMAIL", "admin@example.com"))
|
|
for i := range events {
|
|
e := &events[i]
|
|
admin, _ := st.GetUserByEmail(ctx, adminEmail)
|
|
if admin != nil {
|
|
id := admin.ID
|
|
e.CreatedBy = &id
|
|
}
|
|
if _, err := st.CreateEvent(ctx, e); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
log.Println("seeded curriculum and events")
|
|
return nil
|
|
}
|