package sqlite import ( "context" "database/sql" "fmt" "strings" "time" "armature/models" "armature/store" ) type UserStore struct{} func NewUserStore() *UserStore { return &UserStore{} } const userCols = `id, username, email, password_hash, display_name, avatar_url, is_active, settings, created_at, updated_at, last_login_at, auth_source, external_id, handle` const userListCols = `id, username, email, display_name, avatar_url, is_active, settings, created_at, updated_at, last_login_at, auth_source, external_id, handle` func (s *UserStore) Create(ctx context.Context, u *models.User) error { u.ID = store.NewID() now := time.Now().UTC() u.CreatedAt = now u.UpdatedAt = now if u.AuthSource == "" { u.AuthSource = "builtin" } _, err := DB.ExecContext(ctx, ` INSERT INTO users (id, username, email, password_hash, display_name, is_active, settings, created_at, updated_at, auth_source, external_id, handle) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.IsActive, ToJSON(u.Settings), now.Format(timeFmt), now.Format(timeFmt), u.AuthSource, u.ExternalID, u.Handle, ) return err } func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error) { return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE id = ?", userCols), id) } func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) { return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER(?)", userCols), username) } func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) { return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(email) = LOWER(?)", userCols), email) } func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) { return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", userCols), login, login) } func (s *UserStore) GetByHandle(ctx context.Context, handle string) (*models.User, error) { return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(handle) = LOWER(?)", userCols), handle) } func (s *UserStore) GetByExternalID(ctx context.Context, authSource, externalID string) (*models.User, error) { return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE auth_source = ? AND external_id = ?", userCols), authSource, externalID) } func (s *UserStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { b := NewUpdate("users") for k, v := range fields { if k == "settings" { b.SetJSON(k, v) } else { b.Set(k, v) } } if !b.HasSets() { return nil } b.Where("id", id) _, err := b.Exec(DB) return err } func (s *UserStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, "DELETE FROM users WHERE id = ?", id) return err } func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.User, int, error) { b := NewSelect(userListCols, "users") if opts.Sort == "" { b.OrderBy("username", "ASC") } b.Paginate(opts) var total int DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&total) q, args := b.Build() rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, 0, err } defer rows.Close() var result []models.User for rows.Next() { var u models.User var dn, av sql.NullString var extID, hdl sql.NullString var sj []byte if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av, &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), &u.AuthSource, &extID, &hdl); err != nil { return nil, 0, err } u.DisplayName = NullableString(dn) u.AvatarURL = NullableString(av) if extID.Valid { u.ExternalID = &extID.String } u.Handle = NullableString(hdl) ScanJSON(sj, &u.Settings) result = append(result, u) } return result, total, rows.Err() } func (s *UserStore) UpdateLastLogin(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, "UPDATE users SET last_login_at = datetime('now') WHERE id = ?", id) return err } func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error { _, err := DB.ExecContext(ctx, "UPDATE users SET is_active = ? WHERE id = ?", active, id) return err } func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) { rows, err := DB.QueryContext(ctx, "SELECT id FROM users WHERE is_active = 1") if err != nil { return nil, err } defer rows.Close() var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } // ── Refresh Tokens ────────────────────────── func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error { _, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`, store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt), keepLogin) return err } func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) { var userID string err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID) return userID, err } func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) { var info store.RefreshTokenInfo var lastAct *string err := DB.QueryRowContext(ctx, `SELECT user_id, COALESCE(keep_login, 0), last_activity_at FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&info.UserID, &info.KeepLogin, &lastAct) if err != nil { return nil, err } if lastAct != nil { if t, err := time.Parse(timeFmt, *lastAct); err == nil { info.LastActivityAt = &t } } return &info, nil } func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error { _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash) return err } func (s *UserStore) RevokeAllRefreshTokens(ctx context.Context, userID string) error { _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL", userID) return err } func (s *UserStore) CleanExpiredTokens(ctx context.Context) error { _, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')") return err } func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error { _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET last_activity_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL", userID) return err } // ── Internal ──────────────────────────────── func (s *UserStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.User, error) { var u models.User var dn, av, ph sql.NullString var extID, hdl sql.NullString var sj []byte err := DB.QueryRowContext(ctx, query, args...).Scan( &u.ID, &u.Username, &u.Email, &ph, &dn, &av, &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), &u.AuthSource, &extID, &hdl, ) if err != nil { return nil, err } u.PasswordHash = NullableString(ph) u.DisplayName = NullableString(dn) u.AvatarURL = NullableString(av) if extID.Valid { u.ExternalID = &extID.String } u.Handle = NullableString(hdl) ScanJSON(sj, &u.Settings) return &u, nil } // ── CS1 additions ───────────────────────────────────────────── func (s *UserStore) Exists(ctx context.Context, userID string) (bool, error) { var count int err := DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE id = ?`, userID).Scan(&count) return count > 0, err } func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query string) ([]store.UserSearchResult, error) { q := ` SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle FROM users WHERE is_active = 1 AND id != ?` args := []interface{}{excludeUserID} if query != "" { q += ` AND (LOWER(username) LIKE ? OR LOWER(display_name) LIKE ? OR LOWER(handle) LIKE ?)` pattern := "%" + strings.ToLower(query) + "%" args = append(args, pattern, pattern, pattern) } q += ` ORDER BY username LIMIT 20` rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() var results []store.UserSearchResult for rows.Next() { var u store.UserSearchResult if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil { continue } results = append(results, u) } if results == nil { results = []store.UserSearchResult{} } return results, rows.Err() } // ResolveByIDs returns lightweight identity records for the given user IDs. func (s *UserStore) ResolveByIDs(ctx context.Context, ids []string) ([]store.UserSearchResult, error) { if len(ids) == 0 { return []store.UserSearchResult{}, nil } if len(ids) > 100 { ids = ids[:100] } // Build ?,?,... placeholders placeholders := make([]string, len(ids)) args := make([]interface{}, len(ids)) for i, id := range ids { placeholders[i] = "?" args[i] = id } q := `SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle, COALESCE(avatar_url, '') AS avatar_url FROM users WHERE id IN (` + strings.Join(placeholders, ",") + `)` rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() var results []store.UserSearchResult for rows.Next() { var u store.UserSearchResult if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle, &u.AvatarURL); err != nil { continue } results = append(results, u) } if results == nil { results = []store.UserSearchResult{} } return results, rows.Err() } // ── CS2 additions ───────────────────────────────────────────── func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error { _, err := DB.ExecContext(ctx, ` UPDATE users SET settings = json_patch( CASE WHEN settings IS NULL OR settings = 'null' OR json_type(settings) != 'object' THEN '{}' ELSE settings END, ?), updated_at = datetime('now') WHERE id = ? `, string(patch), userID) return err } func (s *UserStore) GetVaultKeys(ctx context.Context, userID string) (bool, []byte, []byte, []byte, error) { var vaultSet bool var encUEK, salt, nonce []byte err := DB.QueryRowContext(ctx, ` SELECT vault_set, encrypted_uek, uek_salt, uek_nonce FROM users WHERE id = ? `, userID).Scan(&vaultSet, &encUEK, &salt, &nonce) return vaultSet, encUEK, salt, nonce, err } func (s *UserStore) UpdateVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error { _, err := DB.ExecContext(ctx, ` UPDATE users SET encrypted_uek = ?, uek_salt = ?, uek_nonce = ?, updated_at = datetime('now') WHERE id = ? `, encUEK, salt, nonce, userID) return err } func (s *UserStore) CountAll(ctx context.Context) (int, error) { var count int err := DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&count) return count, err } // ── CS4 additions ───────────────────────────────────────────── func (s *UserStore) ClearVaultKeys(ctx context.Context, userID string) error { _, err := DB.ExecContext(ctx, ` UPDATE users SET encrypted_uek = NULL, uek_salt = NULL, uek_nonce = NULL, vault_set = 0 WHERE id = ? `, userID) return err } func (s *UserStore) InitVaultKeys(ctx context.Context, userID string, encUEK, salt, nonce []byte) error { _, err := DB.ExecContext(ctx, ` UPDATE users SET encrypted_uek = ?, uek_salt = ?, uek_nonce = ?, vault_set = 1 WHERE id = ? `, encUEK, salt, nonce, userID) return err }