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,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