feat(backend): add domain store, learning services, and seed data

Persist LMS entities and implement hearts/watch rules plus curriculum bootstrap.
This commit is contained in:
ahp
2026-10-07 19:40:26 +03:30
parent f76c7a110b
commit 3ee609337c
15 changed files with 4076 additions and 0 deletions
+281
View File
@@ -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()
}
+135
View File
@@ -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)
}
+243
View File
@@ -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
}
+450
View File
@@ -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
}
+314
View File
@@ -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()
}
+36
View File
@@ -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
+238
View File
@@ -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
}