diff --git a/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto b/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto index fbf946b35..fb9be2f19 100644 --- a/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto +++ b/definitions/flipcash/protos/src/main/proto/profile/v1/profile_service.proto @@ -17,6 +17,10 @@ service Profile { rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse); + // SetUsername sets the caller's username, replacing any username already + // set. + rpc SetUsername(SetUsernameRequest) returns (SetUsernameResponse); + // SetProfilePicture sets the caller's profile picture to a blob they have // already uploaded via BlobStorage, replacing any picture already set. // @@ -90,6 +94,31 @@ message SetDisplayNameResponse { moderation.v1.FlaggedCategory flagged_category = 2; } +message SetUsernameRequest { + // Username is the new username to set. + common.v1.Username username = 1 [(validate.rules).message.required = true]; + + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; +} + +message SetUsernameResponse { + Result result = 1; + enum Result { + OK = 0; + INVALID_USERNAME = 1; + DENIED = 2; + ALREADY_TAKEN = 3; + FAILED_MODERATED = 4; + INSUFFICIENT_BALANCE = 5; + RESERVED_WORD = 6; + } + + // The best-fit category that tripped moderation, mirroring the Moderation + // service's vocabulary. Set only when result == FAILED_MODERATED; NONE + // otherwise. + moderation.v1.FlaggedCategory flagged_category = 2; +} + message SetProfilePictureRequest { // The blob holding the ORIGINAL image the caller uploaded. It must be owned // by the caller and READY; the server derives the remaining renditions from diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt index 9f9a3fa57..6e06f9796 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt @@ -98,6 +98,22 @@ class ProfileController @Inject constructor( .onSuccess { mergeLocalProfile { it.copy(displayName = displayName) } } } + /** + * Claims [username] as the caller's public Flipcash handle, replacing any + * username already set. + */ + suspend fun setUsername( + username: String, + ): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.setUsername(username, owner) + // Reflect the change locally so anything observing the profile (e.g. a setup flow + // deciding which steps remain) sees it without waiting for a refresh. + .onSuccess { mergeLocalProfile { it.copy(username = username) } } + } + /** * Updates the caller's tip card customization with the given hex color string. */ diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt index 9141d5315..2e54c04ab 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt @@ -75,6 +75,25 @@ internal class ProfileApi @Inject constructor( } } + /** + * Sets the username for a user, replacing any username already set. + */ + suspend fun setUsername( + username: String, + owner: Ed25519.KeyPair + ): ProfileService.SetUsernameResponse { + val request = ProfileService.SetUsernameRequest.newBuilder() + .setUsername(username.asUsername()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.setUsername(request) + } + } + /** * Sets the caller's profile picture to a blob they have already uploaded via * BlobStorage. The server derives the DISPLAY/THUMBNAIL renditions and returns diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt index 34a1e3803..c07a98080 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt @@ -10,6 +10,7 @@ import com.flipcash.services.models.LinkSocialAccountError import com.flipcash.services.models.ProfileIdentifier import com.flipcash.services.models.SetDisplayNameError import com.flipcash.services.models.SetProfilePictureError +import com.flipcash.services.models.SetUsernameError import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest import com.flipcash.services.models.UnlinkSocialAccountError @@ -63,6 +64,31 @@ internal class ProfileService @Inject constructor( ) } + suspend fun setUsername( + username: String, + owner: Ed25519.KeyPair, + ): Result { + return runCatching { + api.setUsername(username, owner) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + ProfileService.SetUsernameResponse.Result.OK -> Result.success(Unit) + ProfileService.SetUsernameResponse.Result.INVALID_USERNAME -> Result.failure(SetUsernameError.InvalidUsername()) + ProfileService.SetUsernameResponse.Result.DENIED -> Result.failure(SetUsernameError.Denied()) + ProfileService.SetUsernameResponse.Result.ALREADY_TAKEN -> Result.failure(SetUsernameError.AlreadyTaken()) + ProfileService.SetUsernameResponse.Result.FAILED_MODERATED -> + Result.failure(SetUsernameError.FailedModerated(response.flaggedCategory.toFlaggedCategory())) + ProfileService.SetUsernameResponse.Result.INSUFFICIENT_BALANCE -> Result.failure(SetUsernameError.InsufficientBalance()) + ProfileService.SetUsernameResponse.Result.RESERVED_WORD -> Result.failure(SetUsernameError.ReservedWord()) + ProfileService.SetUsernameResponse.Result.UNRECOGNIZED -> Result.failure(SetUsernameError.Unrecognized()) + null -> Result.failure(SetUsernameError.Unrecognized()) + } + }, + onFailure = { Result.failure(it.toValidationOrElse { cause -> SetUsernameError.Other(cause) }) } + ) + } + suspend fun setProfilePicture( blobId: BlobId, owner: Ed25519.KeyPair, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt index 0898f23fc..ad34e50a2 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt @@ -5,6 +5,8 @@ import com.flipcash.services.internal.domain.UserProfileMapper import com.flipcash.services.internal.network.services.ProfileService import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.ProfileIdentifier +import com.flipcash.services.models.SetDisplayNameError +import com.flipcash.services.models.SetUsernameError import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.SocialAccountLinkRequest import com.flipcash.services.models.SocialAccountUnlinkRequest @@ -38,7 +40,34 @@ internal class InternalProfileRepository( owner: Ed25519.KeyPair ): Result { return service.setDisplayName(displayName, owner) - .onFailure { ErrorUtils.handleError(it) } + .onFailure { + // The rejections below are the server answering a user's choice of + // display name, not a fault worth reporting. + val expected = it is SetDisplayNameError.InvalidDisplayName || + it is SetDisplayNameError.FailedModerated + if (!expected) { + ErrorUtils.handleError(it) + } + } + } + + override suspend fun setUsername( + username: String, + owner: Ed25519.KeyPair + ): Result { + return service.setUsername(username, owner) + .onFailure { + // The rejections below are the server answering a user's choice of + // username, not a fault worth reporting. + val expected = it is SetUsernameError.InvalidUsername || + it is SetUsernameError.AlreadyTaken || + it is SetUsernameError.ReservedWord || + it is SetUsernameError.FailedModerated || + it is SetUsernameError.InsufficientBalance + if (!expected) { + ErrorUtils.handleError(it) + } + } } override suspend fun setProfilePicture( diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index f157e1d0d..c956ae44d 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -206,6 +206,23 @@ sealed class SetDisplayNameError( data class Other(override val cause: Throwable? = null) : SetDisplayNameError(message = cause?.message, cause = cause), NotifiableError } +sealed class SetUsernameError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class InvalidUsername: SetUsernameError("Invalid username") + class Denied: SetUsernameError("Denied") + // Another user already holds this username. + class AlreadyTaken: SetUsernameError("Username already taken") + class FailedModerated(val category: ModerationResult.FlaggedCategory) : SetUsernameError("Content flagged: $category") + // Claiming this username costs more than the caller can pay. + class InsufficientBalance: SetUsernameError("Insufficient balance") + // The username is on the server's reserved list and cannot be claimed. + class ReservedWord: SetUsernameError("Reserved word") + class Unrecognized : SetUsernameError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SetUsernameError(message = cause?.message, cause = cause), NotifiableError +} + sealed class SetProfilePictureError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt index 33efbc44f..257ac6a08 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt @@ -12,6 +12,7 @@ import com.getcode.ed25519.Ed25519 interface ProfileRepository { suspend fun getProfile(identifier: ProfileIdentifier, owner: Ed25519.KeyPair): Result suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair): Result + suspend fun setUsername(username: String, owner: Ed25519.KeyPair): Result suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair): Result suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String): Result suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair): Result diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt index 5f8b007bd..d141e3779 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt @@ -302,6 +302,7 @@ class ProfileControllerTest { private class FakeProfileRepository : ProfileRepository { var getProfileResult: Result = Result.failure(RuntimeException("not configured")) var setDisplayNameResult: Result = Result.success(Unit) + var setUsernameResult: Result = Result.success(Unit) var setProfilePictureResult: Result = Result.failure(RuntimeException("not configured")) var updateTipCardResult: Result = Result.success(Unit) var linkSocialAccountResult: Result = Result.failure(RuntimeException("not configured")) @@ -309,6 +310,7 @@ private class FakeProfileRepository : ProfileRepository { override suspend fun getProfile(identifier: ProfileIdentifier, owner: Ed25519.KeyPair) = getProfileResult override suspend fun setDisplayName(displayName: String, owner: Ed25519.KeyPair) = setDisplayNameResult + override suspend fun setUsername(username: String, owner: Ed25519.KeyPair) = setUsernameResult override suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair) = setProfilePictureResult override suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String) = updateTipCardResult override suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair) =