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
+94
View File
@@ -0,0 +1,94 @@
package service
import (
"context"
"time"
"pargar/backend/internal/config"
"pargar/backend/internal/models"
"pargar/backend/internal/store"
)
// HeartsService manages the fixed 3-heart pool and lockout.
// Hearts do not regenerate over time — only a mentor/admin unlock restores them.
type HeartsService struct {
store *store.Store
}
func NewHeartsService(st *store.Store, _ *config.Config) *HeartsService {
return &HeartsService{store: st}
}
// RefreshHearts is a no-op kept for call-site compatibility.
// The game has no timed heart refill; the stored count is authoritative.
func (h *HeartsService) RefreshHearts(_ context.Context, u *models.User) (*models.User, error) {
return u, nil
}
// ConsumeHearts decrements hearts for wrong answers; locks the account at 0.
// Admin and mentor accounts have infinite hearts and are never locked by this path.
// Returns the updated user and how many hearts were lost.
func (h *HeartsService) ConsumeHearts(ctx context.Context, u *models.User, loss int) (*models.User, int, error) {
if u.Role.IsStaff() {
return u, 0, nil
}
if loss < 0 {
loss = 0
}
prev := u.Hearts
cur := prev - loss
if cur < 0 {
cur = 0
}
actualLost := prev - cur
if err := h.store.SetHearts(ctx, u.ID, cur); err != nil {
return nil, 0, err
}
u.Hearts = cur
if cur == 0 && !u.IsLocked {
locked, err := h.store.LockUser(ctx, u.ID)
if err != nil {
return nil, 0, err
}
return locked, actualLost, nil
}
return u, actualLost, nil
}
// Streak logic keyed on activity date.
func UpdateStreak(ctx context.Context, st *store.Store, u *models.User) (*models.User, error) {
today := time.Now()
current, err := st.GetUserByID(ctx, u.ID)
if err != nil {
return nil, err
}
streak := current.StreakCurrent
longest := current.StreakLongest
if current.LastActivityDate != nil {
last := time.Date(current.LastActivityDate.Year(), current.LastActivityDate.Month(), current.LastActivityDate.Day(), 0, 0, 0, 0, time.UTC)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
diff := int(todayDate.Sub(last).Hours() / 24)
switch {
case diff == 0:
// already active today
return current, nil
case diff == 1:
streak++
default:
streak = 1
}
} else {
streak = 1
}
if streak > longest {
longest = streak
}
lastDate := today
if err := st.UpdateStreakOnly(ctx, current.ID, streak, longest, &lastDate); err != nil {
return nil, err
}
current.StreakCurrent = streak
current.StreakLongest = longest
current.LastActivityDate = &lastDate
return current, nil
}
@@ -0,0 +1,28 @@
package service
import (
"context"
"testing"
"pargar/backend/internal/models"
)
func TestConsumeHeartsSkipsStaff(t *testing.T) {
h := &HeartsService{}
for _, role := range []models.Role{models.RoleAdmin, models.RoleMentor} {
u := &models.User{ID: 1, Role: role, Hearts: 1, IsLocked: false}
out, lost, err := h.ConsumeHearts(context.Background(), u, 3)
if err != nil {
t.Fatalf("%s: unexpected err %v", role, err)
}
if lost != 0 {
t.Fatalf("%s: expected 0 hearts lost, got %d", role, lost)
}
if out.Hearts != 1 {
t.Fatalf("%s: hearts should stay 1, got %d", role, out.Hearts)
}
if out.IsLocked {
t.Fatalf("%s: staff must not lock", role)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package service
import (
"context"
"pargar/backend/internal/models"
"pargar/backend/internal/store"
)
// Pusher delivers realtime messages to a user's connected websockets.
type Pusher interface {
Push(userID int64, msg any)
}
// Notifier persists in-app notifications and pushes them live.
type Notifier struct {
store *store.Store
pusher Pusher
}
func NewNotifier(st *store.Store, pusher Pusher) *Notifier {
return &Notifier{store: st, pusher: pusher}
}
func (n *Notifier) Notify(ctx context.Context, userID int64, category, typ, title, body, route string, data any) error {
enabled, err := n.store.PrefEnabled(ctx, userID, category)
if err != nil || !enabled {
return nil
}
notif := &models.Notification{
UserID: userID,
Category: category,
Type: typ,
Title: title,
Body: body,
Route: route,
}
if data != nil {
notif.Data = mustJSON(data)
}
if err := n.store.CreateNotification(ctx, notif); err != nil {
return err
}
if n.pusher != nil {
n.pusher.Push(userID, map[string]any{
"type": "notification",
"unread": true,
"item": notif,
})
}
return nil
}
func (n *Notifier) NotifyMany(ctx context.Context, userIDs []int64, category, typ, title, body, route string, data any) error {
for _, uid := range userIDs {
if err := n.Notify(ctx, uid, category, typ, title, body, route, data); err != nil {
return err
}
}
return nil
}
+21
View File
@@ -0,0 +1,21 @@
package service
import (
"encoding/json"
"fmt"
"time"
)
func mustJSON(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
return json.RawMessage(`{}`)
}
return b
}
// ISOWeek returns the ISO-8601 week key like "2026-W37".
func ISOWeek(t time.Time) string {
year, week := t.ISOWeek()
return fmt.Sprintf("%d-W%02d", year, week)
}
+112
View File
@@ -0,0 +1,112 @@
package service
import (
"context"
"errors"
"time"
"pargar/backend/internal/models"
"pargar/backend/internal/store"
)
var (
ErrLocked = errors.New("حساب محدود شده است")
ErrLessonLocked = errors.New("درس قفل است")
ErrQuizNotUnlocked = errors.New("آزمون هنوز باز نشده است")
ErrAlreadyPassed = errors.New("درس قبلاً تکمیل شده است")
)
// Max seconds of watch progress accepted per heartbeat (client interval is 10s).
const maxHeartbeatDelta = 15.0
// wallClockSlack is added to elapsed wall time when capping delta (timer jitter).
const wallClockSlack = 1.5
// WatchService validates heartbeats and computes verified watch progress.
type WatchService struct {
store *store.Store
}
func NewWatchService(st *store.Store) *WatchService {
return &WatchService{store: st}
}
// Heartbeat records verified playback progress.
// position = playback position in seconds; delta = seconds actually played since last ping.
func (w *WatchService) Heartbeat(ctx context.Context, userID int64, lesson *models.Lesson, position, delta float64, seq int) (*models.LessonProgress, error) {
if position < 0 {
position = 0
}
dur := float64(lesson.DurationSeconds)
if dur <= 0 {
dur = 1
}
// Clamp position within duration
if position > dur {
position = dur
}
if delta < 0 {
delta = 0
}
if delta > maxHeartbeatDelta {
delta = maxHeartbeatDelta
}
if delta > dur {
delta = dur
}
session, err := w.store.GetOrCreateSession(ctx, userID, lesson.ID)
if err != nil {
return nil, err
}
// Cap by wall clock so speedup / forged deltas cannot outrun real time.
elapsed := time.Since(session.LastHeartbeatAt).Seconds()
if elapsed < 0 {
elapsed = 0
}
wallCap := elapsed + wallClockSlack
if delta > wallCap {
delta = wallCap
}
inserted, err := w.store.InsertHeartbeat(ctx, session, position, delta, seq)
if err != nil {
return nil, err
}
if err := w.store.UpdateSessionPos(ctx, session.ID, position); err != nil {
return nil, err
}
pg, err := w.store.GetProgress(ctx, userID, lesson.ID)
if err != nil {
pg = &models.LessonProgress{UserID: userID, LessonID: lesson.ID}
}
// Only credit progress when the heartbeat row was newly inserted (prevents seq replay).
if inserted {
pg.WatchedSeconds += delta
}
if pg.WatchedSeconds > dur {
pg.WatchedSeconds = dur
}
pg.WatchedPct = pg.WatchedSeconds / dur * 100
pg.LastPosition = position
// mark quiz unlocked once threshold met
if !pg.QuizUnlocked && pg.WatchedPct >= float64(lesson.CompletionThresholdPct) {
pg.QuizUnlocked = true
if err := w.store.SetQuizUnlocked(ctx, userID, lesson.ID); err != nil {
return nil, err
}
}
if err := w.store.UpsertProgress(ctx, pg); err != nil {
return nil, err
}
return pg, nil
}
func (w *WatchService) ResumePosition(ctx context.Context, userID, lessonID int64) (float64, error) {
pg, err := w.store.GetProgress(ctx, userID, lessonID)
if err != nil {
return 0, nil
}
return pg.LastPosition, nil
}
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"testing"
"time"
"pargar/backend/internal/models"
)
func TestHeartbeatCapsDelta(t *testing.T) {
lesson := &models.Lesson{ID: 1, DurationSeconds: 100, CompletionThresholdPct: 85}
delta := 999.0
if delta > maxHeartbeatDelta {
delta = maxHeartbeatDelta
}
if delta > float64(lesson.DurationSeconds) {
delta = float64(lesson.DurationSeconds)
}
if delta != maxHeartbeatDelta {
t.Fatalf("expected cap %v, got %v", maxHeartbeatDelta, delta)
}
}
func TestHeartbeatCapsDeltaByWallClock(t *testing.T) {
last := time.Now().Add(-2 * time.Second)
elapsed := time.Since(last).Seconds()
wallCap := elapsed + wallClockSlack
delta := 15.0
if delta > wallCap {
delta = wallCap
}
if delta > maxHeartbeatDelta {
delta = maxHeartbeatDelta
}
if delta > 5 {
t.Fatalf("wall-clock cap should keep delta near ~3.5s, got %v", delta)
}
if delta < 2 {
t.Fatalf("wall-clock cap too aggressive: %v", delta)
}
}
func TestMaxHeartbeatDeltaConstant(t *testing.T) {
if maxHeartbeatDelta != 15.0 {
t.Fatalf("maxHeartbeatDelta changed unexpectedly: %v", maxHeartbeatDelta)
}
if wallClockSlack < 1 {
t.Fatalf("wallClockSlack too small: %v", wallClockSlack)
}
}