Files
spsays-bff/internal/model/user.go
T
2026-08-07 23:47:03 +05:30

171 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package model
import (
"encoding/json"
"time"
)
// User represents a row in the `users` table.
//
// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling
// even if a caller accidentally passes a User through response.JSON. It's a
// pointer because OAuth-only users have no password — *PasswordHash == nil
// means "this account can only sign in via a linked OAuth provider".
//
// UserSummary is the lightweight projection used in follower / following
// lists — just enough to render a row and link to the profile.
type UserSummary struct {
// ID is populated only where the caller needs it (e.g. mention add/remove);
// omitted from follower/like lists that link by handle.
ID string `json:"id,omitempty"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
}
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
// when the user has not yet verified, instead of an omitted field — the
// frontend uses this to decide whether to show the "verify your email" ribbon.
type User struct {
ID string `json:"id"`
// Email is nullable: OAuth users (typically Facebook accounts with no
// verified email on file) sign up without one and can fill it in later
// from their profile.
Email *string `json:"email"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Role string `json:"role"`
Plan string `json:"plan"` // "free" | "premium"
// Country is the account's ISO-3166-1 alpha-2 code (e.g. "IN"),
// captured best-effort at signup and editable from the profile form.
// nil until known.
Country *string `json:"country"`
// DiscipleID links this account to a row in the Mongo `disciples`
// collection (ObjectID hex) when the account belongs to one of Śrīla
// Prabhupāda's own initiated disciples. Admin-set only — never
// self-serve — and unique across users, so one disciple maps to at most
// one account. nil for everybody else.
//
// Tagged `json:"-"`: the raw cross-store id is an internal detail. The
// public profile exposes only the derived `is_direct_disciple` flag.
DiscipleID *string `json:"-"`
// Bio is a short self-introduction (max 200 characters, enforced in the
// service by rune count) shown on the profile header. Stored as PLAIN
// TEXT — `@handle` mentions are kept verbatim and linkified by the
// frontend at render time, so it must never be injected as HTML.
// nil when unset.
Bio *string `json:"bio"`
// --- Devotional profile (user-editable) ---
// Gender is a free-but-constrained string ("male"|"female"|"other"),
// nil when unspecified.
Gender *string `json:"gender"`
// Sampradaya is the devotee's spiritual lineage (a lowercase code,
// e.g. "gaudiya"). Optional — new accounts default to "gaudiya" but it
// can be cleared, so nil means "not specified".
Sampradaya *string `json:"sampradaya"`
// Initiated marks whether the devotee has received initiation.
Initiated bool `json:"initiated"`
// InitiatedAt is the initiation date (DATE column), nil when unknown or
// not initiated.
InitiatedAt *time.Time `json:"initiated_at"`
// DailyChantingRounds is the devotee's daily japa round vow (1–1008),
// nil when unspecified.
DailyChantingRounds *int `json:"daily_chanting_rounds"`
// --- Notification scheduling (system-managed; no UI yet) ---
// PreferredChantingTime / PeakActiveTime store a local clock time as
// "HH:MM". They're written silently by the backend to time automated
// notifications; the scheduling logic ships later. nil until computed.
PreferredChantingTime *string `json:"preferred_chanting_time"`
PeakActiveTime *string `json:"peak_active_time"`
EmailVerifiedAt *time.Time `json:"email_verified_at"`
PasswordHash *string `json:"-"`
TokenVersion int `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// BannedAt suspends the account when set: RequireAuth rejects the session
// and LogIn refuses to issue a new one. nil = in good standing.
BannedAt *time.Time `json:"banned_at,omitempty"`
// Per-user save-limit overrides (admin-settable; nil = use the plan
// default). Never serialized raw — surfaced only via the derived `limits`
// object in MarshalJSON so the app can render/enforce the right caps.
BookmarkLimit *int `json:"-"`
PublicCollectionLimit *int `json:"-"`
PrivateCollectionLimit *int `json:"-"`
}
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
// "Change password" vs "Set a password" for OAuth-only accounts, without
// the password hash itself ever leaving the server. `is_direct_disciple` is
// likewise derived — the raw cross-store DiscipleID stays server-side while
// the frontend gets the one bit it needs to render the badge.
func (u User) MarshalJSON() ([]byte, error) {
type alias User
return json.Marshal(&struct {
alias
HasPassword bool `json:"has_password"`
IsDirectDisciple bool `json:"is_direct_disciple"`
Limits UserLimits `json:"limits"`
}{
alias: alias(u),
HasPassword: u.PasswordHash != nil,
IsDirectDisciple: u.DiscipleID != nil && *u.DiscipleID != "",
Limits: u.EffectiveLimits(),
})
}
// AuthToken represents a row in the `auth_tokens` table.
// Used for both password reset and email verification flows.
type AuthToken struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Purpose string `json:"purpose"` // "password_reset" | "email_verification"
TokenHash string `json:"-"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
const (
PurposePasswordReset = "password_reset"
PurposeEmailVerification = "email_verification"
// PurposeLoginOTP is a 6-digit one-time code emailed to a user whose
// account has been unverified for >30 days. Verification happens
// per-user (FindValidForUser), so the 1M-keyspace can't be brute-forced
// offline by hash collision — only online against rate limits.
PurposeLoginOTP = "login_otp"
)
// OAuthAccount represents a row in the `oauth_accounts` table — one entry
// per (provider, provider_user_id). A single user can have several linked
// providers.
type OAuthAccount struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Provider string `json:"provider"`
ProviderUserID string `json:"provider_user_id"`
Email *string `json:"email"`
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ShortURL represents a shortened URL.
type ShortURL struct {
ID string `json:"id"`
Slug string `json:"slug"`
TargetURL string `json:"target_url"`
CreatedBy *string `json:"created_by,omitempty"`
ClickCount int64 `json:"click_count"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}