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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"pargar/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPhoneBlacklisted = errors.New("phone is blacklisted")
|
||||||
|
ErrBlacklistNotFound = errors.New("blacklist entry not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
const blacklistCols = `id, phone, created_by, created_at`
|
||||||
|
|
||||||
|
func scanBlacklist(row rowScanner) (*models.RegistrationPhoneBlacklist, error) {
|
||||||
|
var e models.RegistrationPhoneBlacklist
|
||||||
|
err := row.Scan(&e.ID, &e.Phone, &e.CreatedBy, &e.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrBlacklistNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPhoneBlacklisted reports whether phone is on the registration blacklist.
|
||||||
|
func (s *Store) IsPhoneBlacklisted(ctx context.Context, phone string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM registration_phone_blacklist WHERE phone=$1)`, phone).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPhoneBlacklistedTx(ctx context.Context, tx pgx.Tx, phone string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM registration_phone_blacklist WHERE phone=$1)`, phone).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlacklistImportResult is the outcome of a blacklist import batch.
|
||||||
|
type BlacklistImportResult struct {
|
||||||
|
Inserted int
|
||||||
|
Skipped int
|
||||||
|
RemovedWhitelist int
|
||||||
|
Rejected int
|
||||||
|
RejectedPhones []string
|
||||||
|
WhitelistedConflicts []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportBlacklistPhones inserts phones onto the blacklist.
|
||||||
|
// Already-blacklisted phones are skipped.
|
||||||
|
// Available whitelist entries are reported in WhitelistedConflicts unless resolveConflicts
|
||||||
|
// is true, in which case they are removed from the whitelist then blacklisted.
|
||||||
|
// Phones already consumed as users (whitelist consumed or existing user) are rejected.
|
||||||
|
func (s *Store) ImportBlacklistPhones(ctx context.Context, phones []string, createdBy int64, resolveConflicts bool) (BlacklistImportResult, error) {
|
||||||
|
var result BlacklistImportResult
|
||||||
|
if len(phones) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
for _, phone := range phones {
|
||||||
|
phone = strings.TrimSpace(phone)
|
||||||
|
if phone == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var wlStatus string
|
||||||
|
wlErr := tx.QueryRow(ctx, `
|
||||||
|
SELECT status FROM registration_phone_whitelist WHERE phone=$1`, phone).Scan(&wlStatus)
|
||||||
|
if wlErr != nil && !errors.Is(wlErr, pgx.ErrNoRows) {
|
||||||
|
return BlacklistImportResult{}, wlErr
|
||||||
|
}
|
||||||
|
onWhitelist := wlErr == nil
|
||||||
|
|
||||||
|
if onWhitelist && wlStatus == string(models.WhitelistConsumed) {
|
||||||
|
result.Rejected++
|
||||||
|
result.RejectedPhones = append(result.RejectedPhones, phone)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var userExists bool
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM users WHERE phone=$1)`, phone).Scan(&userExists); err != nil {
|
||||||
|
return BlacklistImportResult{}, err
|
||||||
|
}
|
||||||
|
if userExists {
|
||||||
|
result.Rejected++
|
||||||
|
result.RejectedPhones = append(result.RejectedPhones, phone)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if onWhitelist && wlStatus == string(models.WhitelistAvailable) {
|
||||||
|
if !resolveConflicts {
|
||||||
|
result.WhitelistedConflicts = append(result.WhitelistedConflicts, phone)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
DELETE FROM registration_phone_whitelist
|
||||||
|
WHERE phone = $1 AND status = 'available'`, phone)
|
||||||
|
if err != nil {
|
||||||
|
return BlacklistImportResult{}, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() > 0 {
|
||||||
|
result.RemovedWhitelist++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO registration_phone_blacklist (phone, created_by)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (phone) DO NOTHING`, phone, createdBy)
|
||||||
|
if err != nil {
|
||||||
|
return BlacklistImportResult{}, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
result.Skipped++
|
||||||
|
} else {
|
||||||
|
result.Inserted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := assertPhoneListsExclusiveTx(ctx, tx); err != nil {
|
||||||
|
return BlacklistImportResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return BlacklistImportResult{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertPhoneListsExclusiveTx fails if any phone appears on both whitelist and blacklist.
|
||||||
|
func assertPhoneListsExclusiveTx(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
var n int
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM registration_phone_whitelist w
|
||||||
|
INNER JOIN registration_phone_blacklist b ON b.phone = w.phone`).Scan(&n); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return errors.New("phone cannot be on whitelist and blacklist at the same time")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListBlacklistPhones(ctx context.Context, query string, limit, offset int) ([]models.RegistrationPhoneBlacklist, int64, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
var total int64
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM registration_phone_blacklist
|
||||||
|
WHERE ($1 = '' OR phone ILIKE '%'||$1||'%')`, query).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT `+blacklistCols+`
|
||||||
|
FROM registration_phone_blacklist
|
||||||
|
WHERE ($1 = '' OR phone ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, query, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []models.RegistrationPhoneBlacklist
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanBlacklist(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, *e)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBlacklistPhone removes a blacklist entry by id.
|
||||||
|
func (s *Store) DeleteBlacklistPhone(ctx context.Context, id int64) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
DELETE FROM registration_phone_blacklist WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrBlacklistNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const blacklistAttemptCols = `id, phone, path, invite_id, ip, user_agent,
|
||||||
|
attempted_username, attempted_email, attempted_at`
|
||||||
|
|
||||||
|
func scanBlacklistAttempt(row rowScanner) (*models.RegistrationBlacklistAttempt, error) {
|
||||||
|
var e models.RegistrationBlacklistAttempt
|
||||||
|
err := row.Scan(
|
||||||
|
&e.ID, &e.Phone, &e.Path, &e.InviteID, &e.IP, &e.UserAgent,
|
||||||
|
&e.AttemptedUsername, &e.AttemptedEmail, &e.AttemptedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordBlacklistAttempt inserts an audit row when a blacklisted phone tries to register.
|
||||||
|
func (s *Store) RecordBlacklistAttempt(
|
||||||
|
ctx context.Context,
|
||||||
|
phone, path string,
|
||||||
|
inviteID *int64,
|
||||||
|
ip, userAgent, username, email string,
|
||||||
|
) (*models.RegistrationBlacklistAttempt, error) {
|
||||||
|
if path != "public" && path != "invite" {
|
||||||
|
path = "public"
|
||||||
|
}
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO registration_blacklist_attempts
|
||||||
|
(phone, path, invite_id, ip, user_agent, attempted_username, attempted_email)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING `+blacklistAttemptCols,
|
||||||
|
phone, path, inviteID, ip, userAgent, username, email,
|
||||||
|
)
|
||||||
|
return scanBlacklistAttempt(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBlacklistAttempts returns recent attempts, optionally filtered by q (phone/username/email).
|
||||||
|
func (s *Store) ListBlacklistAttempts(ctx context.Context, query string, limit, offset int) ([]models.RegistrationBlacklistAttempt, int64, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
where := `($1 = '' OR phone ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(attempted_username, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(attempted_email, '') ILIKE '%'||$1||'%')`
|
||||||
|
var total int64
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM registration_blacklist_attempts WHERE `+where, query).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT `+blacklistAttemptCols+`
|
||||||
|
FROM registration_blacklist_attempts
|
||||||
|
WHERE `+where+`
|
||||||
|
ORDER BY attempted_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, query, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []models.RegistrationBlacklistAttempt
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanBlacklistAttempt(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, *e)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"pargar/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Store) CreateChallenge(ctx context.Context, c *models.Challenge) (int64, error) {
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO challenges (title, description, target_xp, starts_at, ends_at, is_active, created_by)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id, created_at`,
|
||||||
|
c.Title, c.Description, c.TargetXP, c.StartsAt, c.EndsAt, c.IsActive, c.CreatedBy,
|
||||||
|
).Scan(&c.ID, &c.CreatedAt)
|
||||||
|
return c.ID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateChallenge(ctx context.Context, id int64, c *models.Challenge) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE challenges SET title=$1, description=$2, target_xp=$3, starts_at=$4, ends_at=$5, is_active=$6
|
||||||
|
WHERE id=$7`,
|
||||||
|
c.Title, c.Description, c.TargetXP, c.StartsAt, c.EndsAt, c.IsActive, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteChallenge(ctx context.Context, id int64) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `DELETE FROM challenges WHERE id=$1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListChallengesAdmin(ctx context.Context) ([]models.Challenge, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, title, description, target_xp, starts_at, ends_at, is_active, created_at
|
||||||
|
FROM challenges ORDER BY starts_at DESC LIMIT 200`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanChallenges(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveChallengesForUser returns challenges currently in window (active + now in range) with progress.
|
||||||
|
func (s *Store) ListActiveChallengesForUser(ctx context.Context, userID int64) ([]models.Challenge, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT c.id, c.title, c.description, c.target_xp, c.starts_at, c.ends_at, c.is_active, c.created_at,
|
||||||
|
COALESCE(p.xp, 0) AS my_xp
|
||||||
|
FROM challenges c
|
||||||
|
LEFT JOIN challenge_progress p ON p.challenge_id = c.id AND p.user_id = $1
|
||||||
|
WHERE c.is_active AND c.starts_at <= now() AND c.ends_at >= now()
|
||||||
|
ORDER BY c.ends_at ASC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []models.Challenge
|
||||||
|
for rows.Next() {
|
||||||
|
var c models.Challenge
|
||||||
|
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.TargetXP, &c.StartsAt, &c.EndsAt, &c.IsActive, &c.CreatedAt, &c.MyXP); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.Completed = c.MyXP >= c.TargetXP
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []models.Challenge{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddChallengeProgress adds XP toward all active challenges in the current window.
|
||||||
|
func (s *Store) AddChallengeProgress(ctx context.Context, userID int64, amount int) error {
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO challenge_progress (challenge_id, user_id, xp, updated_at)
|
||||||
|
SELECT c.id, $1, $2, now()
|
||||||
|
FROM challenges c
|
||||||
|
WHERE c.is_active AND c.starts_at <= now() AND c.ends_at >= now()
|
||||||
|
ON CONFLICT (challenge_id, user_id) DO UPDATE
|
||||||
|
SET xp = challenge_progress.xp + EXCLUDED.xp,
|
||||||
|
updated_at = now()`, userID, amount)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanChallenges(rows pgx.Rows) ([]models.Challenge, error) {
|
||||||
|
var out []models.Challenge
|
||||||
|
for rows.Next() {
|
||||||
|
var c models.Challenge
|
||||||
|
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.TargetXP, &c.StartsAt, &c.EndsAt, &c.IsActive, &c.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []models.Challenge{}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetChallenge(ctx context.Context, id int64) (*models.Challenge, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, title, description, target_xp, starts_at, ends_at, is_active, created_at
|
||||||
|
FROM challenges WHERE id=$1`, id)
|
||||||
|
var c models.Challenge
|
||||||
|
err := row.Scan(&c.ID, &c.Title, &c.Description, &c.TargetXP, &c.StartsAt, &c.EndsAt, &c.IsActive, &c.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChallengeWindowOK is a small helper for tests / validation.
|
||||||
|
func ChallengeWindowOK(starts, ends time.Time) bool {
|
||||||
|
return !starts.IsZero() && !ends.IsZero() && !ends.Before(starts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"pargar/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrChatExportTokenNotFound = errors.New("chat export token not found")
|
||||||
|
ErrChatExportTokenRevoked = errors.New("chat export token revoked")
|
||||||
|
ErrChatExportTokenExpired = errors.New("chat export token expired")
|
||||||
|
ErrChatExportTokenUsed = errors.New("chat export token already used")
|
||||||
|
ErrChatExportTokenInvalid = errors.New("chat export token invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
const chatExportTokenCols = `id, token_hash, token_enc, label, expires_at, revoked_at, used_at,
|
||||||
|
created_by, created_at, last_used_at`
|
||||||
|
|
||||||
|
// ChatExportPair is one student↔mentor conversation found in chat_messages.
|
||||||
|
type ChatExportPair struct {
|
||||||
|
StudentID int64
|
||||||
|
StudentName string
|
||||||
|
MentorID int64
|
||||||
|
MentorName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatExportStats is an approximate size summary for the full chat archive.
|
||||||
|
type ChatExportStats struct {
|
||||||
|
ConversationCount int64 `json:"conversationCount"`
|
||||||
|
MessageCount int64 `json:"messageCount"`
|
||||||
|
AttachmentBytes int64 `json:"attachmentBytes"`
|
||||||
|
BodyBytes int64 `json:"bodyBytes"`
|
||||||
|
// EstimatedBytes is a rough upper-bound for the ZIP (attachments + compressed text overhead).
|
||||||
|
EstimatedBytes int64 `json:"estimatedBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatExportStats returns approximate totals for the project-wide chat archive.
|
||||||
|
func (s *Store) ChatExportStats(ctx context.Context) (*ChatExportStats, error) {
|
||||||
|
var st ChatExportStats
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
COALESCE((SELECT COUNT(*) FROM (SELECT DISTINCT user_id, mentor_id FROM chat_messages) p), 0),
|
||||||
|
COUNT(*),
|
||||||
|
COALESCE(SUM(COALESCE(attachment_size, 0)), 0),
|
||||||
|
COALESCE(SUM(OCTET_LENGTH(COALESCE(body, ''))), 0)
|
||||||
|
FROM chat_messages`).Scan(
|
||||||
|
&st.ConversationCount,
|
||||||
|
&st.MessageCount,
|
||||||
|
&st.AttachmentBytes,
|
||||||
|
&st.BodyBytes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Text/JSON compresses well in ZIP; media usually does not. Bias toward a safe upper bound.
|
||||||
|
const perMessageJSON int64 = 160
|
||||||
|
const perConversationJSON int64 = 120
|
||||||
|
const zipEnvelope int64 = 4096
|
||||||
|
textPacked := st.BodyBytes/2 + st.MessageCount*perMessageJSON + st.ConversationCount*perConversationJSON + zipEnvelope
|
||||||
|
st.EstimatedBytes = st.AttachmentBytes + textPacked
|
||||||
|
return &st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChatExportPairs returns every distinct conversation pair with display names.
|
||||||
|
func (s *Store) ListChatExportPairs(ctx context.Context) ([]ChatExportPair, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT m.user_id, COALESCE(su.name, ''), m.mentor_id, COALESCE(mu.name, '')
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT user_id, mentor_id FROM chat_messages
|
||||||
|
) m
|
||||||
|
LEFT JOIN users su ON su.id = m.user_id
|
||||||
|
LEFT JOIN users mu ON mu.id = m.mentor_id
|
||||||
|
ORDER BY m.user_id, m.mentor_id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ChatExportPair
|
||||||
|
for rows.Next() {
|
||||||
|
var p ChatExportPair
|
||||||
|
if err := rows.Scan(&p.StudentID, &p.StudentName, &p.MentorID, &p.MentorName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatExportTokenActive(tok *models.ChatExportToken, now time.Time) bool {
|
||||||
|
return tok != nil && tok.RevokedAt == nil && tok.UsedAt == nil && tok.ExpiresAt.After(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanChatExportToken(row rowScanner) (*models.ChatExportToken, error) {
|
||||||
|
var tok models.ChatExportToken
|
||||||
|
var tokenHash, tokenEnc string
|
||||||
|
err := row.Scan(
|
||||||
|
&tok.ID, &tokenHash, &tokenEnc, &tok.Label, &tok.ExpiresAt, &tok.RevokedAt, &tok.UsedAt,
|
||||||
|
&tok.CreatedBy, &tok.CreatedAt, &tok.LastUsedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrChatExportTokenNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tok.Active = chatExportTokenActive(&tok, time.Now().UTC())
|
||||||
|
// Stash enc on Token for decrypt by callers; clear/replace before JSON if unused.
|
||||||
|
tok.Token = tokenEnc
|
||||||
|
return &tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateChatExportToken(
|
||||||
|
ctx context.Context,
|
||||||
|
tokenHash, tokenEnc, label string,
|
||||||
|
expiresAt time.Time,
|
||||||
|
createdBy int64,
|
||||||
|
) (*models.ChatExportToken, error) {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
if utf8.RuneCountInString(label) > 120 {
|
||||||
|
label = string([]rune(label)[:120])
|
||||||
|
}
|
||||||
|
if expiresAt.IsZero() || !expiresAt.After(time.Now().UTC()) {
|
||||||
|
return nil, errors.New("expiresAt must be in the future")
|
||||||
|
}
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO chat_export_tokens (token_hash, token_enc, label, expires_at, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING `+chatExportTokenCols,
|
||||||
|
tokenHash, tokenEnc, label, expiresAt.UTC(), createdBy)
|
||||||
|
return scanChatExportToken(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListChatExportTokens(ctx context.Context, limit int) ([]models.ChatExportToken, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT `+chatExportTokenCols+`
|
||||||
|
FROM chat_export_tokens
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []models.ChatExportToken
|
||||||
|
for rows.Next() {
|
||||||
|
tok, err := scanChatExportToken(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *tok)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetChatExportTokenByHash(ctx context.Context, tokenHash string) (*models.ChatExportToken, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT `+chatExportTokenCols+`
|
||||||
|
FROM chat_export_tokens WHERE token_hash=$1`, tokenHash)
|
||||||
|
return scanChatExportToken(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetChatExportTokenByID(ctx context.Context, id int64) (*models.ChatExportToken, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT `+chatExportTokenCols+`
|
||||||
|
FROM chat_export_tokens WHERE id=$1`, id)
|
||||||
|
return scanChatExportToken(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateChatExportToken returns nil when the token is active (not revoked, unused, not expired).
|
||||||
|
func ValidateChatExportToken(tok *models.ChatExportToken, now time.Time) error {
|
||||||
|
if tok == nil {
|
||||||
|
return ErrChatExportTokenNotFound
|
||||||
|
}
|
||||||
|
if tok.RevokedAt != nil {
|
||||||
|
return ErrChatExportTokenRevoked
|
||||||
|
}
|
||||||
|
if tok.UsedAt != nil {
|
||||||
|
return ErrChatExportTokenUsed
|
||||||
|
}
|
||||||
|
if !tok.ExpiresAt.After(now) {
|
||||||
|
return ErrChatExportTokenExpired
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeChatExportToken marks a token used atomically (single-use download links).
|
||||||
|
func (s *Store) ConsumeChatExportToken(ctx context.Context, id int64) (*models.ChatExportToken, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE chat_export_tokens
|
||||||
|
SET used_at = now(), last_used_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at > now()
|
||||||
|
RETURNING `+chatExportTokenCols, id)
|
||||||
|
tok, err := scanChatExportToken(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrChatExportTokenNotFound) {
|
||||||
|
existing, getErr := s.GetChatExportTokenByID(ctx, id)
|
||||||
|
if getErr != nil {
|
||||||
|
return nil, getErr
|
||||||
|
}
|
||||||
|
if err := ValidateChatExportToken(existing, time.Now().UTC()); err != nil {
|
||||||
|
return existing, err
|
||||||
|
}
|
||||||
|
return existing, ErrChatExportTokenInvalid
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RevokeChatExportToken(ctx context.Context, id int64) (*models.ChatExportToken, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE chat_export_tokens
|
||||||
|
SET revoked_at = now()
|
||||||
|
WHERE id = $1 AND revoked_at IS NULL
|
||||||
|
RETURNING `+chatExportTokenCols, id)
|
||||||
|
tok, err := scanChatExportToken(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrChatExportTokenNotFound) {
|
||||||
|
existing, getErr := s.GetChatExportTokenByID(ctx, id)
|
||||||
|
if getErr != nil {
|
||||||
|
return nil, getErr
|
||||||
|
}
|
||||||
|
return existing, ErrChatExportTokenInvalid
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"pargar/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInviteNotFound = errors.New("invite not found")
|
||||||
|
ErrInviteInvalid = errors.New("invite invalid")
|
||||||
|
ErrInvitePaused = errors.New("invite paused")
|
||||||
|
ErrInviteRevoked = errors.New("invite revoked")
|
||||||
|
ErrInviteExhausted = errors.New("invite exhausted")
|
||||||
|
ErrInviteExpired = errors.New("invite expired")
|
||||||
|
ErrInviteMaxUsesTooLow = errors.New("maxUses below used_count")
|
||||||
|
ErrInviteMaxUsesRange = errors.New("maxUses out of range")
|
||||||
|
)
|
||||||
|
|
||||||
|
const inviteCols = `id, token_hash, token_enc, label, max_uses, used_count, status,
|
||||||
|
expires_at, created_by, created_at`
|
||||||
|
|
||||||
|
func HashInviteToken(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptInviteToken encrypts the raw invite token with AES-GCM keyed by secret.
|
||||||
|
func EncryptInviteToken(secret, plaintext string) (string, error) {
|
||||||
|
key := sha256.Sum256([]byte(secret))
|
||||||
|
block, err := aes.NewCipher(key[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptInviteToken decrypts a token produced by EncryptInviteToken.
|
||||||
|
func DecryptInviteToken(secret, ciphertext string) (string, error) {
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := sha256.Sum256([]byte(secret))
|
||||||
|
block, err := aes.NewCipher(key[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(raw) < gcm.NonceSize() {
|
||||||
|
return "", errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
nonce, sealed := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
|
||||||
|
plain, err := gcm.Open(nil, nonce, sealed, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInviteToken returns 32 random bytes encoded as base64url (no padding).
|
||||||
|
func NewInviteToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inviteRemaining(maxUses, usedCount int, status models.InviteStatus) int {
|
||||||
|
// Admin still sees remaining capacity while paused; revoked/exhausted show 0.
|
||||||
|
if status != models.InviteActive && status != models.InvitePaused {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
rem := maxUses - usedCount
|
||||||
|
if rem < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return rem
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanInvite(row rowScanner) (*models.RegistrationInvite, error) {
|
||||||
|
var inv models.RegistrationInvite
|
||||||
|
var tokenHash, tokenEnc, status string
|
||||||
|
err := row.Scan(
|
||||||
|
&inv.ID, &tokenHash, &tokenEnc, &inv.Label, &inv.MaxUses, &inv.UsedCount, &status,
|
||||||
|
&inv.ExpiresAt, &inv.CreatedBy, &inv.CreatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrInviteNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inv.Status = models.InviteStatus(status)
|
||||||
|
inv.Remaining = inviteRemaining(inv.MaxUses, inv.UsedCount, inv.Status)
|
||||||
|
// Stash enc on Token temporarily for decrypt by callers that need it; clear before JSON if unused.
|
||||||
|
inv.Token = tokenEnc
|
||||||
|
return &inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateRegistrationInvite(
|
||||||
|
ctx context.Context,
|
||||||
|
tokenHash, tokenEnc, label string,
|
||||||
|
maxUses int,
|
||||||
|
expiresAt *time.Time,
|
||||||
|
createdBy int64,
|
||||||
|
) (*models.RegistrationInvite, error) {
|
||||||
|
label = strings.TrimSpace(label)
|
||||||
|
if utf8.RuneCountInString(label) > 120 {
|
||||||
|
label = string([]rune(label)[:120])
|
||||||
|
}
|
||||||
|
if maxUses < 1 {
|
||||||
|
return nil, errors.New("maxUses must be >= 1")
|
||||||
|
}
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO registration_invites (token_hash, token_enc, label, max_uses, expires_at, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING `+inviteCols,
|
||||||
|
tokenHash, tokenEnc, label, maxUses, expiresAt, createdBy)
|
||||||
|
return scanInvite(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
var total int64
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM registration_invites
|
||||||
|
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')`, query).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT `+inviteCols+`
|
||||||
|
FROM registration_invites
|
||||||
|
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, query, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []models.RegistrationInvite
|
||||||
|
for rows.Next() {
|
||||||
|
inv, err := scanInvite(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, *inv)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetRegistrationInviteByID(ctx context.Context, id int64) (*models.RegistrationInvite, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `SELECT `+inviteCols+` FROM registration_invites WHERE id=$1`, id)
|
||||||
|
return scanInvite(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetRegistrationInviteByTokenHash(ctx context.Context, tokenHash string) (*models.RegistrationInvite, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `SELECT `+inviteCols+` FROM registration_invites WHERE token_hash=$1`, tokenHash)
|
||||||
|
return scanInvite(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RevokeRegistrationInvite(ctx context.Context, id int64) (*models.RegistrationInvite, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE registration_invites
|
||||||
|
SET status = 'revoked'
|
||||||
|
WHERE id = $1 AND status IN ('active', 'paused')
|
||||||
|
RETURNING `+inviteCols, id)
|
||||||
|
inv, err := scanInvite(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrInviteNotFound) {
|
||||||
|
existing, getErr := s.GetRegistrationInviteByID(ctx, id)
|
||||||
|
if getErr != nil {
|
||||||
|
return nil, getErr
|
||||||
|
}
|
||||||
|
return existing, ErrInviteInvalid
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) PauseRegistrationInvite(ctx context.Context, id int64) (*models.RegistrationInvite, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE registration_invites
|
||||||
|
SET status = 'paused'
|
||||||
|
WHERE id = $1 AND status = 'active'
|
||||||
|
RETURNING `+inviteCols, id)
|
||||||
|
inv, err := scanInvite(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrInviteNotFound) {
|
||||||
|
existing, getErr := s.GetRegistrationInviteByID(ctx, id)
|
||||||
|
if getErr != nil {
|
||||||
|
return nil, getErr
|
||||||
|
}
|
||||||
|
return existing, ErrInviteInvalid
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ResumeRegistrationInvite(ctx context.Context, id int64) (*models.RegistrationInvite, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE registration_invites
|
||||||
|
SET status = 'active'
|
||||||
|
WHERE id = $1 AND status = 'paused'
|
||||||
|
AND used_count < max_uses
|
||||||
|
AND (expires_at IS NULL OR expires_at > now())
|
||||||
|
RETURNING `+inviteCols, id)
|
||||||
|
inv, err := scanInvite(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrInviteNotFound) {
|
||||||
|
existing, getErr := s.GetRegistrationInviteByID(ctx, id)
|
||||||
|
if getErr != nil {
|
||||||
|
return nil, getErr
|
||||||
|
}
|
||||||
|
if existing.Status != models.InvitePaused {
|
||||||
|
return existing, ErrInviteInvalid
|
||||||
|
}
|
||||||
|
if existing.ExpiresAt != nil && !existing.ExpiresAt.After(time.Now()) {
|
||||||
|
return existing, ErrInviteExpired
|
||||||
|
}
|
||||||
|
if existing.UsedCount >= existing.MaxUses {
|
||||||
|
return existing, ErrInviteExhausted
|
||||||
|
}
|
||||||
|
return existing, ErrInviteInvalid
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRegistrationInvite patches label, max uses, and/or expiry for an editable invite.
|
||||||
|
// When setExpires is true, expiresAt is applied (nil clears expiry).
|
||||||
|
func (s *Store) UpdateRegistrationInvite(
|
||||||
|
ctx context.Context,
|
||||||
|
id int64,
|
||||||
|
label *string,
|
||||||
|
maxUses *int,
|
||||||
|
setExpires bool,
|
||||||
|
expiresAt *time.Time,
|
||||||
|
) (*models.RegistrationInvite, error) {
|
||||||
|
existing, err := s.GetRegistrationInviteByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing.Status != models.InviteActive && existing.Status != models.InvitePaused {
|
||||||
|
return existing, ErrInviteInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
newLabel := existing.Label
|
||||||
|
if label != nil {
|
||||||
|
newLabel = strings.TrimSpace(*label)
|
||||||
|
if utf8.RuneCountInString(newLabel) > 120 {
|
||||||
|
newLabel = string([]rune(newLabel)[:120])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newMax := existing.MaxUses
|
||||||
|
if maxUses != nil {
|
||||||
|
if *maxUses < 1 || *maxUses > 10_000 {
|
||||||
|
return nil, ErrInviteMaxUsesRange
|
||||||
|
}
|
||||||
|
if *maxUses < existing.UsedCount {
|
||||||
|
return nil, ErrInviteMaxUsesTooLow
|
||||||
|
}
|
||||||
|
newMax = *maxUses
|
||||||
|
}
|
||||||
|
|
||||||
|
newExpires := existing.ExpiresAt
|
||||||
|
if setExpires {
|
||||||
|
newExpires = expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE registration_invites
|
||||||
|
SET label = $2,
|
||||||
|
max_uses = $3,
|
||||||
|
expires_at = $4
|
||||||
|
WHERE id = $1 AND status IN ('active', 'paused')
|
||||||
|
RETURNING `+inviteCols,
|
||||||
|
id, newLabel, newMax, newExpires)
|
||||||
|
return scanInvite(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateInviteAvailability checks whether an invite can still accept registrations.
|
||||||
|
func EvaluateInviteAvailability(inv *models.RegistrationInvite, now time.Time) error {
|
||||||
|
if inv == nil {
|
||||||
|
return ErrInviteNotFound
|
||||||
|
}
|
||||||
|
switch inv.Status {
|
||||||
|
case models.InviteRevoked:
|
||||||
|
return ErrInviteRevoked
|
||||||
|
case models.InviteExhausted:
|
||||||
|
return ErrInviteExhausted
|
||||||
|
case models.InvitePaused:
|
||||||
|
return ErrInvitePaused
|
||||||
|
case models.InviteActive:
|
||||||
|
// ok
|
||||||
|
default:
|
||||||
|
return ErrInviteInvalid
|
||||||
|
}
|
||||||
|
if inv.ExpiresAt != nil && !inv.ExpiresAt.After(now) {
|
||||||
|
return ErrInviteExpired
|
||||||
|
}
|
||||||
|
if inv.UsedCount >= inv.MaxUses {
|
||||||
|
return ErrInviteExhausted
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterInviteStudent creates a student, consumes the IP, and atomically consumes one invite seat.
|
||||||
|
func (s *Store) RegisterInviteStudent(
|
||||||
|
ctx context.Context,
|
||||||
|
inviteID int64,
|
||||||
|
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash, ip string,
|
||||||
|
) (*models.User, error) {
|
||||||
|
ip = strings.TrimSpace(ip)
|
||||||
|
if ip == "" {
|
||||||
|
return nil, errors.New("empty registration ip")
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if blocked, err := isPhoneBlacklistedTx(ctx, tx, phone); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if blocked {
|
||||||
|
return nil, ErrPhoneBlacklisted
|
||||||
|
}
|
||||||
|
|
||||||
|
var taken bool
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)`, ip).Scan(&taken); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return nil, ErrRegistrationIPTaken
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
UPDATE registration_invites
|
||||||
|
SET used_count = used_count + 1,
|
||||||
|
status = CASE
|
||||||
|
WHEN used_count + 1 >= max_uses THEN 'exhausted'
|
||||||
|
ELSE status
|
||||||
|
END
|
||||||
|
WHERE id = $1
|
||||||
|
AND status = 'active'
|
||||||
|
AND used_count < max_uses
|
||||||
|
AND (expires_at IS NULL OR expires_at > now())`, inviteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
var status string
|
||||||
|
var used, maxUses int
|
||||||
|
var expiresAt *time.Time
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
SELECT status, used_count, max_uses, expires_at
|
||||||
|
FROM registration_invites WHERE id=$1`, inviteID).Scan(&status, &used, &maxUses, &expiresAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrInviteNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch models.InviteStatus(status) {
|
||||||
|
case models.InviteRevoked:
|
||||||
|
return nil, ErrInviteRevoked
|
||||||
|
case models.InviteExhausted:
|
||||||
|
return nil, ErrInviteExhausted
|
||||||
|
case models.InvitePaused:
|
||||||
|
return nil, ErrInvitePaused
|
||||||
|
}
|
||||||
|
if expiresAt != nil && !expiresAt.After(time.Now()) {
|
||||||
|
return nil, ErrInviteExpired
|
||||||
|
}
|
||||||
|
if used >= maxUses {
|
||||||
|
return nil, ErrInviteExhausted
|
||||||
|
}
|
||||||
|
return nil, ErrInviteInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
row := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO users (name, email, username, password_hash, role, phone, security_question, security_answer_hash)
|
||||||
|
VALUES ($1, $2, $3, $4, 'student', $5, $6, $7)
|
||||||
|
RETURNING `+userCols,
|
||||||
|
name, email, username, passwordHash, phone, securityQuestion, securityAnswerHash)
|
||||||
|
u, err := scanUser(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Consume whitelist seat if this phone was listed (invite may use non-whitelist phones).
|
||||||
|
if err := consumeWhitelistPhoneTx(ctx, tx, phone, u.ID, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO notification_prefs (user_id) VALUES ($1)
|
||||||
|
ON CONFLICT (user_id) DO NOTHING`, u.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO registration_ips (ip, user_id) VALUES ($1, $2)`, ip, u.ID); err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return nil, ErrRegistrationIPTaken
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LearningOverview is cohort-level learning analytics for staff.
|
||||||
|
type LearningOverview struct {
|
||||||
|
StudentsActive int64 `json:"studentsActive"`
|
||||||
|
StudentsLocked int64 `json:"studentsLocked"`
|
||||||
|
StudentsTotal int64 `json:"studentsTotal"`
|
||||||
|
LessonsTotal int64 `json:"lessonsTotal"`
|
||||||
|
Completions int64 `json:"completions"`
|
||||||
|
Attempts7d int64 `json:"attempts7d"`
|
||||||
|
Passes7d int64 `json:"passes7d"`
|
||||||
|
PassRate7d float64 `json:"passRate7d"`
|
||||||
|
AvgWatchPct float64 `json:"avgWatchPct"`
|
||||||
|
XPAwarded7d int64 `json:"xpAwarded7d"`
|
||||||
|
StudentsStreaking int64 `json:"studentsStreaking"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) LearningOverview(ctx context.Context) (*LearningOverview, error) {
|
||||||
|
o := &LearningOverview{}
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE role='student'),
|
||||||
|
COUNT(*) FILTER (WHERE role='student' AND is_active),
|
||||||
|
COUNT(*) FILTER (WHERE role='student' AND is_locked)
|
||||||
|
FROM users`).Scan(&o.StudentsTotal, &o.StudentsActive, &o.StudentsLocked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&o.LessonsTotal); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE passed_quiz`).Scan(&o.Completions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT
|
||||||
|
COUNT(*),
|
||||||
|
COUNT(*) FILTER (WHERE status='passed')
|
||||||
|
FROM quiz_attempts
|
||||||
|
WHERE created_at >= now() - interval '7 days'`).Scan(&o.Attempts7d, &o.Passes7d); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if o.Attempts7d > 0 {
|
||||||
|
o.PassRate7d = float64(o.Passes7d) / float64(o.Attempts7d) * 100
|
||||||
|
}
|
||||||
|
_ = s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(AVG(watched_pct), 0)
|
||||||
|
FROM lesson_progress
|
||||||
|
WHERE watched_pct > 0 AND NOT passed_quiz`).Scan(&o.AvgWatchPct)
|
||||||
|
_ = s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(SUM(amount), 0)
|
||||||
|
FROM xp_events
|
||||||
|
WHERE created_at >= now() - interval '7 days'`).Scan(&o.XPAwarded7d)
|
||||||
|
_ = s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM users
|
||||||
|
WHERE role='student' AND streak_current > 0 AND is_active`).Scan(&o.StudentsStreaking)
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StudentLearningRow is a list row for the learning dashboard.
|
||||||
|
type StudentLearningRow struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
XP int `json:"xp"`
|
||||||
|
Hearts int `json:"hearts"`
|
||||||
|
StreakCurrent int `json:"streakCurrent"`
|
||||||
|
IsLocked bool `json:"isLocked"`
|
||||||
|
IsActive bool `json:"isActive"`
|
||||||
|
LessonsPassed int64 `json:"lessonsPassed"`
|
||||||
|
LessonsTotal int64 `json:"lessonsTotal"`
|
||||||
|
ProgressPct float64 `json:"progressPct"`
|
||||||
|
Attempts7d int64 `json:"attempts7d"`
|
||||||
|
Passes7d int64 `json:"passes7d"`
|
||||||
|
PassRate7d float64 `json:"passRate7d"`
|
||||||
|
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLearningRow, error) {
|
||||||
|
var lessonsTotal int64
|
||||||
|
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&lessonsTotal); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT u.id, u.name, u.username, u.email, u.xp, u.hearts, u.streak_current,
|
||||||
|
u.is_locked, u.is_active, u.last_activity_date,
|
||||||
|
COALESCE(p.passed, 0),
|
||||||
|
COALESCE(a.attempts_7d, 0),
|
||||||
|
COALESCE(a.passes_7d, 0)
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT user_id, COUNT(*) FILTER (WHERE passed_quiz) AS passed
|
||||||
|
FROM lesson_progress GROUP BY user_id
|
||||||
|
) p ON p.user_id = u.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT user_id,
|
||||||
|
COUNT(*) AS attempts_7d,
|
||||||
|
COUNT(*) FILTER (WHERE status='passed') AS passes_7d
|
||||||
|
FROM quiz_attempts
|
||||||
|
WHERE created_at >= now() - interval '7 days'
|
||||||
|
GROUP BY user_id
|
||||||
|
) a ON a.user_id = u.id
|
||||||
|
WHERE u.role = 'student'
|
||||||
|
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY u.xp DESC, u.id ASC
|
||||||
|
LIMIT 500`, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]StudentLearningRow, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var r StudentLearningRow
|
||||||
|
r.LessonsTotal = lessonsTotal
|
||||||
|
if err := rows.Scan(
|
||||||
|
&r.ID, &r.Name, &r.Username, &r.Email, &r.XP, &r.Hearts, &r.StreakCurrent,
|
||||||
|
&r.IsLocked, &r.IsActive, &r.LastActivityAt,
|
||||||
|
&r.LessonsPassed, &r.Attempts7d, &r.Passes7d,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r.LessonsTotal > 0 {
|
||||||
|
r.ProgressPct = float64(r.LessonsPassed) / float64(r.LessonsTotal) * 100
|
||||||
|
}
|
||||||
|
if r.Attempts7d > 0 {
|
||||||
|
r.PassRate7d = float64(r.Passes7d) / float64(r.Attempts7d) * 100
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type LessonLearningDetail struct {
|
||||||
|
LessonID int64 `json:"lessonId"`
|
||||||
|
ChapterID int64 `json:"chapterId"`
|
||||||
|
ChapterTitle string `json:"chapterTitle"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
WatchedPct float64 `json:"watchedPct"`
|
||||||
|
QuizUnlocked bool `json:"quizUnlocked"`
|
||||||
|
PassedQuiz bool `json:"passedQuiz"`
|
||||||
|
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
|
||||||
|
LastAttemptStatus string `json:"lastAttemptStatus,omitempty"`
|
||||||
|
LastAttemptScore float64 `json:"lastAttemptScore,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttemptLearningDetail struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
LessonID int64 `json:"lessonId"`
|
||||||
|
LessonTitle string `json:"lessonTitle"`
|
||||||
|
CorrectCount int `json:"correctCount"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
ScorePct float64 `json:"scorePct"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
HeartsLost int `json:"heartsLost"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type XPEventDetail struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Amount int `json:"amount"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
RefID int64 `json:"refId"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WatchSessionDetail struct {
|
||||||
|
LessonID int64 `json:"lessonId"`
|
||||||
|
LessonTitle string `json:"lessonTitle"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
LastHeartbeatAt time.Time `json:"lastHeartbeatAt"`
|
||||||
|
LastPosition float64 `json:"lastPosition"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserLearningDetail struct {
|
||||||
|
User StudentLearningRow `json:"user"`
|
||||||
|
Lessons []LessonLearningDetail `json:"lessons"`
|
||||||
|
Attempts []AttemptLearningDetail `json:"attempts"`
|
||||||
|
XPEvents []XPEventDetail `json:"xpEvents"`
|
||||||
|
Sessions []WatchSessionDetail `json:"sessions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLearningDetail, error) {
|
||||||
|
u, err := s.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list, err := s.ListStudentLearning(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var row StudentLearningRow
|
||||||
|
found := false
|
||||||
|
for _, r := range list {
|
||||||
|
if r.ID == userID {
|
||||||
|
row = r
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
row = StudentLearningRow{
|
||||||
|
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
|
||||||
|
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
|
||||||
|
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate,
|
||||||
|
}
|
||||||
|
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal)
|
||||||
|
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed)
|
||||||
|
if row.LessonsTotal > 0 {
|
||||||
|
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := &UserLearningDetail{User: row, Lessons: []LessonLearningDetail{}, Attempts: []AttemptLearningDetail{}, XPEvents: []XPEventDetail{}, Sessions: []WatchSessionDetail{}}
|
||||||
|
|
||||||
|
lrows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT l.id, l.chapter_id, c.title, l.title, l.sort_order,
|
||||||
|
COALESCE(p.watched_pct, 0), COALESCE(p.quiz_unlocked, false), COALESCE(p.passed_quiz, false), p.quiz_completed_at,
|
||||||
|
COALESCE(la.status, ''), COALESCE(la.score_pct, 0)
|
||||||
|
FROM lessons l
|
||||||
|
JOIN chapters c ON c.id = l.chapter_id
|
||||||
|
LEFT JOIN lesson_progress p ON p.lesson_id = l.id AND p.user_id = $1
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT status, score_pct FROM quiz_attempts
|
||||||
|
WHERE user_id=$1 AND lesson_id=l.id
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
) la ON true
|
||||||
|
WHERE l.is_active
|
||||||
|
ORDER BY c.sort_order, l.sort_order, l.id`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer lrows.Close()
|
||||||
|
for lrows.Next() {
|
||||||
|
var d LessonLearningDetail
|
||||||
|
if err := lrows.Scan(
|
||||||
|
&d.LessonID, &d.ChapterID, &d.ChapterTitle, &d.Title, &d.SortOrder,
|
||||||
|
&d.WatchedPct, &d.QuizUnlocked, &d.PassedQuiz, &d.QuizCompletedAt,
|
||||||
|
&d.LastAttemptStatus, &d.LastAttemptScore,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
detail.Lessons = append(detail.Lessons, d)
|
||||||
|
}
|
||||||
|
if err := lrows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
arows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT a.id, a.lesson_id, l.title, a.correct_count, a.total, a.score_pct, a.status, a.hearts_lost, a.created_at
|
||||||
|
FROM quiz_attempts a
|
||||||
|
JOIN lessons l ON l.id = a.lesson_id
|
||||||
|
WHERE a.user_id = $1
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT 50`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer arows.Close()
|
||||||
|
for arows.Next() {
|
||||||
|
var a AttemptLearningDetail
|
||||||
|
if err := arows.Scan(&a.ID, &a.LessonID, &a.LessonTitle, &a.CorrectCount, &a.Total, &a.ScorePct, &a.Status, &a.HeartsLost, &a.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
detail.Attempts = append(detail.Attempts, a)
|
||||||
|
}
|
||||||
|
if err := arows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
xrows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, amount, source, COALESCE(ref_id, 0), created_at
|
||||||
|
FROM xp_events WHERE user_id=$1
|
||||||
|
ORDER BY created_at DESC LIMIT 40`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer xrows.Close()
|
||||||
|
for xrows.Next() {
|
||||||
|
var x XPEventDetail
|
||||||
|
if err := xrows.Scan(&x.ID, &x.Amount, &x.Source, &x.RefID, &x.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
detail.XPEvents = append(detail.XPEvents, x)
|
||||||
|
}
|
||||||
|
if err := xrows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
srows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT ws.lesson_id, l.title, ws.started_at, ws.last_heartbeat_at, ws.last_position
|
||||||
|
FROM watch_sessions ws
|
||||||
|
JOIN lessons l ON l.id = ws.lesson_id
|
||||||
|
WHERE ws.user_id = $1
|
||||||
|
ORDER BY ws.last_heartbeat_at DESC
|
||||||
|
LIMIT 20`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer srows.Close()
|
||||||
|
for srows.Next() {
|
||||||
|
var w WatchSessionDetail
|
||||||
|
if err := srows.Scan(&w.LessonID, &w.LessonTitle, &w.StartedAt, &w.LastHeartbeatAt, &w.LastPosition); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
detail.Sessions = append(detail.Sessions, w)
|
||||||
|
}
|
||||||
|
return detail, srows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pargar/backend/internal/db"
|
||||||
|
"pargar/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLearningOverviewSmoke(t *testing.T) {
|
||||||
|
dsn := os.Getenv("DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "postgres://pargar:f479ad70f076f25594b73b767691be4b295901c4430ed208@localhost:5432/pargar?sslmode=disable"
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := db.Connect(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("postgres unavailable:", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
st := store.New(pool)
|
||||||
|
o, err := st.LearningOverview(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o == nil {
|
||||||
|
t.Fatal("nil overview")
|
||||||
|
}
|
||||||
|
students, err := st.ListStudentLearning(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = students
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"pargar/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrWhitelistNotAllowed = errors.New("phone not on whitelist")
|
||||||
|
ErrWhitelistConsumed = errors.New("whitelist phone already consumed")
|
||||||
|
ErrWhitelistNotFound = errors.New("whitelist entry not found")
|
||||||
|
ErrWhitelistNotAvailable = errors.New("whitelist entry not available")
|
||||||
|
)
|
||||||
|
|
||||||
|
const whitelistCols = `id, phone, status, consumed_at, consumed_user_id, created_by, created_at`
|
||||||
|
|
||||||
|
func scanWhitelist(row rowScanner) (*models.RegistrationPhoneWhitelist, error) {
|
||||||
|
var e models.RegistrationPhoneWhitelist
|
||||||
|
var status string
|
||||||
|
err := row.Scan(
|
||||||
|
&e.ID, &e.Phone, &status, &e.ConsumedAt, &e.ConsumedUserID, &e.CreatedBy, &e.CreatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrWhitelistNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Status = models.WhitelistStatus(status)
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhitelistImportResult is the outcome of a whitelist import batch.
|
||||||
|
type WhitelistImportResult struct {
|
||||||
|
Inserted int
|
||||||
|
Skipped int
|
||||||
|
Rejected int
|
||||||
|
RejectedPhones []string
|
||||||
|
RemovedBlacklist int
|
||||||
|
BlacklistedConflicts []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportWhitelistPhones inserts available phones; skips duplicates.
|
||||||
|
// Phones on the blacklist are reported in BlacklistedConflicts unless resolveConflicts
|
||||||
|
// is true, in which case they are removed from the blacklist then added to the whitelist.
|
||||||
|
// Phones already belonging to a user are rejected (cannot be on both lists / re-registered).
|
||||||
|
func (s *Store) ImportWhitelistPhones(ctx context.Context, phones []string, createdBy int64, resolveConflicts bool) (WhitelistImportResult, error) {
|
||||||
|
var result WhitelistImportResult
|
||||||
|
if len(phones) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
for _, phone := range phones {
|
||||||
|
phone = strings.TrimSpace(phone)
|
||||||
|
if phone == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var userExists bool
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT EXISTS(SELECT 1 FROM users WHERE phone=$1)`, phone).Scan(&userExists); err != nil {
|
||||||
|
return WhitelistImportResult{}, err
|
||||||
|
}
|
||||||
|
if userExists {
|
||||||
|
result.Rejected++
|
||||||
|
result.RejectedPhones = append(result.RejectedPhones, phone)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked, err := isPhoneBlacklistedTx(ctx, tx, phone)
|
||||||
|
if err != nil {
|
||||||
|
return WhitelistImportResult{}, err
|
||||||
|
}
|
||||||
|
if blocked {
|
||||||
|
if !resolveConflicts {
|
||||||
|
result.BlacklistedConflicts = append(result.BlacklistedConflicts, phone)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
DELETE FROM registration_phone_blacklist WHERE phone = $1`, phone)
|
||||||
|
if err != nil {
|
||||||
|
return WhitelistImportResult{}, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() > 0 {
|
||||||
|
result.RemovedBlacklist++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO registration_phone_whitelist (phone, created_by)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (phone) DO NOTHING`, phone, createdBy)
|
||||||
|
if err != nil {
|
||||||
|
return WhitelistImportResult{}, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
result.Skipped++
|
||||||
|
} else {
|
||||||
|
result.Inserted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := assertPhoneListsExclusiveTx(ctx, tx); err != nil {
|
||||||
|
return WhitelistImportResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return WhitelistImportResult{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListWhitelistPhones(ctx context.Context, status, query string, limit, offset int) ([]models.RegistrationPhoneWhitelist, int64, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
status = strings.TrimSpace(status)
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
filtered := status == string(models.WhitelistAvailable) || status == string(models.WhitelistConsumed)
|
||||||
|
|
||||||
|
where := `($1 = '' OR phone ILIKE '%'||$1||'%')`
|
||||||
|
args := []any{query}
|
||||||
|
if filtered {
|
||||||
|
args = append(args, status)
|
||||||
|
where += ` AND status = $2`
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT COUNT(*) FROM registration_phone_whitelist WHERE `+where, args...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
limitIdx := len(args) + 1
|
||||||
|
offsetIdx := len(args) + 2
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
order := `ORDER BY created_at DESC`
|
||||||
|
if !filtered {
|
||||||
|
order = `ORDER BY CASE status WHEN 'available' THEN 0 ELSE 1 END, created_at DESC`
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT `+whitelistCols+`
|
||||||
|
FROM registration_phone_whitelist
|
||||||
|
WHERE `+where+`
|
||||||
|
`+order+`
|
||||||
|
LIMIT $`+strconv.Itoa(limitIdx)+` OFFSET $`+strconv.Itoa(offsetIdx), args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []models.RegistrationPhoneWhitelist
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanWhitelist(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out = append(out, *e)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetWhitelistByPhone(ctx context.Context, phone string) (*models.RegistrationPhoneWhitelist, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT `+whitelistCols+`
|
||||||
|
FROM registration_phone_whitelist WHERE phone=$1`, phone)
|
||||||
|
return scanWhitelist(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAvailableWhitelistPhone removes an unused whitelist entry.
|
||||||
|
func (s *Store) DeleteAvailableWhitelistPhone(ctx context.Context, id int64) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
DELETE FROM registration_phone_whitelist
|
||||||
|
WHERE id = $1 AND status = 'available'`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
err = s.pool.QueryRow(ctx, `
|
||||||
|
SELECT status FROM registration_phone_whitelist WHERE id=$1`, id).Scan(&status)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrWhitelistNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ErrWhitelistNotAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeWhitelistPhoneTx marks an available phone as consumed for userID.
|
||||||
|
// require: if true, missing/consumed returns an error; if false, missing/consumed is a no-op.
|
||||||
|
func consumeWhitelistPhoneTx(ctx context.Context, tx pgx.Tx, phone string, userID int64, require bool) error {
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
UPDATE registration_phone_whitelist
|
||||||
|
SET status = 'consumed',
|
||||||
|
consumed_at = now(),
|
||||||
|
consumed_user_id = $2
|
||||||
|
WHERE phone = $1 AND status = 'available'`, phone, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !require {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
SELECT status FROM registration_phone_whitelist WHERE phone=$1`, phone).Scan(&status)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrWhitelistNotAllowed
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status == string(models.WhitelistConsumed) {
|
||||||
|
return ErrWhitelistConsumed
|
||||||
|
}
|
||||||
|
return ErrWhitelistNotAllowed
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user