2026-10-07 19:40:26 +03:30
package store
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"pargar/backend/internal/models"
)
var ErrNotFound = errors . New ( "not found" )
var ErrPinLimit = errors . New ( "pin limit" )
2026-10-10 16:42:35 +03:30
var ErrUsernameTaken = errors . New ( "username taken" )
var ErrAccountFrozen = errors . New ( "account frozen" )
var ErrAccountClosed = errors . New ( "account closed" )
var ErrAccountNotFrozen = errors . New ( "account not frozen" )
2026-10-07 19:40:26 +03:30
2026-10-10 16:42:35 +03:30
// UsernameCooldownError means the caller already used the username-change allowance.
type UsernameCooldownError struct {
Until time . Time
}
func ( e * UsernameCooldownError ) Error () string { return "username cooldown" }
const MaxHearts = 5
2026-10-07 19:40:26 +03:30
type Store struct {
pool * pgxpool . Pool
}
func New ( pool * pgxpool . Pool ) * Store {
return & Store { pool : pool }
}
func ( s * Store ) Pool () * pgxpool . Pool { return s . pool }
type Tx struct {
queries pgx . Tx
}
func ( s * Store ) Begin ( ctx context . Context ) ( * Tx , error ) {
tx , err := s . pool . Begin ( ctx )
if err != nil {
return nil , err
}
return & Tx { queries : tx }, nil
}
func ( t * Tx ) Commit ( ctx context . Context ) error { return t . queries . Commit ( ctx ) }
func ( t * Tx ) Rollback ( ctx context . Context ) error {
return t . queries . Rollback ( ctx )
}
// ---- Users ----
func ( s * Store ) CreateUser ( ctx context . Context , name , email , username , passwordHash , phone , securityQuestion , securityAnswerHash string , role models . Role ) ( * models . User , error ) {
row := s . pool . QueryRow ( ctx , `
INSERT INTO users (name, email, username, password_hash, role, phone, security_question, security_answer_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING ` + userCols ,
name , email , username , passwordHash , role . String (), phone , securityQuestion , securityAnswerHash )
u , err := scanUser ( row )
if err != nil {
return nil , err
}
if _ , err := s . pool . Exec ( ctx , `
INSERT INTO notification_prefs (user_id) VALUES ($1)
ON CONFLICT (user_id) DO NOTHING` , u . ID ); err != nil {
return nil , err
}
return u , nil
}
type rowScanner interface {
Scan ( dest ... any ) error
}
var _ rowScanner = pgx . Row ( nil )
func scanUser ( row rowScanner ) ( * models . User , error ) {
var u models . User
err := row . Scan ( & u . ID , & u . Name , & u . Email , & u . Username , & u . PasswordHash , & u . Role ,
& u . XP , & u . Hearts , & u . HeartsUpdatedAt , & u . StreakCurrent , & u . StreakLongest ,
& u . LastActivityDate , & u . IsLocked , & u . LockedAt , & u . UnlockedBy , & u . IsActive ,
& u . AvatarVariant , & u . AvatarPalette , & u . AvatarPhoto ,
& u . Phone , & u . TelegramID , & u . TelegramUsername , & u . SecurityQuestion , & u . SecurityAnswerHash ,
2026-10-10 16:42:35 +03:30
& u . CreatedAt , & u . UsernameChangeCount , & u . UsernameCooldownUntil ,
& u . FrozenAt , & u . ClosedAt )
2026-10-07 19:40:26 +03:30
if err != nil {
return nil , err
}
u . HasSecurityAnswer = u . SecurityAnswerHash != ""
2026-10-10 16:42:35 +03:30
now := time . Now ()
u . ApplyUsernameQuota ( now )
u . ApplyFreezeState ( now )
2026-10-07 19:40:26 +03:30
return & u , nil
}
2026-10-10 16:42:35 +03:30
// humanOnly keeps bot accounts, which share the mentor role, out of people lists.
const humanOnly = `NOT EXISTS (SELECT 1 FROM bot_tokens WHERE bot_user_id = users.id)`
2026-10-07 19:40:26 +03:30
const userCols = `id, name, email, username, password_hash, role, xp, hearts, hearts_updated_at,
streak_current, streak_longest, last_activity_date, is_locked, locked_at,
unlocked_by, is_active, avatar_variant, avatar_palette, avatar_photo,
2026-10-10 16:42:35 +03:30
phone, telegram_id, telegram_username, security_question, security_answer_hash, created_at,
username_change_count, username_cooldown_until, frozen_at, closed_at`
2026-10-07 19:40:26 +03:30
func ( s * Store ) GetUserByEmail ( ctx context . Context , email string ) ( * models . User , error ) {
row := s . pool . QueryRow ( ctx , `SELECT ` + userCols + ` FROM users WHERE email=$1` , email )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return u , err
}
func ( s * Store ) GetUserByUsername ( ctx context . Context , username string ) ( * models . User , error ) {
row := s . pool . QueryRow ( ctx , `SELECT ` + userCols + ` FROM users WHERE lower(username)=$1` , username )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return u , err
}
// GetUserByLogin resolves an email or username identifier.
func ( s * Store ) GetUserByLogin ( ctx context . Context , login string ) ( * models . User , error ) {
login = strings . TrimSpace ( login )
if strings . Contains ( login , "@" ) {
return s . GetUserByEmail ( ctx , strings . ToLower ( login ))
}
return s . GetUserByUsername ( ctx , strings . ToLower ( login ))
}
func ( s * Store ) GetUserByID ( ctx context . Context , id int64 ) ( * models . User , error ) {
row := s . pool . QueryRow ( ctx , `SELECT ` + userCols + ` FROM users WHERE id=$1` , id )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return u , err
}
func ( s * Store ) UpdateUserStats ( ctx context . Context , userID int64 , xp int , streak int , streakLongest int , lastActivity * time . Time ) error {
_ , err := s . pool . Exec ( ctx , `
UPDATE users SET xp=$1, streak_current=$2, streak_longest=$3, last_activity_date=$4
WHERE id=$5` , xp , streak , streakLongest , lastActivity , userID )
return err
}
// UpdateStreakOnly updates streak fields without rewriting XP (avoids clobbering concurrent AddUserXP).
func ( s * Store ) UpdateStreakOnly ( ctx context . Context , userID int64 , streak int , streakLongest int , lastActivity * time . Time ) error {
_ , err := s . pool . Exec ( ctx , `
UPDATE users SET streak_current=$1, streak_longest=$2, last_activity_date=$3
WHERE id=$4` , streak , streakLongest , lastActivity , userID )
return err
}
func ( s * Store ) AddUserXP ( ctx context . Context , userID int64 , amount int ) ( * models . User , error ) {
row := s . pool . QueryRow ( ctx , `UPDATE users SET xp = xp + $1 WHERE id=$2 RETURNING ` + userCols , amount , userID )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return u , err
}
func ( s * Store ) SetHearts ( ctx context . Context , userID int64 , hearts int ) error {
violation := hearts > MaxHearts
if hearts < 0 {
hearts = 0
}
if violation {
hearts = MaxHearts
}
_ , err := s . pool . Exec ( ctx , `
UPDATE users
SET hearts=$1, hearts_updated_at=now(), is_locked=is_locked OR $3,
locked_at=CASE WHEN $3 THEN COALESCE(locked_at, now()) ELSE locked_at END
WHERE id=$2` , hearts , userID , violation )
return err
}
func ( s * Store ) UpdateAvatar ( ctx context . Context , userID int64 , variant , palette * string , photo * string ) ( * models . User , error ) {
row := s . pool . QueryRow ( ctx , `
UPDATE users SET
avatar_variant = COALESCE($2::text, avatar_variant),
avatar_palette = COALESCE($3::text, avatar_palette),
avatar_photo = COALESCE($4::text, avatar_photo)
WHERE id=$1 RETURNING ` + userCols , userID , variant , palette , photo )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return u , err
}
func ( s * Store ) LockUser ( ctx context . Context , userID int64 ) ( * models . User , error ) {
row := s . pool . QueryRow ( ctx , `
UPDATE users SET is_locked=true, locked_at=now() WHERE id=$1 RETURNING ` + userCols , userID )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return u , err
}
func ( s * Store ) UnlockUser ( ctx context . Context , userID int64 , by int64 ) ( * models . User , error ) {
row := s . pool . QueryRow ( ctx , `
2026-10-10 16:42:35 +03:30
UPDATE users SET is_locked=false, locked_at=NULL, unlocked_by=$1, hearts=5, hearts_updated_at=now()
2026-10-07 19:40:26 +03:30
WHERE id=$2 RETURNING ` + userCols , by , userID )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return u , err
}
2026-10-10 16:42:35 +03:30
// FreezeUser records the start of the 30-day self-service freeze. It does not touch is_locked.
func ( s * Store ) FreezeUser ( ctx context . Context , id int64 ) ( * models . User , error ) {
row := s . pool . QueryRow ( ctx , `
UPDATE users SET frozen_at = now()
WHERE id=$1 AND frozen_at IS NULL AND closed_at IS NULL
RETURNING ` + userCols , id )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
existing , gerr := s . GetUserByID ( ctx , id )
if gerr != nil {
return nil , gerr
}
if existing . IsClosed {
return nil , ErrAccountClosed
}
return nil , ErrAccountFrozen
}
return u , err
}
// UnfreezeUser clears frozen_at only while the 30-day window is still open and the account is not closed.
func ( s * Store ) UnfreezeUser ( ctx context . Context , id int64 ) ( * models . User , error ) {
row := s . pool . QueryRow ( ctx , `
UPDATE users SET frozen_at = NULL
WHERE id=$1
AND closed_at IS NULL
AND frozen_at IS NOT NULL
AND now() < frozen_at + interval '30 days'
RETURNING ` + userCols , id )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
existing , gerr := s . GetUserByID ( ctx , id )
if gerr != nil {
return nil , gerr
}
if existing . IsClosed {
return nil , ErrAccountClosed
}
return nil , ErrAccountNotFrozen
}
return u , err
}
// MarkAccountClosed sets closed_at once the freeze window has elapsed. A no-op returns the current user.
func ( s * Store ) MarkAccountClosed ( ctx context . Context , id int64 ) ( * models . User , error ) {
row := s . pool . QueryRow ( ctx , `
UPDATE users SET closed_at = now()
WHERE id=$1
AND closed_at IS NULL
AND frozen_at IS NOT NULL
AND now() >= frozen_at + interval '30 days'
RETURNING ` + userCols , id )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return s . GetUserByID ( ctx , id )
}
return u , err
}
2026-10-07 19:40:26 +03:30
func ( s * Store ) ListUsers ( ctx context . Context , query string , role string , limit , offset int ) ([] models . User , int64 , error ) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
2026-10-10 16:42:35 +03:30
where := `($1='' OR name ILIKE '%'||$1||'%' OR email ILIKE '%'||$1||'%' OR username ILIKE '%'||$1||'%') AND ` + humanOnly
2026-10-07 19:40:26 +03:30
args := [] any { query }
if role != "" {
args = append ( args , role )
where += ` AND role=$2`
}
var total int64
countQ := `SELECT COUNT(*) FROM users WHERE ` + where
if err := s . pool . QueryRow ( ctx , countQ , args ... ). Scan ( & total ); err != nil {
return nil , 0 , err
}
limitIdx := len ( args ) + 1
offsetIdx := len ( args ) + 2
args = append ( args , limit , offset )
q := `SELECT ` + userCols + ` FROM users WHERE ` + where +
` ORDER BY created_at ASC LIMIT $` + strconv . Itoa ( limitIdx ) + ` OFFSET $` + strconv . Itoa ( offsetIdx )
rows , err := s . pool . Query ( ctx , q , args ... )
if err != nil {
return nil , 0 , err
}
defer rows . Close ()
var out [] models . User
for rows . Next () {
u , err := scanUser ( rows )
if err != nil {
return nil , 0 , err
}
out = append ( out , * u )
}
return out , total , rows . Err ()
}
2026-10-10 16:42:35 +03:30
// ListActiveByRoles returns active users whose role is in roles, ordered by name.
func ( s * Store ) ListActiveByRoles ( ctx context . Context , roles [] string , limit , offset int ) ([] models . User , int64 , error ) {
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
if offset < 0 {
offset = 0
}
if len ( roles ) == 0 {
return [] models . User {}, 0 , nil
}
var total int64
if err := s . pool . QueryRow ( ctx , `
SELECT COUNT(*) FROM users WHERE is_active AND role::text = ANY($1::text[]) AND ` + humanOnly , roles ). Scan ( & total ); err != nil {
return nil , 0 , err
}
rows , err := s . pool . Query ( ctx , `
SELECT ` + userCols + ` FROM users
WHERE is_active AND role::text = ANY($1::text[]) AND ` + humanOnly + `
ORDER BY name ASC
LIMIT $2 OFFSET $3` , roles , limit , offset )
if err != nil {
return nil , 0 , err
}
defer rows . Close ()
var out [] models . User
for rows . Next () {
u , err := scanUser ( rows )
if err != nil {
return nil , 0 , err
}
out = append ( out , * u )
}
return out , total , rows . Err ()
}
2026-10-07 19:40:26 +03:30
func ( s * Store ) ListMentors ( ctx context . Context ) ([] models . User , error ) {
2026-10-10 16:42:35 +03:30
rows , err := s . pool . Query ( ctx , `SELECT ` + userCols + ` FROM users WHERE role IN ('mentor','admin') AND is_active AND ` + humanOnly + ` ORDER BY name` )
2026-10-07 19:40:26 +03:30
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] models . User
for rows . Next () {
u , err := scanUser ( rows )
if err != nil {
return nil , err
}
out = append ( out , * u )
}
return out , rows . Err ()
}
func ( s * Store ) UpdateUser ( ctx context . Context , id int64 , name , email , username , passwordHash , phone , securityQuestion , securityAnswerHash string , role models . Role , hearts , xp int ) ( * models . User , error ) {
violation := hearts > MaxHearts
if hearts < 0 {
hearts = 0
}
if violation {
hearts = MaxHearts
}
row := s . pool . QueryRow ( ctx , `
UPDATE users SET
name = COALESCE(NULLIF($2::text, ''), name),
email = COALESCE(NULLIF($3::text, ''), email),
username = COALESCE(NULLIF($4::text, ''), username),
password_hash = CASE WHEN $5::text = '' THEN password_hash ELSE $5 END,
role = $6::user_role,
hearts = $7,
xp = $8,
is_locked = is_locked OR $9,
locked_at = CASE WHEN $9 THEN COALESCE(locked_at, now()) ELSE locked_at END,
phone = COALESCE($10, phone),
security_question = CASE WHEN $11::text = '' THEN security_question ELSE $11 END,
security_answer_hash = CASE WHEN $12::text = '' THEN security_answer_hash ELSE $12 END
WHERE id=$1 RETURNING ` + userCols ,
id , name , email , username , passwordHash , role . String (), hearts , xp , violation , phone , securityQuestion , securityAnswerHash )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return u , err
}
2026-10-10 16:42:35 +03:30
// ChangeUsername sets a new username and consumes one allowance slot.
// The same username is a no-op. The third change in a window starts a cooldown;
// after that cooldown the allowance returns to the limit.
func ( s * Store ) ChangeUsername ( ctx context . Context , id int64 , username string , now time . Time ) ( * models . User , error ) {
tx , err := s . pool . Begin ( ctx )
if err != nil {
return nil , err
}
defer tx . Rollback ( ctx )
var current string
var count int
var until * time . Time
err = tx . QueryRow ( ctx , `
SELECT username, username_change_count, username_cooldown_until
FROM users WHERE id=$1 FOR UPDATE` , id ). Scan ( & current , & count , & until )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
if err != nil {
return nil , err
}
reset := false
if until != nil && ! now . Before ( * until ) {
count = 0
until = nil
reset = true
}
if strings . EqualFold ( current , username ) {
if reset {
if _ , err := tx . Exec ( ctx , `
UPDATE users SET username_change_count=0, username_cooldown_until=NULL WHERE id=$1` , id ); err != nil {
return nil , err
}
}
if err := tx . Commit ( ctx ); err != nil {
return nil , err
}
return s . GetUserByID ( ctx , id )
}
if count >= models . UsernameChangeLimit {
if until == nil {
t := now . Add ( models . UsernameChangeCooldown )
until = & t
if _ , err := tx . Exec ( ctx , `
UPDATE users SET username_change_count=$2, username_cooldown_until=$3 WHERE id=$1` ,
id , count , until ); err != nil {
return nil , err
}
if err := tx . Commit ( ctx ); err != nil {
return nil , err
}
}
return nil , & UsernameCooldownError { Until : * until }
}
var taken int64
err = tx . QueryRow ( ctx , `SELECT id FROM users WHERE lower(username)=lower($1) AND id<>$2` , username , id ). Scan ( & taken )
if err == nil {
return nil , ErrUsernameTaken
}
if ! errors . Is ( err , pgx . ErrNoRows ) {
return nil , err
}
count ++
var nextUntil * time . Time
if count >= models . UsernameChangeLimit {
t := now . Add ( models . UsernameChangeCooldown )
nextUntil = & t
}
row := tx . QueryRow ( ctx , `
UPDATE users
SET username=$2, username_change_count=$3, username_cooldown_until=$4
WHERE id=$1
RETURNING ` + userCols , id , username , count , nextUntil )
u , err := scanUser ( row )
if err != nil {
if isUniqueViolation ( err ) {
return nil , ErrUsernameTaken
}
return nil , err
}
if err := tx . Commit ( ctx ); err != nil {
if isUniqueViolation ( err ) {
return nil , ErrUsernameTaken
}
return nil , err
}
return u , nil
}
2026-10-07 19:40:26 +03:30
func ( s * Store ) GetUserByPhone ( ctx context . Context , phone string ) ( * models . User , error ) {
phone = strings . TrimSpace ( phone )
if phone == "" {
return nil , ErrNotFound
}
row := s . pool . QueryRow ( ctx , `SELECT ` + userCols + ` FROM users WHERE phone=$1` , phone )
u , err := scanUser ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return u , err
}
func ( s * Store ) SetUserPassword ( ctx context . Context , id int64 , passwordHash string ) error {
tag , err := s . pool . Exec ( ctx , `UPDATE users SET password_hash=$1 WHERE id=$2` , passwordHash , id )
if err != nil {
return err
}
if tag . RowsAffected () == 0 {
return ErrNotFound
}
return nil
}
// ErrRegistrationIPTaken is returned when an IP has already completed public registration.
var ErrRegistrationIPTaken = errors . New ( "registration ip taken" )
func ( s * Store ) HasRegistrationIP ( ctx context . Context , ip string ) ( bool , error ) {
ip = strings . TrimSpace ( ip )
if ip == "" {
return false , nil
}
var exists bool
err := s . pool . QueryRow ( ctx , `SELECT EXISTS(SELECT 1 FROM registration_ips WHERE ip=$1)` , ip ). Scan ( & exists )
return exists , err
}
2026-10-07 23:13:22 +03:30
// RegisterPublicStudent creates a student and optionally records the client IP.
2026-10-07 19:40:26 +03:30
// When requireWhitelist is true, the phone must be an available whitelist entry.
2026-10-07 23:13:22 +03:30
// Empty ip skips one-registration-per-IP enforcement (NAT / shared egress).
2026-10-07 19:40:26 +03:30
func ( s * Store ) RegisterPublicStudent (
ctx context . Context ,
name , email , username , passwordHash , phone , securityQuestion , securityAnswerHash , ip string ,
requireWhitelist bool ,
) ( * models . User , error ) {
ip = strings . TrimSpace ( 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
}
2026-10-07 23:13:22 +03:30
if ip != "" {
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
}
2026-10-07 19:40:26 +03:30
}
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
}
if err := consumeWhitelistPhoneTx ( ctx , tx , phone , u . ID , requireWhitelist ); 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
}
2026-10-07 23:13:22 +03:30
if ip != "" {
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
2026-10-07 19:40:26 +03:30
}
}
if err := tx . Commit ( ctx ); err != nil {
return nil , err
}
return u , nil
}
func isUniqueViolation ( err error ) bool {
var pe * pgconn . PgError
return errors . As ( err , & pe ) && pe . Code == "23505"
}
// IsUniqueViolation reports a Postgres unique_violation (23505).
func IsUniqueViolation ( err error ) bool {
return isUniqueViolation ( err )
}
func ( s * Store ) CountActiveLessons ( ctx context . Context ) ( int , error ) {
var n int
2026-10-18 10:14:22 +03:30
err := s . pool . QueryRow ( ctx , `SELECT COUNT(*) FROM lessons WHERE is_active AND is_published` ). Scan ( & n )
2026-10-07 19:40:26 +03:30
return n , err
}
func ( s * Store ) CountPassedActiveLessons ( ctx context . Context , userID int64 ) ( int , error ) {
var n int
err := s . pool . QueryRow ( ctx , `
SELECT COUNT(*) FROM lesson_progress lp
JOIN lessons l ON l.id = lp.lesson_id
2026-10-18 10:14:22 +03:30
WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active AND l.is_published` , userID ). Scan ( & n )
2026-10-07 19:40:26 +03:30
return n , err
}
func ( s * Store ) HasCompletedCurriculum ( ctx context . Context , userID int64 ) ( bool , error ) {
2026-10-18 10:14:22 +03:30
// An issued (non-revoked) certificate is permanent proof of completion regardless
// of subsequent curriculum changes (e.g. new lessons added after the student finished).
if cert , err := s . GetCertificateByUser ( ctx , userID ); err == nil && cert . RevokedAt == nil {
return true , nil
}
2026-10-07 19:40:26 +03:30
active , err := s . CountActiveLessons ( ctx )
if err != nil {
return false , err
}
if active == 0 {
return false , nil
}
passed , err := s . CountPassedActiveLessons ( ctx , userID )
if err != nil {
return false , err
}
return passed >= active , nil
}
func ( s * Store ) GetCertificateByUser ( ctx context . Context , userID int64 ) ( * models . Certificate , error ) {
row := s . pool . QueryRow ( ctx , `
2026-10-18 10:14:22 +03:30
SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
2026-10-07 19:40:26 +03:30
FROM certificates WHERE user_id=$1` , userID )
return scanCertificate ( row )
}
func ( s * Store ) GetCertificateByPublicID ( ctx context . Context , publicID string ) ( * models . Certificate , error ) {
row := s . pool . QueryRow ( ctx , `
2026-10-18 10:14:22 +03:30
SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
FROM certificates WHERE public_id=$1 AND status='issued'` , publicID )
2026-10-07 19:40:26 +03:30
return scanCertificate ( row )
}
func scanCertificate ( row rowScanner ) ( * models . Certificate , error ) {
var c models . Certificate
2026-10-18 10:14:22 +03:30
var publicID * string
err := row . Scan ( & c . ID , & c . UserID , & publicID , & c . FullName , & c . IssuedAt , & c . RevokedAt , & c . Status )
2026-10-07 19:40:26 +03:30
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
if err != nil {
return nil , err
}
2026-10-18 10:14:22 +03:30
if publicID != nil {
c . PublicID = * publicID
}
2026-10-07 19:40:26 +03:30
return & c , nil
}
func ( s * Store ) CreateCertificate ( ctx context . Context , userID int64 , publicID , fullName string ) ( * models . Certificate , error ) {
row := s . pool . QueryRow ( ctx , `
2026-10-18 10:14:22 +03:30
INSERT INTO certificates (user_id, public_id, full_name, status)
VALUES ($1, $2, $3, 'issued')
2026-10-07 19:40:26 +03:30
ON CONFLICT (user_id) DO UPDATE SET
full_name = EXCLUDED.full_name,
2026-10-18 10:14:22 +03:30
public_id = EXCLUDED.public_id,
status = 'issued',
2026-10-07 19:40:26 +03:30
revoked_at = NULL
2026-10-18 10:14:22 +03:30
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status` ,
2026-10-07 19:40:26 +03:30
userID , publicID , fullName )
return scanCertificate ( row )
}
2026-10-18 10:14:22 +03:30
func ( s * Store ) CreatePendingCertificate ( ctx context . Context , userID int64 , fullName string ) ( * models . Certificate , error ) {
row := s . pool . QueryRow ( ctx , `
INSERT INTO certificates (user_id, public_id, full_name, status)
VALUES ($1, NULL, $2, 'pending')
ON CONFLICT (user_id) DO NOTHING
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status` ,
userID , fullName )
cert , err := scanCertificate ( row )
if errors . Is ( err , ErrNotFound ) {
return s . GetCertificateByUser ( ctx , userID )
}
return cert , err
}
func ( s * Store ) IssuePendingCertificate ( ctx context . Context , userID int64 , publicID , fullName string ) ( * models . Certificate , error ) {
row := s . pool . QueryRow ( ctx , `
UPDATE certificates SET
public_id = $1,
full_name = $2,
status = 'issued',
revoked_at = NULL,
issued_at = NOW()
WHERE user_id = $3
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status` ,
publicID , fullName , userID )
return scanCertificate ( row )
}
func ( s * Store ) RevokeCertificate ( ctx context . Context , userID int64 ) error {
_ , err := s . pool . Exec ( ctx , `UPDATE certificates SET revoked_at=NOW() WHERE user_id=$1` , userID )
return err
}
func ( s * Store ) ListCertificatesByStatus ( ctx context . Context , status string ) ([] models . Certificate , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT c.id, c.user_id, c.public_id, c.full_name, c.issued_at, c.revoked_at, c.status
FROM certificates c
WHERE c.status=$1 AND c.revoked_at IS NULL
ORDER BY c.issued_at DESC` , status )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] models . Certificate
for rows . Next () {
var c models . Certificate
var publicID * string
if err := rows . Scan ( & c . ID , & c . UserID , & publicID , & c . FullName , & c . IssuedAt , & c . RevokedAt , & c . Status ); err != nil {
return nil , err
}
if publicID != nil {
c . PublicID = * publicID
}
out = append ( out , c )
}
return out , rows . Err ()
}
2026-10-07 19:40:26 +03:30
const physicalOrderCols = `id, certificate_id, user_id, status, note,
recipient_name, phone, address, city, postal_code, tracking_code,
window_ends_at, created_at, updated_at`
func ( s * Store ) CreatePhysicalOrder ( ctx context . Context , certID , userID int64 , windowEndsAt time . Time , ship models . CertificatePhysicalOrder ) ( * models . CertificatePhysicalOrder , error ) {
row := s . pool . QueryRow ( ctx , `
INSERT INTO certificate_physical_orders (
certificate_id, user_id, window_ends_at,
recipient_name, phone, address, city, postal_code
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING ` + physicalOrderCols ,
certID , userID , windowEndsAt ,
ship . RecipientName , ship . Phone , ship . Address , ship . City , ship . PostalCode )
return scanPhysicalOrder ( row )
}
func ( s * Store ) GetLatestPhysicalOrder ( ctx context . Context , userID int64 ) ( * models . CertificatePhysicalOrder , error ) {
row := s . pool . QueryRow ( ctx , `
SELECT ` + physicalOrderCols + `
FROM certificate_physical_orders
WHERE user_id=$1
ORDER BY created_at DESC LIMIT 1` , userID )
return scanPhysicalOrder ( row )
}
func ( s * Store ) GetPhysicalOrder ( ctx context . Context , id int64 ) ( * models . CertificatePhysicalOrder , error ) {
row := s . pool . QueryRow ( ctx , `
SELECT ` + physicalOrderCols + `
FROM certificate_physical_orders WHERE id=$1` , id )
return scanPhysicalOrder ( row )
}
2026-10-09 19:05:59 +03:30
func ( s * Store ) ListPhysicalOrders ( ctx context . Context , status string , limit , offset int ) ([] models . CertificatePhysicalOrder , int64 , error ) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
where := ""
args := [] any {}
if status != "" {
where = ` WHERE o.status=$1`
args = append ( args , status )
}
var total int64
if err := s . pool . QueryRow ( ctx , `SELECT COUNT(*) FROM certificate_physical_orders o` + where , args ... ). Scan ( & total ); err != nil {
return nil , 0 , err
}
2026-10-07 19:40:26 +03:30
q := `
SELECT o.id, o.certificate_id, o.user_id, o.status, o.note,
o.recipient_name, o.phone, o.address, o.city, o.postal_code, o.tracking_code,
o.window_ends_at, o.created_at, o.updated_at,
u.name, c.public_id
FROM certificate_physical_orders o
JOIN users u ON u.id = o.user_id
2026-10-09 19:05:59 +03:30
JOIN certificates c ON c.id = o.certificate_id` + where + ` ORDER BY o.created_at DESC LIMIT $` + strconv . Itoa ( len ( args ) + 1 ) + ` OFFSET $` + strconv . Itoa ( len ( args ) + 2 )
args = append ( args , limit , offset )
2026-10-07 19:40:26 +03:30
rows , err := s . pool . Query ( ctx , q , args ... )
if err != nil {
2026-10-09 19:05:59 +03:30
return nil , 0 , err
2026-10-07 19:40:26 +03:30
}
defer rows . Close ()
out := make ([] models . CertificatePhysicalOrder , 0 )
for rows . Next () {
var o models . CertificatePhysicalOrder
if err := rows . Scan ( & o . ID , & o . CertificateID , & o . UserID , & o . Status , & o . Note ,
& o . RecipientName , & o . Phone , & o . Address , & o . City , & o . PostalCode , & o . TrackingCode ,
& o . WindowEndsAt , & o . CreatedAt , & o . UpdatedAt , & o . UserName , & o . PublicID ); err != nil {
2026-10-09 19:05:59 +03:30
return nil , 0 , err
2026-10-07 19:40:26 +03:30
}
out = append ( out , o )
}
2026-10-09 19:05:59 +03:30
return out , total , rows . Err ()
2026-10-07 19:40:26 +03:30
}
func ( s * Store ) UpdatePhysicalOrder ( ctx context . Context , id int64 , status models . PhysicalOrderStatus , note , tracking string ) ( * models . CertificatePhysicalOrder , error ) {
row := s . pool . QueryRow ( ctx , `
UPDATE certificate_physical_orders
SET status=$2,
note=CASE WHEN $3='' THEN note ELSE $3 END,
tracking_code=CASE WHEN $4='' THEN tracking_code ELSE $4 END,
updated_at=now()
WHERE id=$1
RETURNING ` + physicalOrderCols ,
id , string ( status ), note , tracking )
return scanPhysicalOrder ( row )
}
func scanPhysicalOrder ( row rowScanner ) ( * models . CertificatePhysicalOrder , error ) {
var o models . CertificatePhysicalOrder
err := row . Scan (
& o . ID , & o . CertificateID , & o . UserID , & o . Status , & o . Note ,
& o . RecipientName , & o . Phone , & o . Address , & o . City , & o . PostalCode , & o . TrackingCode ,
& o . WindowEndsAt , & o . CreatedAt , & o . UpdatedAt ,
)
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
if err != nil {
return nil , err
}
return & o , nil
}
func ( s * Store ) CreateUnlockRequest ( ctx context . Context , userID int64 , note string ) ( * models . UnlockRequest , error ) {
row := s . pool . QueryRow ( ctx , `
INSERT INTO unlock_requests (user_id, note) VALUES ($1, $2)
RETURNING id, user_id, note, created_at, resolved_at` , userID , note )
var ur models . UnlockRequest
if err := row . Scan ( & ur . ID , & ur . UserID , & ur . Note , & ur . CreatedAt , & ur . ResolvedAt ); err != nil {
return nil , err
}
return & ur , nil
}
func ( s * Store ) LatestOpenUnlockRequest ( ctx context . Context , userID int64 , within time . Duration ) ( * models . UnlockRequest , error ) {
since := time . Now (). Add ( - within )
row := s . pool . QueryRow ( ctx , `
SELECT id, user_id, note, created_at, resolved_at
FROM unlock_requests
WHERE user_id=$1 AND resolved_at IS NULL AND created_at > $2
ORDER BY created_at DESC LIMIT 1` , userID , since )
var ur models . UnlockRequest
err := row . Scan ( & ur . ID , & ur . UserID , & ur . Note , & ur . CreatedAt , & ur . ResolvedAt )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
if err != nil {
return nil , err
}
return & ur , nil
}
func ( s * Store ) ResolveUnlockRequests ( ctx context . Context , userID int64 ) error {
_ , err := s . pool . Exec ( ctx , `
UPDATE unlock_requests SET resolved_at=now()
WHERE user_id=$1 AND resolved_at IS NULL` , userID )
return err
}
func ( s * Store ) GetSetting ( ctx context . Context , key string ) ( string , error ) {
var v string
err := s . pool . QueryRow ( ctx , `SELECT value FROM app_settings WHERE key=$1` , key ). Scan ( & v )
if errors . Is ( err , pgx . ErrNoRows ) {
return "" , ErrNotFound
}
return v , err
}
func ( s * Store ) SetSetting ( ctx context . Context , key , value string ) error {
_ , err := s . pool . Exec ( ctx , `
INSERT INTO app_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value` , key , value )
return err
}
func ( s * Store ) DeleteUser ( ctx context . Context , id int64 ) error {
tx , err := s . pool . Begin ( ctx )
if err != nil {
return err
}
defer tx . Rollback ( ctx )
queries := [] string {
`DELETE FROM watch_heartbeats WHERE user_id=$1 OR session_id IN (SELECT id FROM watch_sessions WHERE user_id=$1)` ,
`DELETE FROM watch_sessions WHERE user_id=$1` ,
`DELETE FROM quiz_attempts WHERE user_id=$1` ,
`DELETE FROM lesson_progress WHERE user_id=$1` ,
`DELETE FROM xp_events WHERE user_id=$1` ,
`DELETE FROM weekly_leaderboard WHERE user_id=$1` ,
`DELETE FROM event_rsvps WHERE user_id=$1` ,
`DELETE FROM chat_reactions WHERE user_id=$1` ,
`DELETE FROM chat_messages WHERE user_id=$1 OR mentor_id=$1` ,
`DELETE FROM notifications WHERE user_id=$1` ,
`DELETE FROM notification_prefs WHERE user_id=$1` ,
`DELETE FROM refresh_tokens WHERE user_id=$1` ,
`DELETE FROM reminders_sent WHERE user_id=$1` ,
`UPDATE users SET unlocked_by=NULL WHERE unlocked_by=$1` ,
`UPDATE events SET created_by=NULL WHERE created_by=$1` ,
`DELETE FROM users WHERE id=$1` ,
}
for _ , q := range queries {
if _ , err := tx . Exec ( ctx , q , id ); err != nil {
return err
}
}
return tx . Commit ( ctx )
}
// ---- Chapters / Lessons / Questions ----
func ( s * Store ) CreateChapter ( ctx context . Context , c * models . Chapter ) ( int64 , error ) {
2026-10-18 10:14:22 +03:30
c . IsPublished = false
2026-10-07 19:40:26 +03:30
err := s . pool . QueryRow ( ctx , `
2026-10-18 10:14:22 +03:30
INSERT INTO chapters (title, description, icon, sort_order, is_published) VALUES ($1,$2,$3,$4,$5) RETURNING id` ,
c . Title , c . Description , c . Icon , c . SortOrder , c . IsPublished ). Scan ( & c . ID )
2026-10-07 19:40:26 +03:30
return c . ID , err
}
func ( s * Store ) UpdateChapter ( ctx context . Context , id int64 , c * models . Chapter ) error {
_ , err := s . pool . Exec ( ctx , `
2026-10-18 10:14:22 +03:30
UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4, is_published=$5 WHERE id=$6` ,
c . Title , c . Description , c . Icon , c . SortOrder , c . IsPublished , id )
return err
}
func ( s * Store ) PublishChapter ( ctx context . Context , id int64 , publish bool ) error {
_ , err := s . pool . Exec ( ctx , `UPDATE chapters SET is_published=$1 WHERE id=$2` , publish , id )
2026-10-07 19:40:26 +03:30
return err
}
func ( s * Store ) DeleteChapter ( ctx context . Context , id int64 ) error {
_ , err := s . pool . Exec ( ctx , `DELETE FROM chapters WHERE id=$1` , id )
return err
}
func ( s * Store ) ListChapters ( ctx context . Context ) ([] models . Chapter , error ) {
2026-10-18 10:14:22 +03:30
rows , err := s . pool . Query ( ctx , `SELECT id, title, description, icon, sort_order, is_published FROM chapters ORDER BY sort_order, id` )
2026-10-07 19:40:26 +03:30
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] models . Chapter
for rows . Next () {
var c models . Chapter
2026-10-18 10:14:22 +03:30
if err := rows . Scan ( & c . ID , & c . Title , & c . Description , & c . Icon , & c . SortOrder , & c . IsPublished ); err != nil {
2026-10-07 19:40:26 +03:30
return nil , err
}
out = append ( out , c )
}
return out , rows . Err ()
}
func ( s * Store ) CreateLesson ( ctx context . Context , l * models . Lesson ) ( int64 , error ) {
2026-10-18 10:14:22 +03:30
l . IsPublished = false
2026-10-07 19:40:26 +03:30
err := s . pool . QueryRow ( ctx , `
INSERT INTO lessons (chapter_id, title, description, video_key, duration_seconds,
2026-10-18 10:14:22 +03:30
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id` ,
2026-10-07 19:40:26 +03:30
l . ChapterID , l . Title , l . Description , l . VideoKey , l . DurationSeconds ,
2026-10-18 10:14:22 +03:30
l . CompletionThresholdPct , l . RequiresLessonID , l . X , l . Y , l . SortOrder , l . XPReward , l . IsActive , l . IsPublished ). Scan ( & l . ID )
2026-10-07 19:40:26 +03:30
return l . ID , err
}
func ( s * Store ) UpdateLesson ( ctx context . Context , id int64 , l * models . Lesson ) error {
_ , err := s . pool . Exec ( ctx , `
UPDATE lessons SET chapter_id=$1, title=$2, description=$3, video_key=$4, duration_seconds=$5,
2026-10-18 10:14:22 +03:30
completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12, is_published=$13
WHERE id=$14` ,
2026-10-07 19:40:26 +03:30
l . ChapterID , l . Title , l . Description , l . VideoKey , l . DurationSeconds ,
2026-10-18 10:14:22 +03:30
l . CompletionThresholdPct , l . RequiresLessonID , l . X , l . Y , l . SortOrder , l . XPReward , l . IsActive , l . IsPublished , id )
return err
}
func ( s * Store ) PublishLesson ( ctx context . Context , id int64 , publish bool ) error {
_ , err := s . pool . Exec ( ctx , `UPDATE lessons SET is_published=$1 WHERE id=$2` , publish , id )
2026-10-07 19:40:26 +03:30
return err
}
func ( s * Store ) DeleteLesson ( ctx context . Context , id int64 ) error {
_ , err := s . pool . Exec ( ctx , `DELETE FROM lessons WHERE id=$1` , id )
return err
}
func ( s * Store ) GetLesson ( ctx context . Context , id int64 ) ( * models . Lesson , error ) {
row := s . pool . QueryRow ( ctx , `
SELECT id, chapter_id, title, description, video_key, duration_seconds,
2026-10-18 10:14:22 +03:30
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published
2026-10-07 19:40:26 +03:30
FROM lessons WHERE id=$1` , id )
var l models . Lesson
err := row . Scan ( & l . ID , & l . ChapterID , & l . Title , & l . Description , & l . VideoKey , & l . DurationSeconds ,
2026-10-18 10:14:22 +03:30
& l . CompletionThresholdPct , & l . RequiresLessonID , & l . X , & l . Y , & l . SortOrder , & l . XPReward , & l . IsActive , & l . IsPublished )
2026-10-07 19:40:26 +03:30
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return & l , err
}
func ( s * Store ) ListLessons ( ctx context . Context , includeInactive bool ) ([] models . Lesson , error ) {
q := `SELECT id, chapter_id, title, description, video_key, duration_seconds,
2026-10-18 10:14:22 +03:30
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published FROM lessons`
2026-10-07 19:40:26 +03:30
if ! includeInactive {
2026-10-18 10:14:22 +03:30
q += ` WHERE is_active AND is_published`
2026-10-07 19:40:26 +03:30
}
q += ` ORDER BY chapter_id, sort_order, id`
rows , err := s . pool . Query ( ctx , q )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] models . Lesson
for rows . Next () {
var l models . Lesson
if err := rows . Scan ( & l . ID , & l . ChapterID , & l . Title , & l . Description , & l . VideoKey , & l . DurationSeconds ,
2026-10-18 10:14:22 +03:30
& l . CompletionThresholdPct , & l . RequiresLessonID , & l . X , & l . Y , & l . SortOrder , & l . XPReward , & l . IsActive , & l . IsPublished ); err != nil {
2026-10-07 19:40:26 +03:30
return nil , err
}
out = append ( out , l )
}
return out , rows . Err ()
}
func ( s * Store ) CreateQuestion ( ctx context . Context , q * models . MCQQuestion ) ( int64 , error ) {
opts , _ := json . Marshal ( q . Options )
err := s . pool . QueryRow ( ctx , `
INSERT INTO mcq_questions (lesson_id, position, question, options, answer_index, explanation)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id` ,
q . LessonID , q . Position , q . Question , opts , q . AnswerIndex , q . Explanation ). Scan ( & q . ID )
return q . ID , err
}
func ( s * Store ) UpdateQuestion ( ctx context . Context , id int64 , q * models . MCQQuestion ) error {
opts , _ := json . Marshal ( q . Options )
_ , err := s . pool . Exec ( ctx , `
UPDATE mcq_questions SET position=$1, question=$2, options=$3, answer_index=$4, explanation=$5 WHERE id=$6` ,
q . Position , q . Question , opts , q . AnswerIndex , q . Explanation , id )
return err
}
func ( s * Store ) DeleteQuestion ( ctx context . Context , id int64 ) error {
_ , err := s . pool . Exec ( ctx , `DELETE FROM mcq_questions WHERE id=$1` , id )
return err
}
func ( s * Store ) ListQuestions ( ctx context . Context , lessonID int64 , withAnswers bool ) ([] models . MCQQuestion , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT id, lesson_id, position, question, options, answer_index, explanation
FROM mcq_questions WHERE lesson_id=$1 ORDER BY position, id` , lessonID )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] models . MCQQuestion
for rows . Next () {
var q models . MCQQuestion
var opts [] byte
if err := rows . Scan ( & q . ID , & q . LessonID , & q . Position , & q . Question , & opts , & q . AnswerIndex , & q . Explanation ); err != nil {
return nil , err
}
_ = json . Unmarshal ( opts , & q . Options )
if ! withAnswers {
q . AnswerIndex = - 1
}
out = append ( out , q )
}
return out , rows . Err ()
}
// ---- Watch sessions / heartbeats / progress ----
func ( s * Store ) GetOrCreateSession ( ctx context . Context , userID , lessonID int64 ) ( * models . WatchSession , error ) {
row := s . pool . QueryRow ( ctx , `
INSERT INTO watch_sessions (user_id, lesson_id) VALUES ($1,$2)
ON CONFLICT (user_id, lesson_id) DO UPDATE SET user_id=EXCLUDED.user_id
RETURNING id, user_id, lesson_id, started_at, last_heartbeat_at, last_position` ,
userID , lessonID )
var ws models . WatchSession
err := row . Scan ( & ws . ID , & ws . UserID , & ws . LessonID , & ws . StartedAt , & ws . LastHeartbeatAt , & ws . LastPosition )
return & ws , err
}
func ( s * Store ) InsertHeartbeat ( ctx context . Context , hb * models . WatchSession , position , delta float64 , seq int ) ( bool , error ) {
tag , err := s . pool . Exec ( ctx , `
INSERT INTO watch_heartbeats (user_id, lesson_id, session_id, position, delta, seq)
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (user_id, lesson_id, seq) DO NOTHING` ,
hb . UserID , hb . LessonID , hb . ID , position , delta , seq )
if err != nil {
return false , err
}
return tag . RowsAffected () > 0 , nil
}
func ( s * Store ) UpdateSessionPos ( ctx context . Context , sessionID int64 , position float64 ) error {
_ , err := s . pool . Exec ( ctx , `
UPDATE watch_sessions SET last_position=$1, last_heartbeat_at=now() WHERE id=$2` , position , sessionID )
return err
}
// MaxHeartbeatSeq returns the highest heartbeat seq for a user/lesson (0 if none).
func ( s * Store ) MaxHeartbeatSeq ( ctx context . Context , userID , lessonID int64 ) ( int , error ) {
var seq int
err := s . pool . QueryRow ( ctx , `
SELECT COALESCE(MAX(seq), 0) FROM watch_heartbeats
WHERE user_id=$1 AND lesson_id=$2` , userID , lessonID ). Scan ( & seq )
return seq , err
}
func ( s * Store ) GetProgress ( ctx context . Context , userID , lessonID int64 ) ( * models . LessonProgress , error ) {
row := s . pool . QueryRow ( ctx , `
SELECT user_id, lesson_id, watched_seconds, watched_pct, last_position,
quiz_unlocked, passed_quiz, quiz_completed_at
FROM lesson_progress WHERE user_id=$1 AND lesson_id=$2` , userID , lessonID )
var p models . LessonProgress
err := row . Scan ( & p . UserID , & p . LessonID , & p . WatchedSeconds , & p . WatchedPct , & p . LastPosition ,
& p . QuizUnlocked , & p . PassedQuiz , & p . QuizCompletedAt )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return & p , err
}
func ( s * Store ) UpsertProgress ( ctx context . Context , p * models . LessonProgress ) error {
_ , err := s . pool . Exec ( ctx , `
INSERT INTO lesson_progress (user_id, lesson_id, watched_seconds, watched_pct, last_position)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (user_id, lesson_id) DO UPDATE SET
watched_seconds = GREATEST(lesson_progress.watched_seconds, EXCLUDED.watched_seconds),
watched_pct = GREATEST(lesson_progress.watched_pct, EXCLUDED.watched_pct),
last_position = CASE
WHEN EXCLUDED.watched_seconds >= lesson_progress.watched_seconds THEN EXCLUDED.last_position
ELSE lesson_progress.last_position
END` ,
p . UserID , p . LessonID , p . WatchedSeconds , p . WatchedPct , p . LastPosition )
return err
}
func ( s * Store ) SetQuizUnlocked ( ctx context . Context , userID , lessonID int64 ) error {
_ , err := s . pool . Exec ( ctx , `
INSERT INTO lesson_progress (user_id, lesson_id, quiz_unlocked)
VALUES ($1,$2,true)
ON CONFLICT (user_id, lesson_id) DO UPDATE SET quiz_unlocked=true` ,
userID , lessonID )
return err
}
func ( s * Store ) MarkQuizPassed ( ctx context . Context , userID , lessonID int64 ) error {
_ , err := s . ClaimFirstQuizPass ( ctx , userID , lessonID )
return err
}
// ClaimFirstQuizPass marks the lesson passed only if it was not already passed.
// Returns true when this call is the first successful pass (XP should be awarded).
func ( s * Store ) ClaimFirstQuizPass ( ctx context . Context , userID , lessonID int64 ) ( bool , error ) {
_ , _ = s . pool . Exec ( ctx , `
INSERT INTO lesson_progress (user_id, lesson_id)
VALUES ($1,$2) ON CONFLICT (user_id, lesson_id) DO NOTHING` , userID , lessonID )
tag , err := s . pool . Exec ( ctx , `
UPDATE lesson_progress
SET passed_quiz=true,
quiz_unlocked=true,
quiz_completed_at=COALESCE(quiz_completed_at, now())
WHERE user_id=$1 AND lesson_id=$2 AND NOT passed_quiz` , userID , lessonID )
if err != nil {
return false , err
}
return tag . RowsAffected () > 0 , nil
}
func ( s * Store ) ProgressMap ( ctx context . Context , userID int64 ) ( map [ int64 ] * models . LessonProgress , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT user_id, lesson_id, watched_seconds, watched_pct, last_position,
quiz_unlocked, passed_quiz, quiz_completed_at
FROM lesson_progress WHERE user_id=$1` , userID )
if err != nil {
return nil , err
}
defer rows . Close ()
out := map [ int64 ] * models . LessonProgress {}
for rows . Next () {
var p models . LessonProgress
if err := rows . Scan ( & p . UserID , & p . LessonID , & p . WatchedSeconds , & p . WatchedPct , & p . LastPosition ,
& p . QuizUnlocked , & p . PassedQuiz , & p . QuizCompletedAt ); err != nil {
return nil , err
}
out [ p . LessonID ] = & p
}
return out , rows . Err ()
}
// ---- Quiz attempts ----
func ( s * Store ) CreateAttempt ( ctx context . Context , userID , lessonID int64 , answers [] int ) ( * models . QuizAttempt , error ) {
b , _ := json . Marshal ( answers )
row := s . pool . QueryRow ( ctx , `
INSERT INTO quiz_attempts (user_id, lesson_id, answers) VALUES ($1,$2,$3)
RETURNING id, user_id, lesson_id, answers, correct_count, total, score_pct, status, hearts_lost, passed_at, created_at` ,
userID , lessonID , b )
var a models . QuizAttempt
var raw [] byte
if err := row . Scan ( & a . ID , & a . UserID , & a . LessonID , & raw , & a . CorrectCount , & a . Total , & a . ScorePct ,
& a . Status , & a . HeartsLost , & a . PassedAt , & a . CreatedAt ); err != nil {
return nil , err
}
_ = json . Unmarshal ( raw , & a . Answers )
return & a , nil
}
func ( s * Store ) FinalizeAttempt ( ctx context . Context , attemptID int64 , correct , total int , scorePct float64 , status string , heartsLost int ) error {
_ , err := s . pool . Exec ( ctx , `
UPDATE quiz_attempts SET correct_count=$1, total=$2, score_pct=$3, status=($4)::quiz_status, hearts_lost=$5,
passed_at=CASE WHEN $4='passed' THEN now() ELSE passed_at END
WHERE id=$6` , correct , total , scorePct , status , heartsLost , attemptID )
return err
}
func ( s * Store ) LastAttempt ( ctx context . Context , userID , lessonID int64 ) ( * models . QuizAttempt , error ) {
row := s . pool . QueryRow ( ctx , `
SELECT id, user_id, lesson_id, answers, correct_count, total, score_pct, status, hearts_lost, passed_at, created_at
FROM quiz_attempts WHERE user_id=$1 AND lesson_id=$2 ORDER BY id DESC LIMIT 1` , userID , lessonID )
var a models . QuizAttempt
var raw [] byte
if err := row . Scan ( & a . ID , & a . UserID , & a . LessonID , & raw , & a . CorrectCount , & a . Total , & a . ScorePct ,
& a . Status , & a . HeartsLost , & a . PassedAt , & a . CreatedAt ); err != nil {
return nil , err
}
_ = json . Unmarshal ( raw , & a . Answers )
return & a , nil
}
// ---- XP / streaks / leaderboard ----
func ( s * Store ) AddXPEvent ( ctx context . Context , tx * Tx , userID int64 , amount int , source string , refID int64 ) error {
_ , err := tx . queries . Exec ( ctx , `INSERT INTO xp_events (user_id, amount, source, ref_id) VALUES ($1,$2,$3,$4)` ,
userID , amount , source , refID )
return err
}
func ( s * Store ) UpdateLeaderboardRow ( ctx context . Context , week string , userID int64 , amount int ) error {
_ , err := s . pool . Exec ( ctx , `
INSERT INTO weekly_leaderboard (week, user_id, xp) VALUES ($1,$2,$3)
ON CONFLICT (week, user_id) DO UPDATE SET xp = weekly_leaderboard.xp + EXCLUDED.xp` ,
week , userID , amount )
return err
}
// ---- Events / RSVPs ----
func ( s * Store ) CreateEvent ( ctx context . Context , e * models . Event ) ( int64 , error ) {
err := s . pool . QueryRow ( ctx , `
INSERT INTO events (title, description, event_type, external_url, starts_at, ends_at, is_active, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id` ,
e . Title , e . Description , e . EventType , e . ExternalURL , e . StartsAt , e . EndsAt , e . IsActive , e . CreatedBy ). Scan ( & e . ID )
return e . ID , err
}
func ( s * Store ) UpdateEvent ( ctx context . Context , id int64 , e * models . Event ) error {
_ , err := s . pool . Exec ( ctx , `
UPDATE events SET title=$1, description=$2, event_type=$3, external_url=$4, starts_at=$5, ends_at=$6, is_active=$7
WHERE id=$8` , e . Title , e . Description , e . EventType , e . ExternalURL , e . StartsAt , e . EndsAt , e . IsActive , id )
return err
}
func ( s * Store ) DeleteEvent ( ctx context . Context , id int64 ) error {
_ , err := s . pool . Exec ( ctx , `DELETE FROM events WHERE id=$1` , id )
return err
}
func ( s * Store ) GetEvent ( ctx context . Context , id int64 ) ( * models . Event , error ) {
row := s . pool . QueryRow ( ctx , `
SELECT id, title, description, event_type, external_url, starts_at, ends_at, is_active, created_at
FROM events WHERE id=$1` , id )
var e models . Event
err := row . Scan ( & e . ID , & e . Title , & e . Description , & e . EventType , & e . ExternalURL , & e . StartsAt , & e . EndsAt , & e . IsActive , & e . CreatedAt )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return & e , err
}
func ( s * Store ) ListUpcomingEvents ( ctx context . Context , userID int64 ) ([] models . Event , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT e.id, e.title, e.description, e.event_type, e.external_url, e.starts_at, e.ends_at, e.is_active, e.created_at,
CASE WHEN EXISTS (SELECT 1 FROM event_rsvps r WHERE r.event_id=e.id AND r.user_id=$1) THEN true ELSE false END AS rsvped
FROM events e WHERE e.is_active AND e.ends_at > now() ORDER BY e.starts_at ASC` , userID )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] models . Event
for rows . Next () {
var e models . Event
if err := rows . Scan ( & e . ID , & e . Title , & e . Description , & e . EventType , & e . ExternalURL , & e . StartsAt , & e . EndsAt , & e . IsActive , & e . CreatedAt , & e . Rsvped ); err != nil {
return nil , err
}
out = append ( out , e )
}
return out , rows . Err ()
}
func ( s * Store ) Rsvp ( ctx context . Context , eventID , userID int64 ) error {
_ , err := s . pool . Exec ( ctx , `INSERT INTO event_rsvps (event_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING` , eventID , userID )
return err
}
func ( s * Store ) Unrsvp ( ctx context . Context , eventID , userID int64 ) error {
_ , err := s . pool . Exec ( ctx , `DELETE FROM event_rsvps WHERE event_id=$1 AND user_id=$2` , eventID , userID )
return err
}
func ( s * Store ) EventRsvpUsers ( ctx context . Context , eventID int64 ) ([] models . User , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT ` + userCols + ` FROM users WHERE id IN (SELECT user_id FROM event_rsvps WHERE event_id=$1)` , eventID )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] models . User
for rows . Next () {
u , err := scanUser ( rows )
if err != nil {
return nil , err
}
out = append ( out , * u )
}
return out , rows . Err ()
}
// ---- Chat ----
2026-10-10 16:42:35 +03:30
func packChatAttachments ( atts [] models . Attachment ) ( typ , url , name * string , size * int64 , raw [] byte ) {
if len ( atts ) == 0 {
return nil , nil , nil , nil , nil
}
first := atts [ 0 ]
typ = new ( string )
* typ = first . Type
url = new ( string )
* url = first . URL
name = new ( string )
* name = first . Name
sz := first . Size
size = & sz
if len ( atts ) > 1 {
if b , err := json . Marshal ( atts ); err == nil {
raw = b
}
}
return
}
func ( s * Store ) AddChatMessage ( ctx context . Context , userID , mentorID int64 , senderRole models . Role , body string , replyTo * int64 , atts [] models . Attachment ) ( * models . ChatMessage , error ) {
attType , attURL , attName , attSize , raw := packChatAttachments ( atts )
var rawArg any
if len ( raw ) > 0 {
rawArg = string ( raw )
2026-10-07 19:40:26 +03:30
}
row := s . pool . QueryRow ( ctx , `
2026-10-10 16:42:35 +03:30
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, reply_to, attachment_type, attachment_url, attachment_name, attachment_size, attachments)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb)
2026-10-07 19:40:26 +03:30
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
2026-10-10 16:42:35 +03:30
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments` ,
userID , mentorID , senderRole . String (), body , replyTo , attType , attURL , attName , attSize , rawArg )
2026-10-07 19:40:26 +03:30
return scanMessage ( row )
}
// ImportChatMessage inserts a historical message (backup restore), preserving created_at / pin.
2026-10-10 16:42:35 +03:30
func ( s * Store ) ImportChatMessage ( ctx context . Context , userID , mentorID int64 , senderRole models . Role , body string , atts [] models . Attachment , createdAt time . Time , pinned bool ) ( * models . ChatMessage , error ) {
attType , attURL , attName , attSize , raw := packChatAttachments ( atts )
var rawArg any
if len ( raw ) > 0 {
rawArg = string ( raw )
2026-10-07 19:40:26 +03:30
}
if createdAt . IsZero () {
createdAt = time . Now (). UTC ()
}
var pinnedAt * time . Time
if pinned {
t := createdAt
pinnedAt = & t
}
row := s . pool . QueryRow ( ctx , `
2026-10-10 16:42:35 +03:30
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size, attachments, created_at, pinned_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11)
2026-10-07 19:40:26 +03:30
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
2026-10-10 16:42:35 +03:30
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments` ,
userID , mentorID , senderRole . String (), body , attType , attURL , attName , attSize , rawArg , createdAt , pinnedAt )
2026-10-07 19:40:26 +03:30
return scanMessage ( row )
}
// ListChatPartnerIDs returns distinct partner user ids for conversations involving me.
func ( s * Store ) ListChatPartnerIDs ( ctx context . Context , meID int64 ) ([] int64 , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT DISTINCT partner FROM (
SELECT user_id AS partner FROM chat_messages WHERE mentor_id=$1
UNION
SELECT mentor_id FROM chat_messages WHERE user_id=$1
) p
WHERE partner <> $1
ORDER BY partner` , meID )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] int64
for rows . Next () {
var id int64
if err := rows . Scan ( & id ); err != nil {
return nil , err
}
out = append ( out , id )
}
return out , rows . Err ()
}
// DeleteChatMessage removes one of my own messages in this conversation.
func ( s * Store ) DeleteChatMessage ( ctx context . Context , meID , partnerID , msgID int64 , meRole models . Role ) ( bool , error ) {
tag , err := s . pool . Exec ( ctx , `
DELETE FROM chat_messages
WHERE id=$1
AND ((user_id=$2 AND mentor_id=$3) OR (user_id=$3 AND mentor_id=$2))
AND sender_role=$4
AND (user_id=$2 OR mentor_id=$2)` , msgID , meID , partnerID , meRole . String ())
if err != nil {
return false , err
}
return tag . RowsAffected () > 0 , nil
}
2026-10-09 19:05:59 +03:30
// ReplaceChatAttachment swaps the photo, video, or voice on one of my messages and marks it edited.
// The new type must match the current attachment.
func ( s * Store ) ReplaceChatAttachment ( ctx context . Context , meID , partnerID , msgID int64 , meRole models . Role , body , attType , attURL , attName string , attSize int64 ) ( * models . ChatMessage , error ) {
2026-10-10 16:42:35 +03:30
elem , err := json . Marshal ( models . Attachment { Type : attType , URL : attURL , Name : attName , Size : attSize })
if err != nil {
return nil , err
}
2026-10-09 19:05:59 +03:30
row := s . pool . QueryRow ( ctx , `
UPDATE chat_messages
SET body=$1, edited_at=now(),
2026-10-10 16:42:35 +03:30
attachment_type=$2, attachment_url=$3, attachment_name=$4, attachment_size=$5,
attachments = CASE
WHEN attachments IS NULL THEN attachments
WHEN jsonb_typeof(attachments) <> 'array' THEN attachments
WHEN jsonb_array_length(attachments) = 0 THEN attachments
ELSE jsonb_set(attachments, '{0}', $6::jsonb, false)
END
WHERE id=$7
AND ((user_id=$8 AND mentor_id=$9) OR (user_id=$9 AND mentor_id=$8))
AND sender_role=$10
AND (user_id=$8 OR mentor_id=$8)
2026-10-09 19:05:59 +03:30
AND attachment_type=$2
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
2026-10-10 16:42:35 +03:30
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments` ,
body , attType , attURL , attName , attSize , string ( elem ), msgID , meID , partnerID , meRole . String ())
2026-10-09 19:05:59 +03:30
m , err := scanMessage ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return m , err
}
// EditChatMessage replaces the body of one of my own messages in this conversation.
func ( s * Store ) EditChatMessage ( ctx context . Context , meID , partnerID , msgID int64 , meRole models . Role , body string ) ( * models . ChatMessage , error ) {
row := s . pool . QueryRow ( ctx , `
UPDATE chat_messages
SET body=$1, edited_at=now()
WHERE id=$2
AND ((user_id=$3 AND mentor_id=$4) OR (user_id=$4 AND mentor_id=$3))
AND sender_role=$5
AND (user_id=$3 OR mentor_id=$3)
AND ($1 <> '' OR attachment_url IS NOT NULL)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
2026-10-10 16:42:35 +03:30
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments` ,
2026-10-09 19:05:59 +03:30
body , msgID , meID , partnerID , meRole . String ())
m , err := scanMessage ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return m , err
}
2026-10-10 16:42:35 +03:30
// GetOwnChatMessage loads one of my messages in this conversation, including its attachments.
func ( s * Store ) GetOwnChatMessage ( ctx context . Context , meID , partnerID , msgID int64 , meRole models . Role ) ( * models . ChatMessage , error ) {
row := s . pool . QueryRow ( ctx , `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments
FROM chat_messages
WHERE id=$1
AND ((user_id=$2 AND mentor_id=$3) OR (user_id=$3 AND mentor_id=$2))
AND sender_role=$4
AND (user_id=$2 OR mentor_id=$2)` ,
msgID , meID , partnerID , meRole . String ())
m , err := scanMessage ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return m , err
}
// SaveEditedChatMessage writes the caption and the full attachment set for one of my messages.
// Callers pass the attachments that should remain; an empty list is stored as no attachment.
func ( s * Store ) SaveEditedChatMessage ( ctx context . Context , meID , partnerID , msgID int64 , meRole models . Role , body string , atts [] models . Attachment ) ( * models . ChatMessage , error ) {
attType , attURL , attName , attSize , raw := packChatAttachments ( atts )
var rawArg any
if len ( raw ) > 0 {
rawArg = string ( raw )
}
row := s . pool . QueryRow ( ctx , `
UPDATE chat_messages
SET body=$1, edited_at=now(),
attachment_type=$2, attachment_url=$3, attachment_name=$4, attachment_size=$5,
attachments=$6::jsonb
WHERE id=$7
AND ((user_id=$8 AND mentor_id=$9) OR (user_id=$9 AND mentor_id=$8))
AND sender_role=$10
AND (user_id=$8 OR mentor_id=$8)
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments` ,
body , attType , attURL , attName , attSize , rawArg , msgID , meID , partnerID , meRole . String ())
m , err := scanMessage ( row )
if errors . Is ( err , pgx . ErrNoRows ) {
return nil , ErrNotFound
}
return m , err
}
2026-10-07 19:40:26 +03:30
const maxPinnedMessages = 3
// TogglePinChatMessage pins or unpins a message. If already at the pin limit,
// the oldest pin is replaced so a new message (including emoji-only) can be pinned.
func ( s * Store ) TogglePinChatMessage ( ctx context . Context , meID , partnerID , msgID int64 ) ( * models . ChatMessage , error ) {
ok , err := s . MessageInConversation ( ctx , msgID , meID , partnerID )
if err != nil {
return nil , err
}
if ! ok {
return nil , ErrNotFound
}
row := s . pool . QueryRow ( ctx , `
SELECT pinned_at FROM chat_messages WHERE id=$1` , msgID )
var pinnedAt * time . Time
if err := row . Scan ( & pinnedAt ); err != nil {
return nil , err
}
if pinnedAt != nil {
out := s . pool . QueryRow ( ctx , `
UPDATE chat_messages SET pinned_at=NULL WHERE id=$1
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
2026-10-10 16:42:35 +03:30
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments` , msgID )
2026-10-07 19:40:26 +03:30
return scanMessage ( out )
}
var n int
if err := s . pool . QueryRow ( ctx , `
SELECT COUNT(*) FROM chat_messages
WHERE pinned_at IS NOT NULL
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))` ,
meID , partnerID ). Scan ( & n ); err != nil {
return nil , err
}
if n >= maxPinnedMessages {
// Evict oldest pin in this conversation, then pin the new message.
if _ , err := s . pool . Exec ( ctx , `
UPDATE chat_messages SET pinned_at=NULL
WHERE id = (
SELECT id FROM chat_messages
WHERE pinned_at IS NOT NULL
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
ORDER BY pinned_at ASC
LIMIT 1
)` , meID , partnerID ); err != nil {
return nil , err
}
}
out := s . pool . QueryRow ( ctx , `
UPDATE chat_messages SET pinned_at=now() WHERE id=$1
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
2026-10-10 16:42:35 +03:30
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments` , msgID )
2026-10-07 19:40:26 +03:30
return scanMessage ( out )
}
// ListPinnedChatMessages returns pinned messages for a conversation (newest pin first).
func ( s * Store ) ListPinnedChatMessages ( ctx context . Context , userID , mentorID int64 ) ([] models . ChatMessage , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
2026-10-10 16:42:35 +03:30
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments
2026-10-07 19:40:26 +03:30
FROM chat_messages
WHERE pinned_at IS NOT NULL
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
ORDER BY pinned_at DESC
LIMIT $3` , userID , mentorID , maxPinnedMessages )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] models . ChatMessage
for rows . Next () {
m , err := scanMessage ( rows )
if err != nil {
return nil , err
}
out = append ( out , * m )
}
return out , rows . Err ()
}
// ListChatMessages returns the most recent `limit` messages strictly older than
// `before` (id cursor, 0 = no cutoff), ordered oldest-first.
func ( s * Store ) ListChatMessages ( ctx context . Context , userID , mentorID , before , limit int64 ) ([] models . ChatMessage , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
2026-10-10 16:42:35 +03:30
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments
2026-10-07 19:40:26 +03:30
FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND ($3 = 0 OR id < $3)
ORDER BY id DESC
LIMIT $4` , userID , mentorID , before , limit )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] models . ChatMessage
for rows . Next () {
m , err := scanMessage ( rows )
if err != nil {
return nil , err
}
out = append ( out , * m )
}
if err := rows . Err (); err != nil {
return nil , err
}
for i , j := 0 , len ( out ) - 1 ; i < j ; i , j = i + 1 , j - 1 {
out [ i ], out [ j ] = out [ j ], out [ i ]
}
return out , nil
}
// ChatMessageInPair reports whether msgID belongs to the conversation between the two users.
func ( s * Store ) ChatMessageInPair ( ctx context . Context , userID , partnerID , msgID int64 ) ( bool , error ) {
var ok bool
err := s . pool . QueryRow ( ctx , `
SELECT EXISTS(
SELECT 1 FROM chat_messages
WHERE id=$3 AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
)` , userID , partnerID , msgID ). Scan ( & ok )
return ok , err
}
// ListChatMessagesAround returns a window of messages centered on aroundID (oldest-first).
func ( s * Store ) ListChatMessagesAround ( ctx context . Context , userID , partnerID , aroundID , limit int64 ) ( msgs [] models . ChatMessage , hasMoreBefore , hasMoreAfter bool , err error ) {
if limit < 4 {
limit = 60
}
beforeLimit := limit / 2 + 1 // include target
afterLimit := limit / 2
older , err := s . pool . Query ( ctx , `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
2026-10-10 16:42:35 +03:30
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments
2026-10-07 19:40:26 +03:30
FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id <= $3
ORDER BY id DESC
LIMIT $4` , userID , partnerID , aroundID , beforeLimit + 1 )
if err != nil {
return nil , false , false , err
}
var before [] models . ChatMessage
for older . Next () {
m , scanErr := scanMessage ( older )
if scanErr != nil {
older . Close ()
return nil , false , false , scanErr
}
before = append ( before , * m )
}
older . Close ()
if err := older . Err (); err != nil {
return nil , false , false , err
}
if int64 ( len ( before )) > beforeLimit {
hasMoreBefore = true
before = before [: beforeLimit ]
}
for i , j := 0 , len ( before ) - 1 ; i < j ; i , j = i + 1 , j - 1 {
before [ i ], before [ j ] = before [ j ], before [ i ]
}
newer , err := s . pool . Query ( ctx , `
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
2026-10-10 16:42:35 +03:30
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at, buttons, attachments
2026-10-07 19:40:26 +03:30
FROM chat_messages
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
AND id > $3
ORDER BY id ASC
LIMIT $4` , userID , partnerID , aroundID , afterLimit + 1 )
if err != nil {
return nil , false , false , err
}
var after [] models . ChatMessage
for newer . Next () {
m , scanErr := scanMessage ( newer )
if scanErr != nil {
newer . Close ()
return nil , false , false , scanErr
}
after = append ( after , * m )
}
newer . Close ()
if err := newer . Err (); err != nil {
return nil , false , false , err
}
if int64 ( len ( after )) > afterLimit {
hasMoreAfter = true
after = after [: afterLimit ]
}
out := make ([] models . ChatMessage , 0 , len ( before ) + len ( after ))
out = append ( out , before ... )
out = append ( out , after ... )
return out , hasMoreBefore , hasMoreAfter , nil
}
type messageScanner interface {
Scan ( dest ... any ) error
}
func scanMessage ( row messageScanner ) ( * models . ChatMessage , error ) {
var m models . ChatMessage
var attType , attURL , attName * string
var attSize * int64
2026-10-10 16:42:35 +03:30
var buttons [] byte
var rawAtts [] byte
2026-10-07 19:40:26 +03:30
if err := row . Scan (
& m . ID , & m . UserID , & m . MentorID , & m . SenderRole , & m . Body , & m . ReadAt , & m . CreatedAt , & m . ReplyTo ,
2026-10-10 16:42:35 +03:30
& attType , & attURL , & attName , & attSize , & m . PinnedAt , & m . EditedAt , & buttons , & rawAtts ,
2026-10-07 19:40:26 +03:30
); err != nil {
return nil , err
}
2026-10-10 16:42:35 +03:30
if len ( buttons ) > 0 && string ( buttons ) != "[]" && string ( buttons ) != "null" {
m . Buttons = buttons
}
2026-10-07 19:40:26 +03:30
m . Pinned = m . PinnedAt != nil
2026-10-10 16:42:35 +03:30
if len ( rawAtts ) > 0 && string ( rawAtts ) != "null" && string ( rawAtts ) != "[]" {
var list [] models . Attachment
if err := json . Unmarshal ( rawAtts , & list ); err == nil && len ( list ) > 0 {
m . Attachments = list
first := list [ 0 ]
m . Attachment = & first
}
}
if m . Attachment == nil && attURL != nil && * attURL != "" {
2026-10-07 19:40:26 +03:30
m . Attachment = & models . Attachment {
Type : derefStr ( attType ), URL : * attURL , Name : derefStr ( attName ), Size : derefInt ( attSize ),
}
}
return & m , nil
}
func derefStr ( p * string ) string {
if p == nil {
return ""
}
return * p
}
func derefInt ( p * int64 ) int64 {
if p == nil {
return 0
}
return * p
}
2026-10-09 19:05:59 +03:30
// MarkChatRead stamps messages the other person sent. Opening the thread must not
// mark my own messages as seen.
func ( s * Store ) MarkChatRead ( ctx context . Context , meID , partnerID int64 , meRole models . Role ) ( int64 , error ) {
tag , err := s . pool . Exec ( ctx , `
UPDATE chat_messages
SET read_at=now()
WHERE read_at IS NULL
AND sender_role <> $3
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))` ,
meID , partnerID , meRole . String ())
if err != nil {
return 0 , err
}
return tag . RowsAffected (), nil
2026-10-07 19:40:26 +03:30
}
// MessageInConversation reports whether a message belongs to a given pair.
func ( s * Store ) MessageInConversation ( ctx context . Context , msgID , meID , partnerID int64 ) ( bool , error ) {
var one int
err := s . pool . QueryRow ( ctx , `
SELECT 1 FROM chat_messages
WHERE id=$1 AND ((user_id=$2 AND mentor_id=$3) OR (user_id=$3 AND mentor_id=$2))` ,
msgID , meID , partnerID ). Scan ( & one )
if err == pgx . ErrNoRows {
return false , nil
}
return err == nil , err
}
// ReactToChatMessage toggles a reaction on a message: same emoji removes it,
// a different emoji replaces it, a new one is inserted. Returns the resulting
// emoji ("" when removed) and the full updated set.
func ( s * Store ) ReactToChatMessage ( ctx context . Context , msgID , userID int64 , emoji string ) ( string , [] models . ChatReaction , error ) {
tx , err := s . pool . Begin ( ctx )
if err != nil {
return "" , nil , err
}
defer func () { _ = tx . Rollback ( ctx ) }()
var existing string
err = tx . QueryRow ( ctx , `SELECT emoji FROM chat_reactions WHERE message_id=$1 AND user_id=$2` , msgID , userID ). Scan ( & existing )
switch {
case err == nil && existing == emoji :
if _ , err := tx . Exec ( ctx , `DELETE FROM chat_reactions WHERE message_id=$1 AND user_id=$2` , msgID , userID ); err != nil {
return "" , nil , err
}
emoji = ""
case err == nil :
if _ , err := tx . Exec ( ctx , `UPDATE chat_reactions SET emoji=$3, created_at=now() WHERE message_id=$1 AND user_id=$2` , msgID , userID , emoji ); err != nil {
return "" , nil , err
}
case errors . Is ( err , pgx . ErrNoRows ):
if _ , err := tx . Exec ( ctx , `INSERT INTO chat_reactions (message_id, user_id, emoji) VALUES ($1,$2,$3)` , msgID , userID , emoji ); err != nil {
return "" , nil , err
}
default :
return "" , nil , err
}
if err := tx . Commit ( ctx ); err != nil {
return "" , nil , err
}
reactions , err := s . ReactionsFor ( ctx , msgID , userID )
if err != nil {
return "" , nil , err
}
return emoji , reactions , nil
}
// ReactionsFor returns the aggregated reactions for a single message.
func ( s * Store ) ReactionsFor ( ctx context . Context , msgID , meID int64 ) ([] models . ChatReaction , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT emoji, count(*)::int, bool_or(user_id=$2) AS mine
FROM chat_reactions WHERE message_id=$1
GROUP BY emoji ORDER BY count(*) DESC, emoji` , msgID , meID )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] models . ChatReaction
for rows . Next () {
var r models . ChatReaction
if err := rows . Scan ( & r . Emoji , & r . Count , & r . Mine ); err != nil {
return nil , err
}
out = append ( out , r )
}
return out , rows . Err ()
}
// FillReactions attaches aggregated reactions to the given messages (one query).
func ( s * Store ) FillReactions ( ctx context . Context , meID int64 , msgs [] models . ChatMessage ) error {
if len ( msgs ) == 0 {
return nil
}
ids := make ([] int64 , 0 , len ( msgs ))
for _ , m := range msgs {
if m . ID > 0 {
ids = append ( ids , m . ID )
}
}
if len ( ids ) == 0 {
return nil
}
rows , err := s . pool . Query ( ctx , `
SELECT message_id, emoji, count(*)::int, bool_or(user_id=$1) AS mine
FROM chat_reactions WHERE message_id = ANY($2)
GROUP BY message_id, emoji
ORDER BY count(*) DESC, emoji` , meID , ids )
if err != nil {
return err
}
defer rows . Close ()
byMsg := map [ int64 ][] models . ChatReaction {}
for rows . Next () {
var msgID int64
var r models . ChatReaction
if err := rows . Scan ( & msgID , & r . Emoji , & r . Count , & r . Mine ); err != nil {
return err
}
byMsg [ msgID ] = append ( byMsg [ msgID ], r )
}
if err := rows . Err (); err != nil {
return err
}
for i := range msgs {
if msgs [ i ]. ID > 0 {
msgs [ i ]. Reactions = byMsg [ msgs [ i ]. ID ]
}
}
return nil
}
// ---- Notifications ----
func ( s * Store ) Notify ( ctx context . Context , userID int64 , category , typeStr , title , body , route string , data any ) error {
d , _ := json . Marshal ( data )
_ , err := s . pool . Exec ( ctx , `
INSERT INTO notifications (user_id, category, type, title, body, route, data)
VALUES ($1,$2,$3,$4,$5,$6,$7)` ,
userID , category , typeStr , title , body , route , d )
return err
}
func ( s * Store ) CreateNotification ( ctx context . Context , n * models . Notification ) error {
d := n . Data
if d == nil {
d = json . RawMessage ( `{}` )
}
err := s . pool . QueryRow ( ctx , `
INSERT INTO notifications (user_id, category, type, title, body, route, data)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id, created_at` ,
n . UserID , n . Category , n . Type , n . Title , n . Body , n . Route , d ). Scan ( & n . ID , & n . CreatedAt )
return err
}
func ( s * Store ) GetPrefs ( ctx context . Context , userID int64 ) ( * models . NotificationPrefs , error ) {
if _ , err := s . pool . Exec ( ctx , `
INSERT INTO notification_prefs (user_id) VALUES ($1)
ON CONFLICT (user_id) DO NOTHING` , userID ); err != nil {
return nil , err
}
var p models . NotificationPrefs
if err := s . pool . QueryRow ( ctx , `
SELECT user_id, progress, gamification, mentor, event FROM notification_prefs WHERE user_id=$1` , userID ).
Scan ( & p . UserID , & p . Progress , & p . Gamification , & p . Mentor , & p . Event ); err != nil {
return nil , err
}
return & p , nil
}
func ( s * Store ) UpdatePrefs ( ctx context . Context , p * models . NotificationPrefs ) error {
_ , err := s . pool . Exec ( ctx , `
INSERT INTO notification_prefs (user_id, progress, gamification, mentor, event)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (user_id) DO UPDATE SET progress=$2, gamification=$3, mentor=$4, event=$5` ,
p . UserID , p . Progress , p . Gamification , p . Mentor , p . Event )
return err
}
func ( s * Store ) PrefEnabled ( ctx context . Context , userID int64 , category string ) ( bool , error ) {
var v bool
err := s . pool . QueryRow ( ctx , `
SELECT CASE $2
WHEN 'progress' THEN progress
WHEN 'gamification' THEN gamification
WHEN 'mentor' THEN mentor
WHEN 'event' THEN event
END FROM notification_prefs WHERE user_id=$1` , userID , category ). Scan ( & v )
if err != nil {
return true , nil
}
return v , nil
}
func ( s * Store ) ListNotifications ( ctx context . Context , userID int64 , limit , offset int ) ([] models . Notification , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT id, user_id, category, type, title, body, route, data, read_at, created_at
FROM notifications WHERE user_id=$1 ORDER BY id DESC LIMIT $2 OFFSET $3` , userID , limit , offset )
if err != nil {
return nil , err
}
defer rows . Close ()
return scanNotifications ( rows )
}
func scanNotifications ( rows pgx . Rows ) ([] models . Notification , error ) {
var out [] models . Notification
for rows . Next () {
var n models . Notification
if err := rows . Scan ( & n . ID , & n . UserID , & n . Category , & n . Type , & n . Title , & n . Body , & n . Route , & n . Data , & n . ReadAt , & n . CreatedAt ); err != nil {
return nil , err
}
out = append ( out , n )
}
return out , rows . Err ()
}
func ( s * Store ) UnreadCount ( ctx context . Context , userID int64 ) ( int , error ) {
var n int
err := s . pool . QueryRow ( ctx , `SELECT count(*) FROM notifications WHERE user_id=$1 AND read_at IS NULL` , userID ). Scan ( & n )
return n , err
}
func ( s * Store ) MarkRead ( ctx context . Context , userID , notifID int64 ) error {
_ , err := s . pool . Exec ( ctx , `UPDATE notifications SET read_at=now() WHERE id=$1 AND user_id=$2` , notifID , userID )
return err
}
func ( s * Store ) MarkAllRead ( ctx context . Context , userID int64 ) error {
_ , err := s . pool . Exec ( ctx , `UPDATE notifications SET read_at=now() WHERE user_id=$1 AND read_at IS NULL` , userID )
return err
}
// ---- Reminders ----
func ( s * Store ) ReminderSent ( ctx context . Context , userID int64 , kind , ref string ) ( bool , error ) {
var one int
err := s . pool . QueryRow ( ctx , `SELECT 1 FROM reminders_sent WHERE user_id=$1 AND kind=$2 AND ref=$3` , userID , kind , ref ). Scan ( & one )
if errors . Is ( err , pgx . ErrNoRows ) {
return false , nil
}
return true , err
}
func ( s * Store ) MarkReminderSent ( ctx context . Context , userID int64 , kind , ref string ) error {
_ , err := s . pool . Exec ( ctx , `INSERT INTO reminders_sent (user_id, kind, ref) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING` , userID , kind , ref )
return err
}
// ---- Video assets ----
func ( s * Store ) CreateVideoAsset ( ctx context . Context , key , mime string , size int64 ) error {
_ , err := s . pool . Exec ( ctx , `INSERT INTO video_assets (key, mime, size_bytes) VALUES ($1,$2,$3) ON CONFLICT (key) DO NOTHING` , key , mime , size )
return err
}
type VideoAssetRow struct {
Key string
Mime string
SizeBytes int64
CreatedAt time . Time
}
func ( s * Store ) ListVideoAssets ( ctx context . Context ) ([] VideoAssetRow , error ) {
rows , err := s . pool . Query ( ctx , `
SELECT key, mime, size_bytes, created_at
FROM video_assets
ORDER BY created_at DESC
LIMIT 500` )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] VideoAssetRow
for rows . Next () {
var v VideoAssetRow
if err := rows . Scan ( & v . Key , & v . Mime , & v . SizeBytes , & v . CreatedAt ); err != nil {
return nil , err
}
out = append ( out , v )
}
if out == nil {
out = [] VideoAssetRow {}
}
return out , rows . Err ()
}
func ( s * Store ) DeleteVideoAsset ( ctx context . Context , key string ) error {
_ , err := s . pool . Exec ( ctx , `DELETE FROM video_assets WHERE key=$1` , key )
return err
}
// ---- Refresh tokens ----
func ( s * Store ) CreateRefreshToken ( ctx context . Context , userID int64 , hash string , expiresAt time . Time ) error {
_ , err := s . pool . Exec ( ctx , `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1,$2,$3)` , userID , hash , expiresAt )
return err
}
func ( s * Store ) RevokeRefreshToken ( ctx context . Context , hash string ) error {
_ , err := s . pool . Exec ( ctx , `UPDATE refresh_tokens SET revoked_at=now() WHERE token_hash=$1` , hash )
return err
}
func ( s * Store ) RevokeAllUserRefreshTokens ( ctx context . Context , userID int64 ) error {
_ , err := s . pool . Exec ( ctx , `
UPDATE refresh_tokens SET revoked_at=now()
WHERE user_id=$1 AND revoked_at IS NULL` , userID )
return err
}
// ConsumeRefreshToken atomically revokes a valid refresh token and returns its
// user id plus the original lifetime (expires_at − created_at) so callers can
// preserve remember-me vs short session on rotation.
func ( s * Store ) ConsumeRefreshToken ( ctx context . Context , hash string ) ( userID int64 , lifetime time . Duration , ok bool , err error ) {
var expiresAt , createdAt time . Time
err = s . pool . QueryRow ( ctx , `
UPDATE refresh_tokens
SET revoked_at=now()
WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at > now()
RETURNING user_id, expires_at, created_at` , hash ). Scan ( & userID , & expiresAt , & createdAt )
if errors . Is ( err , pgx . ErrNoRows ) {
return 0 , 0 , false , nil
}
if err != nil {
return 0 , 0 , false , err
}
lifetime = expiresAt . Sub ( createdAt )
if lifetime < 0 {
lifetime = 0
}
return userID , lifetime , true , nil
}
func ( s * Store ) RefreshTokenValid ( ctx context . Context , hash string ) ( int64 , bool , error ) {
var userID int64
var expiresAt , revokedAt * time . Time
err := s . pool . QueryRow ( ctx , `
SELECT user_id, expires_at, revoked_at FROM refresh_tokens WHERE token_hash=$1` , hash ).
Scan ( & userID , & expiresAt , & revokedAt )
if errors . Is ( err , pgx . ErrNoRows ) {
return 0 , false , nil
}
if err != nil {
return 0 , false , err
}
ok := ( revokedAt == nil ) && ( expiresAt != nil && expiresAt . After ( time . Now ()))
return userID , ok , nil
}
func ( s * Store ) PurgeOldHeartbeats ( ctx context . Context , olderThan time . Duration ) ( int64 , error ) {
cutoff := time . Now (). Add ( - olderThan )
tag , err := s . pool . Exec ( ctx , `DELETE FROM watch_heartbeats WHERE created_at < $1` , cutoff )
if err != nil {
return 0 , err
}
return tag . RowsAffected (), nil
}
// ---- Announcements / stats ----
func ( s * Store ) AllActiveUserIDs ( ctx context . Context ) ([] int64 , error ) {
rows , err := s . pool . Query ( ctx , `SELECT id FROM users WHERE is_active` )
if err != nil {
return nil , err
}
defer rows . Close ()
var out [] int64
for rows . Next () {
var id int64
if err := rows . Scan ( & id ); err != nil {
return nil , err
}
out = append ( out , id )
}
return out , rows . Err ()
}
func ( s * Store ) Stats ( ctx context . Context ) ( map [ string ] any , error ) {
var users , lessons , questions , events , completions int64
2026-10-09 19:05:59 +03:30
if err := s . pool . QueryRow ( ctx , `SELECT count(*) FROM users WHERE role='student' AND is_active` ). Scan ( & users ); err != nil {
2026-10-07 19:40:26 +03:30
return nil , err
}
if err := s . pool . QueryRow ( ctx , `SELECT count(*) FROM lessons` ). Scan ( & lessons ); err != nil {
return nil , err
}
if err := s . pool . QueryRow ( ctx , `SELECT count(*) FROM mcq_questions` ). Scan ( & questions ); err != nil {
return nil , err
}
if err := s . pool . QueryRow ( ctx , `SELECT count(*) FROM events` ). Scan ( & events ); err != nil {
return nil , err
}
if err := s . pool . QueryRow ( ctx , `SELECT count(*) FROM lesson_progress WHERE passed_quiz` ). Scan ( & completions ); err != nil {
return nil , err
}
return map [ string ] any {
"users" : users , "lessons" : lessons , "questions" : questions ,
"events" : events , "completions" : completions ,
}, nil
}