feat(backend): add domain store, learning services, and seed data
Persist LMS entities and implement hearts/watch rules plus curriculum bootstrap.
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user