171 lines
7.1 KiB
Go
171 lines
7.1 KiB
Go
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"`
|
||
}
|