Files
pad/internal/models/user.go
T
xarmian 0c5ec04fac feat(admin): extend user list with aggregations + sort/filter (TASK-1544) (#599)
* feat(admin): extend user list with aggregations + sort/filter (TASK-1544)

GET /admin/users now returns per-user workspace_count, storage_bytes,
last_write_at, and a computed status pill (disabled / no-workspace /
inactive / active, with documented precedence). Adds sort and filter
knobs so the table can scale beyond the existing fixed offset/limit.

Store layer:

- AdminUserSearchParams gains Role, Sort, Order, ActiveWithinDays,
  HasWorkspaces, Disabled. Pointer types where tri-state ("no filter"
  vs. "filter to false") matters.

- AdminUserListEntry wraps models.User with WorkspaceCount, StorageBytes,
  Status — returned in AdminUserSearchResult.Users.

- SearchUsers SQL rewritten: LEFT JOIN against grouped subqueries so
  one user owning N workspaces with M attachments each still produces
  exactly one row (no aggregation explosion). Both subqueries filter
  deleted_at IS NULL to match WorkspaceStorageUsage's existing
  definition. Allow-listed sort clause prevents injection.

- computeAdminUserStatus exported for unit tests; precedence locked in
  by TestComputeAdminUserStatus.

- TestSearchUsersAggregations covers workspace_count + storage_bytes +
  status across a three-user fixture and each new filter/sort knob.

Model + scanner:

- models.User gains LastWriteAt. userColumns + scanUser updated; the
  legacy callers (GetUser, ListUsers, etc.) inherit the new field for
  free via the shared scanner.

Handler:

- handleAdminListUsers accepts the new params: role, disabled,
  has_workspaces, active_within_days, sort, order. Tri-state bools
  only fire when the query param is present. Response now embeds
  workspace_count / storage_bytes / last_write_at / status.

Part of PLAN-1542. Frontend consumption lands in T1548 (cheap columns)
and T1549 (sort/filter UI).

* fix: address Codex review on TASK-1544

- Tri-state bool parsing in handler now uses strconv.ParseBool — accepts
  the canonical truthy/falsy variants ("True"/"TRUE"/"t"/"1" and the
  parallel falses), and silently ignores garbage values rather than
  treating them as false. Closes the "disabled=TRUE silently means
  enabled-only" surprise.

- SearchUsers count query no longer joins the storage aggregation when
  HasWorkspaces isn't an active filter. The page query still needs both
  joins (the row carries the data), but a typical "give me a count"
  call no longer scans every live attachment. The workspace_count join
  remains conditional on HasWorkspaces filtering.

Status threshold (>30d vs >=30d): the documented spec and impl both say
">30d" — no change.
2026-05-20 16:50:16 -04:00

131 lines
5.1 KiB
Go

package models
import (
"encoding/json"
"time"
)
// User represents a registered user in the system.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Username string `json:"username"` // Unique handle; empty until set
Name string `json:"name"`
PasswordHash string `json:"-"` // Never serialized
Role string `json:"role"` // "admin" or "member"
AvatarURL string `json:"avatar_url,omitempty"`
TOTPSecret string `json:"-"` // Never serialized
TOTPEnabled bool `json:"totp_enabled"`
RecoveryCodes string `json:"-"` // Never serialized
Plan string `json:"plan"` // "free", "pro", or "self-hosted"
PlanExpiresAt string `json:"plan_expires_at,omitempty"`
StripeCustomerID string `json:"-"` // Never serialized
PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits
OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"]
PasswordSet bool `json:"password_set"` // True if the user explicitly set a password (vs. OAuth placeholder hash)
DisabledAt string `json:"disabled_at,omitempty"` // Non-empty = account disabled
LastActiveAt string `json:"last_active_at,omitempty"` // Last authenticated API request (any read or write)
LastWriteAt string `json:"last_write_at,omitempty"` // Last mutating action (item/comment/attachment); see Store.TouchUserWrite. PLAN-1542 / TASK-1543.
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// IsDisabled returns true if the user account has been disabled.
func (u *User) IsDisabled() bool {
return u.DisabledAt != ""
}
// GetOAuthProviders parses the JSON oauth_providers field into a string slice.
func (u *User) GetOAuthProviders() []string {
if u.OAuthProviders == "" {
return nil
}
var providers []string
if err := json.Unmarshal([]byte(u.OAuthProviders), &providers); err != nil {
return nil
}
return providers
}
// HasOAuthProvider returns true if the user has linked the given provider.
func (u *User) HasOAuthProvider(provider string) bool {
for _, p := range u.GetOAuthProviders() {
if p == provider {
return true
}
}
return false
}
// HasPassword returns true if the user has explicitly set a password that
// they can sign in with. OAuth-only users have a random placeholder hash
// stored in PasswordHash which can't actually be used to log in, so this
// bit is tracked separately from PasswordHash being non-empty.
func (u *User) HasPassword() bool {
return u.PasswordSet
}
// UserCreate is the input for registering a new user.
type UserCreate struct {
Email string `json:"email"`
Username string `json:"username,omitempty"` // Optional; auto-generated if empty
Name string `json:"name"`
Password string `json:"password"` // Plaintext, will be hashed
Role string `json:"role,omitempty"` // Defaults to "member"
}
// UserUpdate is the input for updating user profile fields.
type UserUpdate struct {
Name *string `json:"name,omitempty"`
Username *string `json:"username,omitempty"`
Password *string `json:"password,omitempty"` // Plaintext, will be hashed
AvatarURL *string `json:"avatar_url,omitempty"`
}
// Session represents a database-backed authentication session.
type Session struct {
ID string `json:"id"`
UserID string `json:"user_id"`
TokenHash string `json:"-"` // Never serialized
DeviceInfo string `json:"device_info,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
// WorkspaceInvitation represents a pending invitation to join a workspace.
type WorkspaceInvitation struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Email string `json:"email"`
Role string `json:"role"`
InvitedBy string `json:"invited_by"`
Code string `json:"code"`
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// IsExpired reports whether an invitation is past its expiration window.
// Invitations created before the expires_at migration (nil ExpiresAt) are
// treated as non-expiring so existing codes don't break on upgrade.
func (inv *WorkspaceInvitation) IsExpired() bool {
if inv == nil || inv.ExpiresAt == nil {
return false
}
return time.Now().UTC().After(*inv.ExpiresAt)
}
// WorkspaceMember represents a user's membership in a workspace.
type WorkspaceMember struct {
WorkspaceID string `json:"workspace_id"`
UserID string `json:"user_id"`
Role string `json:"role"` // "owner", "editor", or "viewer"
CollectionAccess string `json:"collection_access"` // "all" or "specific"
CreatedAt time.Time `json:"created_at"`
// Populated by joins (not stored)
UserName string `json:"user_name,omitempty"`
UserEmail string `json:"user_email,omitempty"`
UserUsername string `json:"user_username,omitempty"`
}