added ability to change user info
This commit is contained in:
parent
cddd341bb2
commit
40d49d5e97
22 changed files with 799 additions and 26 deletions
|
|
@ -33,6 +33,10 @@ const (
|
|||
ActionSetUsername = "account.set_username"
|
||||
ActionSetUserColor = "account.set_color"
|
||||
ActionSetUserEmojiStatus = "account.set_emoji_status"
|
||||
ActionSetProfile = "account.set_profile"
|
||||
ActionSetPhone = "account.set_phone"
|
||||
ActionSetLoginEmail = "account.set_login_email"
|
||||
ActionSetAccountAvatar = "account.set_avatar"
|
||||
ActionSetChannelUsername = "channel.set_username"
|
||||
ActionSetChannelSettings = "channel.set_settings"
|
||||
ActionSetChannelColor = "channel.set_color"
|
||||
|
|
@ -209,6 +213,18 @@ type UsersService interface {
|
|||
UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error)
|
||||
UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error)
|
||||
UpdateEmojiStatus(ctx context.Context, userID int64, status domain.UserEmojiStatus) (domain.User, error)
|
||||
UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error)
|
||||
// SetPhone force-sets a user's phone number (no code verification).
|
||||
SetPhone(ctx context.Context, userID int64, phone string) (domain.User, error)
|
||||
}
|
||||
|
||||
// AccountService carries the login-email factor (account_passwords table),
|
||||
// a separate concern from UsersService's users-table fields.
|
||||
type AccountService interface {
|
||||
// SetLoginEmail force-sets a user's login/signup email, no OTP required.
|
||||
SetLoginEmail(ctx context.Context, userID int64, email string) error
|
||||
// ClearLoginEmail removes the login email factor entirely.
|
||||
ClearLoginEmail(ctx context.Context, userID int64) error
|
||||
}
|
||||
|
||||
type StarsService interface {
|
||||
|
|
@ -281,6 +297,11 @@ type OfficialGiftsSource interface {
|
|||
type AvatarResolver interface {
|
||||
CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error)
|
||||
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
|
||||
// ValidateAvatarUpload is a pure check (no store writes), used by a dry-run
|
||||
// preview before CreateAvatarFromBytes actually materializes the avatar.
|
||||
ValidateAvatarUpload(data []byte) bool
|
||||
CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error)
|
||||
SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error)
|
||||
}
|
||||
|
||||
// StickerSetsService is the admin-console management surface over sticker/custom-emoji
|
||||
|
|
@ -393,7 +414,10 @@ type Dependencies struct {
|
|||
// BotVerification is the third-party mechanism, wired separately from
|
||||
// Verification: the two never read each other's state.
|
||||
BotVerification BotVerificationService
|
||||
Now func() time.Time
|
||||
// Account carries the login-email factor -- a separate app service from
|
||||
// Users, since login email lives in account_passwords, not users.
|
||||
Account AccountService
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
|
|
@ -422,6 +446,7 @@ type Service struct {
|
|||
rating AccountRatingService
|
||||
verification VerificationService
|
||||
botVerification BotVerificationService
|
||||
account AccountService
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
|
|
@ -506,6 +531,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
|||
if deps.BotVerification != nil {
|
||||
s.botVerification = deps.BotVerification
|
||||
}
|
||||
if deps.Account != nil {
|
||||
s.account = deps.Account
|
||||
}
|
||||
if deps.Now != nil {
|
||||
s.now = deps.Now
|
||||
}
|
||||
|
|
@ -889,6 +917,37 @@ type SetUsernameRequest struct {
|
|||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// SetProfileRequest updates first/last name. Both are always sent (not
|
||||
// pointer/omitempty): the admin form always shows and submits both fields
|
||||
// together, so there is no "leave unset" case to represent here.
|
||||
type SetProfileRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
}
|
||||
|
||||
type SetPhoneRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
// SetLoginEmailRequest force-sets (or, if Email is empty, clears) a user's
|
||||
// login/signup email.
|
||||
type SetLoginEmailRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type SetAccountAvatarRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
FileName string `json:"file_name"`
|
||||
Data []byte `json:"-"`
|
||||
}
|
||||
|
||||
type SetChannelUsernameRequest struct {
|
||||
CommandMeta
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
|
|
@ -1594,6 +1653,151 @@ func (s *Service) SetUsername(ctx context.Context, req SetUsernameRequest) (Comm
|
|||
})
|
||||
}
|
||||
|
||||
// SetProfile force-sets a user's first and last name.
|
||||
func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.users == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
||||
}
|
||||
firstName := strings.TrimSpace(req.FirstName)
|
||||
lastName := strings.TrimSpace(req.LastName)
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetProfile, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
details := map[string]any{
|
||||
"previous_first_name": u.FirstName, "previous_last_name": u.LastName,
|
||||
"new_first_name": firstName, "new_last_name": lastName,
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
updated, err := s.users.UpdateProfile(ctx, req.UserID, domain.UserProfileUpdate{
|
||||
FirstName: firstName, HasFirstName: true,
|
||||
LastName: lastName, HasLastName: true,
|
||||
})
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
return CommandResult{Message: "profile updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetPhone force-sets a user's phone number. Rejects a collision with
|
||||
// another account's phone (checked by the users service before writing,
|
||||
// backed by the users_phone_unique_idx constraint as well).
|
||||
func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.users == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
||||
}
|
||||
phone := strings.TrimSpace(req.Phone)
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetPhone, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
details := map[string]any{"previous_phone": u.Phone, "new_phone": phone}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
updated, err := s.users.SetPhone(ctx, req.UserID, phone)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
details["updated_phone"] = updated.Phone
|
||||
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
return CommandResult{Message: "phone updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetLoginEmail force-sets (or, if Email is empty, clears) a user's
|
||||
// login/signup email. Rejects a collision with another account's login
|
||||
// email (checked by the account service before writing, backed by the
|
||||
// account_passwords_login_email_lower_unique_idx constraint as well).
|
||||
func (s *Service) SetLoginEmail(ctx context.Context, req SetLoginEmailRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.account == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin account dependency is not configured")
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetLoginEmail, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"new_login_email": email}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
||||
}
|
||||
var err error
|
||||
if email == "" {
|
||||
err = s.account.ClearLoginEmail(ctx, req.UserID)
|
||||
} else {
|
||||
err = s.account.SetLoginEmail(ctx, req.UserID, email)
|
||||
}
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
message := "login email updated"
|
||||
if email == "" {
|
||||
message = "login email cleared"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetAccountAvatar force-sets a user's current profile photo from raw
|
||||
// uploaded image bytes, reusing the same avatar rendition pipeline
|
||||
// (s/a/c sizes) as photos.uploadProfilePhoto.
|
||||
func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if s == nil || s.photos == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
|
||||
}
|
||||
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
|
||||
return CommandResult{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatar, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data)}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "avatar validated", Details: details}, nil
|
||||
}
|
||||
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(time.Now().Unix())); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["photo_id"] = photo.ID
|
||||
if s.users != nil {
|
||||
if u, found, uerr := s.users.AdminUser(ctx, req.UserID); uerr == nil && found {
|
||||
if nerr := s.notifyUserChanged(ctx, u); nerr != nil {
|
||||
details["notify_error"] = nerr.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
return CommandResult{Message: "avatar updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetUserColor force-sets or clears a user's name/profile color.
|
||||
func (s *Service) SetUserColor(ctx context.Context, req SetUserColorRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
|
|
@ -2649,7 +2853,9 @@ func (s *Service) OfficialStarGifts(ctx context.Context) ([]officialgifts.GiftSu
|
|||
return s.officialGifts.List(ctx)
|
||||
}
|
||||
|
||||
const maxAccountAvatarBytes = 4 << 20
|
||||
// MaxAccountAvatarBytes bounds both reading (AccountAvatar) and writing
|
||||
// (SetAccountAvatar) a user's profile photo through the admin console.
|
||||
const MaxAccountAvatarBytes = 4 << 20
|
||||
|
||||
// AccountAvatar returns an account's current profile photo bytes and detected
|
||||
// MIME type, mirroring internal/web's public avatar serving (same size
|
||||
|
|
@ -2674,17 +2880,17 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
|
|||
if len(data) == 0 {
|
||||
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
|
||||
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
|
||||
Limit: maxAccountAvatarBytes + 1,
|
||||
Limit: MaxAccountAvatarBytes + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
if !found || chunk.Total <= 0 || chunk.Total > maxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
|
||||
if !found || chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
data = chunk.Bytes
|
||||
}
|
||||
if len(data) == 0 || len(data) > maxAccountAvatarBytes {
|
||||
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
detected := http.DetectContentType(data)
|
||||
|
|
@ -2707,7 +2913,7 @@ func bestAccountPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, b
|
|||
var inline []byte
|
||||
switch size.Kind {
|
||||
case domain.PhotoSizeKindCached:
|
||||
if len(size.Bytes) == 0 || len(size.Bytes) > maxAccountAvatarBytes {
|
||||
if len(size.Bytes) == 0 || len(size.Bytes) > MaxAccountAvatarBytes {
|
||||
continue
|
||||
}
|
||||
inline = size.Bytes
|
||||
|
|
|
|||
|
|
@ -835,6 +835,39 @@ func (f *fakeUsersService) UpdateUsername(_ context.Context, userID int64, usern
|
|||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) UpdateProfile(_ context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
if update.HasFirstName {
|
||||
u.FirstName = update.FirstName
|
||||
}
|
||||
if update.HasLastName {
|
||||
u.LastName = update.LastName
|
||||
}
|
||||
if update.HasAbout {
|
||||
u.About = update.About
|
||||
}
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) SetPhone(_ context.Context, userID int64, phone string) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrUserNotFound
|
||||
}
|
||||
for id, existing := range f.users {
|
||||
if id != userID && existing.Phone == phone {
|
||||
return domain.User{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
}
|
||||
u.Phone = phone
|
||||
f.users[userID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUsersService) UpdateColor(_ context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) {
|
||||
u, ok := f.users[userID]
|
||||
if !ok {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue