- Leaderboard: rank-based percentile tiers (champion/gold/silver/bronze),
deterministic tie-breaking (xp DESC, user_id ASC)
- Mentor chat: md: breakpoints, safe-area-inset-bottom padding
- Learning progress: server-side progressPct, curriculum expansion safety
- Physical orders: hard-disabled with 410 Gone
- Certificate approval: pending/issued flow, admin issue/revoke endpoints,
certificate_requires_approval setting (migration 0031)
- Draft/publish: is_published on lessons and chapters, admin publish UI
(migration 0030)
- Video panel: static thumbnails (no preload), client-side pagination
- Rich editor: TipTap + tiptap-markdown for lesson descriptions,
marked + DOMPurify for SafeMarkdown rendering
392 lines
14 KiB
Go
392 lines
14 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding/json"
|
|
"time"
|
|
)
|
|
|
|
type Role string
|
|
|
|
const (
|
|
RoleStudent Role = "student"
|
|
RoleMentor Role = "mentor"
|
|
RoleAdmin Role = "admin"
|
|
)
|
|
|
|
func (r Role) String() string { return string(r) }
|
|
|
|
// IsStaff reports whether the role is exempt from student heart/lockout limits.
|
|
func (r Role) IsStaff() bool {
|
|
return r == RoleAdmin || r == RoleMentor
|
|
}
|
|
|
|
type User struct {
|
|
ID int64 `json:"id"`
|
|
Email string `json:"email"`
|
|
Username string `json:"username"`
|
|
PasswordHash string `json:"-"`
|
|
Name string `json:"name"`
|
|
Role Role `json:"role"`
|
|
XP int `json:"xp"`
|
|
Hearts int `json:"hearts"`
|
|
HeartsUpdatedAt time.Time `json:"heartsUpdatedAt"`
|
|
StreakCurrent int `json:"streakCurrent"`
|
|
StreakLongest int `json:"streakLongest"`
|
|
LastActivityDate *time.Time `json:"lastActivityDate,omitempty"`
|
|
IsLocked bool `json:"isLocked"`
|
|
LockedAt *time.Time `json:"lockedAt,omitempty"`
|
|
UnlockedBy *int64 `json:"unlockedBy,omitempty"`
|
|
IsActive bool `json:"isActive"`
|
|
AvatarVariant string `json:"avatarVariant"`
|
|
AvatarPalette string `json:"avatarPalette"`
|
|
AvatarPhoto string `json:"avatarPhoto,omitempty"`
|
|
Phone string `json:"phone"`
|
|
TelegramID *int64 `json:"-"`
|
|
TelegramUsername string `json:"-"`
|
|
SecurityQuestion string `json:"securityQuestion,omitempty"`
|
|
SecurityAnswerHash string `json:"-"`
|
|
HasSecurityAnswer bool `json:"hasSecurityAnswer"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UsernameChangeCount int `json:"-"`
|
|
UsernameCooldownUntil *time.Time `json:"usernameCooldownUntil,omitempty"`
|
|
UsernameChangesRemaining int `json:"usernameChangesRemaining"`
|
|
FrozenAt *time.Time `json:"frozenAt,omitempty"`
|
|
ClosedAt *time.Time `json:"closedAt,omitempty"`
|
|
IsFrozen bool `json:"isFrozen"`
|
|
IsClosed bool `json:"isClosed"`
|
|
ClosesAt *time.Time `json:"closesAt,omitempty"`
|
|
}
|
|
|
|
const (
|
|
UsernameChangeLimit = 3
|
|
UsernameChangeCooldown = 7 * 24 * time.Hour
|
|
AccountFreezeGraceDays = 30
|
|
)
|
|
|
|
// ApplyUsernameQuota fills UsernameChangesRemaining from the stored count.
|
|
// An expired cooldown clears the allowance so the next window starts at the limit.
|
|
func (u *User) ApplyUsernameQuota(now time.Time) {
|
|
if u.UsernameCooldownUntil != nil && !now.Before(*u.UsernameCooldownUntil) {
|
|
u.UsernameChangeCount = 0
|
|
u.UsernameCooldownUntil = nil
|
|
}
|
|
if u.UsernameCooldownUntil != nil && now.Before(*u.UsernameCooldownUntil) {
|
|
u.UsernameChangesRemaining = 0
|
|
return
|
|
}
|
|
remaining := UsernameChangeLimit - u.UsernameChangeCount
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
u.UsernameChangesRemaining = remaining
|
|
}
|
|
|
|
// ApplyFreezeState derives freeze and closure from frozen_at and closed_at.
|
|
// Frozen is frozen_at set, closed_at empty, and now still inside the 30-day window.
|
|
// Closed is closed_at set, or the window having elapsed.
|
|
func (u *User) ApplyFreezeState(now time.Time) {
|
|
u.IsFrozen = false
|
|
u.IsClosed = false
|
|
u.ClosesAt = nil
|
|
if u.ClosedAt != nil {
|
|
u.IsClosed = true
|
|
return
|
|
}
|
|
if u.FrozenAt == nil {
|
|
return
|
|
}
|
|
closes := u.FrozenAt.AddDate(0, 0, AccountFreezeGraceDays)
|
|
if !now.Before(closes) {
|
|
u.IsClosed = true
|
|
return
|
|
}
|
|
u.IsFrozen = true
|
|
u.ClosesAt = &closes
|
|
}
|
|
|
|
type Certificate struct {
|
|
ID int64 `json:"id"`
|
|
UserID int64 `json:"userId"`
|
|
PublicID string `json:"publicId,omitempty"`
|
|
FullName string `json:"fullName"`
|
|
IssuedAt time.Time `json:"issuedAt"`
|
|
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type PhysicalOrderStatus string
|
|
|
|
const (
|
|
PhysicalRequested PhysicalOrderStatus = "requested"
|
|
PhysicalPaid PhysicalOrderStatus = "paid"
|
|
PhysicalShipped PhysicalOrderStatus = "shipped"
|
|
PhysicalCancelled PhysicalOrderStatus = "cancelled"
|
|
)
|
|
|
|
type CertificatePhysicalOrder struct {
|
|
ID int64 `json:"id"`
|
|
CertificateID int64 `json:"certificateId"`
|
|
UserID int64 `json:"userId"`
|
|
Status PhysicalOrderStatus `json:"status"`
|
|
Note string `json:"note"`
|
|
RecipientName string `json:"recipientName"`
|
|
Phone string `json:"phone"`
|
|
Address string `json:"address"`
|
|
City string `json:"city"`
|
|
PostalCode string `json:"postalCode"`
|
|
TrackingCode string `json:"trackingCode"`
|
|
WindowEndsAt time.Time `json:"windowEndsAt"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
UserName string `json:"userName,omitempty"`
|
|
PublicID string `json:"publicId,omitempty"`
|
|
}
|
|
|
|
type UnlockRequest struct {
|
|
ID int64 `json:"id"`
|
|
UserID int64 `json:"userId"`
|
|
Note string `json:"note"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
ResolvedAt *time.Time `json:"resolvedAt,omitempty"`
|
|
UserName string `json:"userName,omitempty"`
|
|
}
|
|
|
|
type NotificationPrefs struct {
|
|
UserID int64 `json:"userId"`
|
|
Progress bool `json:"progress"`
|
|
Gamification bool `json:"gamification"`
|
|
Mentor bool `json:"mentor"`
|
|
Event bool `json:"event"`
|
|
}
|
|
|
|
type Chapter struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Icon string `json:"icon"`
|
|
SortOrder int `json:"sortOrder"`
|
|
IsPublished bool `json:"isPublished"`
|
|
}
|
|
|
|
type Lesson struct {
|
|
ID int64 `json:"id"`
|
|
ChapterID int64 `json:"chapterId"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
VideoKey string `json:"videoKey"`
|
|
DurationSeconds int `json:"durationSeconds"`
|
|
CompletionThresholdPct int `json:"completionThresholdPct"`
|
|
RequiresLessonID *int64 `json:"requiresLessonId,omitempty"`
|
|
X int `json:"x"`
|
|
Y int `json:"y"`
|
|
SortOrder int `json:"sortOrder"`
|
|
XPReward int `json:"xpReward"`
|
|
IsActive bool `json:"isActive"`
|
|
IsPublished bool `json:"isPublished"`
|
|
}
|
|
|
|
type MCQQuestion struct {
|
|
ID int64 `json:"id"`
|
|
LessonID int64 `json:"lessonId"`
|
|
Position int `json:"position"`
|
|
Question string `json:"question"`
|
|
Options []string `json:"options"`
|
|
AnswerIndex int `json:"answerIndex"`
|
|
Explanation string `json:"explanation"`
|
|
}
|
|
|
|
type LessonProgress struct {
|
|
UserID int64 `json:"userId"`
|
|
LessonID int64 `json:"lessonId"`
|
|
WatchedSeconds float64 `json:"watchedSeconds"`
|
|
WatchedPct float64 `json:"watchedPct"`
|
|
LastPosition float64 `json:"lastPosition"`
|
|
QuizUnlocked bool `json:"quizUnlocked"`
|
|
PassedQuiz bool `json:"passedQuiz"`
|
|
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
|
|
}
|
|
|
|
type WatchSession struct {
|
|
ID int64 `json:"id"`
|
|
UserID int64 `json:"userId"`
|
|
LessonID int64 `json:"lessonId"`
|
|
StartedAt time.Time `json:"startedAt"`
|
|
LastHeartbeatAt time.Time `json:"lastHeartbeatAt"`
|
|
LastPosition float64 `json:"lastPosition"`
|
|
}
|
|
|
|
type QuizQuestionView struct {
|
|
ID int64 `json:"id"`
|
|
Position int `json:"position"`
|
|
Question string `json:"question"`
|
|
Options []string `json:"options"`
|
|
}
|
|
|
|
type QuizAttempt struct {
|
|
ID int64 `json:"id"`
|
|
UserID int64 `json:"userId"`
|
|
LessonID int64 `json:"lessonId"`
|
|
Answers []int `json:"-"`
|
|
CorrectCount int `json:"correctCount"`
|
|
Total int `json:"total"`
|
|
ScorePct float64 `json:"scorePct"`
|
|
Status string `json:"status"`
|
|
HeartsLost int `json:"heartsLost"`
|
|
PassedAt *time.Time `json:"passedAt,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type Event struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
EventType string `json:"eventType"`
|
|
ExternalURL string `json:"externalUrl"`
|
|
StartsAt time.Time `json:"startsAt"`
|
|
EndsAt time.Time `json:"endsAt"`
|
|
IsActive bool `json:"isActive"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
CreatedBy *int64 `json:"-"`
|
|
Rsvped bool `json:"rsvped,omitempty"`
|
|
}
|
|
|
|
// Challenge is a timed XP goal (typically monthly) for students.
|
|
type Challenge struct {
|
|
ID int64 `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
TargetXP int `json:"targetXp"`
|
|
StartsAt time.Time `json:"startsAt"`
|
|
EndsAt time.Time `json:"endsAt"`
|
|
IsActive bool `json:"isActive"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
CreatedBy *int64 `json:"-"`
|
|
// Progress fields for the requesting student (optional).
|
|
MyXP int `json:"myXp,omitempty"`
|
|
Completed bool `json:"completed,omitempty"`
|
|
}
|
|
|
|
type ChatMessage struct {
|
|
ID int64 `json:"id"`
|
|
UserID int64 `json:"userId"`
|
|
MentorID int64 `json:"mentorId"`
|
|
SenderRole Role `json:"senderRole"`
|
|
Body string `json:"body"`
|
|
ReadAt *time.Time `json:"readAt,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
ReplyTo *int64 `json:"replyTo,omitempty"`
|
|
PinnedAt *time.Time `json:"pinnedAt,omitempty"`
|
|
Pinned bool `json:"pinned"`
|
|
EditedAt *time.Time `json:"editedAt,omitempty"`
|
|
Attachment *Attachment `json:"attachment,omitempty"`
|
|
Attachments []Attachment `json:"attachments,omitempty"`
|
|
Reactions []ChatReaction `json:"reactions,omitempty"`
|
|
Buttons json.RawMessage `json:"buttons,omitempty"`
|
|
}
|
|
|
|
type ChatReaction struct {
|
|
Emoji string `json:"emoji"`
|
|
Count int `json:"count"`
|
|
Mine bool `json:"mine"`
|
|
}
|
|
|
|
type Attachment struct {
|
|
Type string `json:"type"` // image | video | audio | file
|
|
URL string `json:"url"`
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
type Notification struct {
|
|
ID int64 `json:"id"`
|
|
UserID int64 `json:"userId"`
|
|
Category string `json:"category"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
Route string `json:"route"`
|
|
Data json.RawMessage `json:"data"`
|
|
ReadAt *time.Time `json:"readAt,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type InviteStatus string
|
|
|
|
const (
|
|
InviteActive InviteStatus = "active"
|
|
InvitePaused InviteStatus = "paused"
|
|
InviteRevoked InviteStatus = "revoked"
|
|
InviteExhausted InviteStatus = "exhausted"
|
|
)
|
|
|
|
// RegistrationInvite is a capacity-limited membership registration link.
|
|
type RegistrationInvite struct {
|
|
ID int64 `json:"id"`
|
|
Label string `json:"label"`
|
|
MaxUses int `json:"maxUses"`
|
|
UsedCount int `json:"usedCount"`
|
|
Remaining int `json:"remaining"`
|
|
Status InviteStatus `json:"status"`
|
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
|
CreatedBy *int64 `json:"createdBy,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
// Token is the raw invite token; only populated when decrypting for admin URL display.
|
|
Token string `json:"token,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
// ChatExportToken is a time-limited, single-use download link for the project chat archive.
|
|
type ChatExportToken struct {
|
|
ID int64 `json:"id"`
|
|
Label string `json:"label"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
|
UsedAt *time.Time `json:"usedAt,omitempty"`
|
|
CreatedBy *int64 `json:"createdBy,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
|
Active bool `json:"active"`
|
|
// Token/URL are populated for admin display after decrypt.
|
|
Token string `json:"token,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
type WhitelistStatus string
|
|
|
|
const (
|
|
WhitelistAvailable WhitelistStatus = "available"
|
|
WhitelistConsumed WhitelistStatus = "consumed"
|
|
)
|
|
|
|
// RegistrationPhoneWhitelist is a one-shot phone allowed for public registration.
|
|
type RegistrationPhoneWhitelist struct {
|
|
ID int64 `json:"id"`
|
|
Phone string `json:"phone"`
|
|
Status WhitelistStatus `json:"status"`
|
|
ConsumedAt *time.Time `json:"consumedAt,omitempty"`
|
|
ConsumedUserID *int64 `json:"consumedUserId,omitempty"`
|
|
CreatedBy *int64 `json:"createdBy,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// RegistrationPhoneBlacklist blocks a phone from all registration paths.
|
|
type RegistrationPhoneBlacklist struct {
|
|
ID int64 `json:"id"`
|
|
Phone string `json:"phone"`
|
|
CreatedBy *int64 `json:"createdBy,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// RegistrationBlacklistAttempt records a failed registration try with a blacklisted phone.
|
|
type RegistrationBlacklistAttempt struct {
|
|
ID int64 `json:"id"`
|
|
Phone string `json:"phone"`
|
|
Path string `json:"path"` // public | invite
|
|
InviteID *int64 `json:"inviteId,omitempty"`
|
|
IP string `json:"ip"`
|
|
UserAgent string `json:"userAgent,omitempty"`
|
|
AttemptedUsername string `json:"attemptedUsername,omitempty"`
|
|
AttemptedEmail string `json:"attemptedEmail,omitempty"`
|
|
AttemptedAt time.Time `json:"attemptedAt"`
|
|
}
|