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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user