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:
ahp
2026-10-07 19:40:26 +03:30
parent f76c7a110b
commit 3ee609337c
15 changed files with 4076 additions and 0 deletions
+249
View File
@@ -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
}