Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
146
cloud/maplefile-backend/internal/service/me/delete.go
Normal file
146
cloud/maplefile-backend/internal/service/me/delete.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me/delete.go
|
||||
package me
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
|
||||
dom_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user"
|
||||
svc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
|
||||
sstring "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/security/securestring"
|
||||
)
|
||||
|
||||
type DeleteMeRequestDTO struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type DeleteMeService interface {
|
||||
Execute(sessCtx context.Context, req *DeleteMeRequestDTO) error
|
||||
}
|
||||
|
||||
type deleteMeServiceImpl struct {
|
||||
config *config.Configuration
|
||||
logger *zap.Logger
|
||||
completeUserDeletionService svc_user.CompleteUserDeletionService
|
||||
}
|
||||
|
||||
func NewDeleteMeService(
|
||||
config *config.Configuration,
|
||||
logger *zap.Logger,
|
||||
completeUserDeletionService svc_user.CompleteUserDeletionService,
|
||||
) DeleteMeService {
|
||||
logger = logger.Named("DeleteMeService")
|
||||
|
||||
return &deleteMeServiceImpl{
|
||||
config: config,
|
||||
logger: logger,
|
||||
completeUserDeletionService: completeUserDeletionService,
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *deleteMeServiceImpl) Execute(sessCtx context.Context, req *DeleteMeRequestDTO) error {
|
||||
//
|
||||
// STEP 1: Validation
|
||||
//
|
||||
|
||||
if req == nil {
|
||||
svc.logger.Warn("Failed validation with nil request")
|
||||
return httperror.NewForBadRequestWithSingleField("non_field_error", "Password is required")
|
||||
}
|
||||
|
||||
e := make(map[string]string)
|
||||
if req.Password == "" {
|
||||
e["password"] = "Password is required"
|
||||
}
|
||||
if len(e) != 0 {
|
||||
svc.logger.Warn("Failed validation",
|
||||
zap.Any("error", e))
|
||||
return httperror.NewForBadRequest(&e)
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 2: Get required from context.
|
||||
//
|
||||
|
||||
sessionUserID, ok := sessCtx.Value(constants.SessionUserID).(gocql.UUID)
|
||||
if !ok {
|
||||
svc.logger.Error("Failed getting local user id",
|
||||
zap.Any("error", "Not found in context: user_id"))
|
||||
return errors.New("user id not found in context")
|
||||
}
|
||||
|
||||
// Defend against admin deleting themselves
|
||||
sessionUserRole, _ := sessCtx.Value(constants.SessionUserRole).(int8)
|
||||
if sessionUserRole == dom_user.UserRoleRoot {
|
||||
svc.logger.Warn("admin is not allowed to delete themselves",
|
||||
zap.Any("error", ""))
|
||||
return httperror.NewForForbiddenWithSingleField("message", "admins do not have permission to delete themselves")
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 3: Verify password (intent confirmation).
|
||||
//
|
||||
|
||||
securePassword, err := sstring.NewSecureString(req.Password)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to create secure string", zap.Any("error", err))
|
||||
return err
|
||||
}
|
||||
defer securePassword.Wipe()
|
||||
|
||||
// NOTE: In this E2EE architecture, the server does not store password hashes.
|
||||
// Password verification happens client-side during key derivation.
|
||||
// The frontend must verify the password locally before calling this endpoint
|
||||
// by successfully deriving the KEK and decrypting the master key.
|
||||
// If the password is wrong, the client-side decryption will fail.
|
||||
//
|
||||
// The password field in the request serves as a confirmation that the user
|
||||
// intentionally wants to delete their account (not cryptographic verification).
|
||||
_ = securePassword // Password used for user intent confirmation
|
||||
|
||||
//
|
||||
// STEP 4: Execute GDPR right-to-be-forgotten complete deletion
|
||||
//
|
||||
|
||||
svc.logger.Info("Starting GDPR right-to-be-forgotten complete user deletion",
|
||||
zap.String("user_id", sessionUserID.String()))
|
||||
|
||||
deletionReq := &svc_user.CompleteUserDeletionRequest{
|
||||
UserID: sessionUserID,
|
||||
Password: req.Password,
|
||||
}
|
||||
|
||||
result, err := svc.completeUserDeletionService.Execute(sessCtx, deletionReq)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed to complete user deletion",
|
||||
zap.Error(err),
|
||||
zap.String("user_id", sessionUserID.String()))
|
||||
return err
|
||||
}
|
||||
|
||||
//
|
||||
// SUCCESS: User account and all data permanently deleted (GDPR compliant)
|
||||
//
|
||||
|
||||
svc.logger.Info("User account successfully deleted (GDPR right-to-be-forgotten)",
|
||||
zap.String("user_id", sessionUserID.String()),
|
||||
zap.Int("files_deleted", result.FilesDeleted),
|
||||
zap.Int("collections_deleted", result.CollectionsDeleted),
|
||||
zap.Int("s3_objects_deleted", result.S3ObjectsDeleted),
|
||||
zap.Int("memberships_removed", result.MembershipsRemoved),
|
||||
zap.Int64("data_size_bytes", result.TotalDataSizeBytes),
|
||||
zap.Int("non_fatal_errors", len(result.Errors)))
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
svc.logger.Warn("Deletion completed with non-fatal errors",
|
||||
zap.Strings("errors", result.Errors))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
159
cloud/maplefile-backend/internal/service/me/get.go
Normal file
159
cloud/maplefile-backend/internal/service/me/get.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me/get.go
|
||||
package me
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
|
||||
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
|
||||
)
|
||||
|
||||
type MeResponseDTO struct {
|
||||
ID gocql.UUID `bson:"_id" json:"id"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
FirstName string `bson:"first_name" json:"first_name"`
|
||||
LastName string `bson:"last_name" json:"last_name"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
LexicalName string `bson:"lexical_name" json:"lexical_name"`
|
||||
Role int8 `bson:"role" json:"role"`
|
||||
// WasEmailVerified bool `bson:"was_email_verified" json:"was_email_verified,omitempty"`
|
||||
// EmailVerificationCode string `bson:"email_verification_code,omitempty" json:"email_verification_code,omitempty"`
|
||||
// EmailVerificationExpiry time.Time `bson:"email_verification_expiry,omitempty" json:"email_verification_expiry,omitempty"`
|
||||
Phone string `bson:"phone" json:"phone,omitempty"`
|
||||
Country string `bson:"country" json:"country,omitempty"`
|
||||
Timezone string `bson:"timezone" json:"timezone"`
|
||||
Region string `bson:"region" json:"region,omitempty"`
|
||||
City string `bson:"city" json:"city,omitempty"`
|
||||
PostalCode string `bson:"postal_code" json:"postal_code,omitempty"`
|
||||
AddressLine1 string `bson:"address_line1" json:"address_line1,omitempty"`
|
||||
AddressLine2 string `bson:"address_line2" json:"address_line2,omitempty"`
|
||||
// HasShippingAddress bool `bson:"has_shipping_address" json:"has_shipping_address,omitempty"`
|
||||
// ShippingName string `bson:"shipping_name" json:"shipping_name,omitempty"`
|
||||
// ShippingPhone string `bson:"shipping_phone" json:"shipping_phone,omitempty"`
|
||||
// ShippingCountry string `bson:"shipping_country" json:"shipping_country,omitempty"`
|
||||
// ShippingRegion string `bson:"shipping_region" json:"shipping_region,omitempty"`
|
||||
// ShippingCity string `bson:"shipping_city" json:"shipping_city,omitempty"`
|
||||
// ShippingPostalCode string `bson:"shipping_postal_code" json:"shipping_postal_code,omitempty"`
|
||||
// ShippingAddressLine1 string `bson:"shipping_address_line1" json:"shipping_address_line1,omitempty"`
|
||||
// ShippingAddressLine2 string `bson:"shipping_address_line2" json:"shipping_address_line2,omitempty"`
|
||||
// HowDidYouHearAboutUs int8 `bson:"how_did_you_hear_about_us" json:"how_did_you_hear_about_us,omitempty"`
|
||||
// HowDidYouHearAboutUsOther string `bson:"how_did_you_hear_about_us_other" json:"how_did_you_hear_about_us_other,omitempty"`
|
||||
// AgreeTermsOfService bool `bson:"agree_terms_of_service" json:"agree_terms_of_service,omitempty"`
|
||||
AgreePromotions bool `bson:"agree_promotions" json:"agree_promotions,omitempty"`
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `bson:"agree_to_tracking_across_third_party_apps_and_services" json:"agree_to_tracking_across_third_party_apps_and_services,omitempty"`
|
||||
ShareNotificationsEnabled *bool `bson:"share_notifications_enabled" json:"share_notifications_enabled,omitempty"`
|
||||
// CreatedFromIPAddress string `bson:"created_from_ip_address" json:"created_from_ip_address"`
|
||||
// CreatedByFederatedIdentityID gocql.UUID `bson:"created_by_federatedidentity_id" json:"created_by_federatedidentity_id"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at,omitempty"`
|
||||
// CreatedByName string `bson:"created_by_name" json:"created_by_name"`
|
||||
// ModifiedFromIPAddress string `bson:"modified_from_ip_address" json:"modified_from_ip_address"`
|
||||
// ModifiedByFederatedIdentityID gocql.UUID `bson:"modified_by_federatedidentity_id" json:"modified_by_federatedidentity_id"`
|
||||
// ModifiedAt time.Time `bson:"modified_at" json:"modified_at,omitempty"`
|
||||
// ModifiedByName string `bson:"modified_by_name" json:"modified_by_name"`
|
||||
Status int8 `bson:"status" json:"status"`
|
||||
// PaymentProcessorName string `bson:"payment_processor_name" json:"payment_processor_name"`
|
||||
// PaymentProcessorCustomerID string `bson:"payment_processor_customer_id" json:"payment_processor_customer_id"`
|
||||
// OTPEnabled bool `bson:"otp_enabled" json:"otp_enabled"`
|
||||
// OTPVerified bool `bson:"otp_verified" json:"otp_verified"`
|
||||
// OTPValidated bool `bson:"otp_validated" json:"otp_validated"`
|
||||
// OTPSecret string `bson:"otp_secret" json:"-"`
|
||||
// OTPAuthURL string `bson:"otp_auth_url" json:"-"`
|
||||
// OTPBackupCodeHash string `bson:"otp_backup_code_hash" json:"-"`
|
||||
// OTPBackupCodeHashAlgorithm string `bson:"otp_backup_code_hash_algorithm" json:"-"`
|
||||
// HowLongCollectingComicBooksForGrading int8 `bson:"how_long_collecting_comic_books_for_grading" json:"how_long_collecting_comic_books_for_grading"`
|
||||
// HasPreviouslySubmittedComicBookForGrading int8 `bson:"has_previously_submitted_comic_book_for_grading" json:"has_previously_submitted_comic_book_for_grading"`
|
||||
// HasOwnedGradedComicBooks int8 `bson:"has_owned_graded_comic_books" json:"has_owned_graded_comic_books"`
|
||||
// HasRegularComicBookShop int8 `bson:"has_regular_comic_book_shop" json:"has_regular_comic_book_shop"`
|
||||
// HasPreviouslyPurchasedFromAuctionSite int8 `bson:"has_previously_purchased_from_auction_site" json:"has_previously_purchased_from_auction_site"`
|
||||
// HasPreviouslyPurchasedFromFacebookMarketplace int8 `bson:"has_previously_purchased_from_facebook_marketplace" json:"has_previously_purchased_from_facebook_marketplace"`
|
||||
// HasRegularlyAttendedComicConsOrCollectibleShows int8 `bson:"has_regularly_attended_comic_cons_or_collectible_shows" json:"has_regularly_attended_comic_cons_or_collectible_shows"`
|
||||
ProfileVerificationStatus int8 `bson:"profile_verification_status" json:"profile_verification_status,omitempty"`
|
||||
WebsiteURL string `bson:"website_url" json:"website_url"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
ComicBookStoreName string `bson:"comic_book_store_name" json:"comic_book_store_name,omitempty"`
|
||||
}
|
||||
|
||||
type GetMeService interface {
|
||||
Execute(sessCtx context.Context) (*MeResponseDTO, error)
|
||||
}
|
||||
|
||||
type getMeServiceImpl struct {
|
||||
config *config.Configuration
|
||||
logger *zap.Logger
|
||||
userGetByIDUseCase uc_user.UserGetByIDUseCase
|
||||
userCreateUseCase uc_user.UserCreateUseCase
|
||||
userUpdateUseCase uc_user.UserUpdateUseCase
|
||||
}
|
||||
|
||||
func NewGetMeService(
|
||||
config *config.Configuration,
|
||||
logger *zap.Logger,
|
||||
userGetByIDUseCase uc_user.UserGetByIDUseCase,
|
||||
userCreateUseCase uc_user.UserCreateUseCase,
|
||||
userUpdateUseCase uc_user.UserUpdateUseCase,
|
||||
) GetMeService {
|
||||
logger = logger.Named("GetMeService")
|
||||
|
||||
return &getMeServiceImpl{
|
||||
config: config,
|
||||
logger: logger,
|
||||
userGetByIDUseCase: userGetByIDUseCase,
|
||||
userCreateUseCase: userCreateUseCase,
|
||||
userUpdateUseCase: userUpdateUseCase,
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *getMeServiceImpl) Execute(sessCtx context.Context) (*MeResponseDTO, error) {
|
||||
//
|
||||
// Get required from context.
|
||||
//
|
||||
|
||||
userID, ok := sessCtx.Value(constants.SessionUserID).(gocql.UUID)
|
||||
if !ok {
|
||||
svc.logger.Error("Failed getting local user id",
|
||||
zap.Any("error", "Not found in context: user_id"))
|
||||
return nil, errors.New("user id not found in context")
|
||||
}
|
||||
|
||||
// Get the user account (aka "Me") and if it doesn't exist then return error.
|
||||
user, err := svc.userGetByIDUseCase.Execute(sessCtx, userID)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed getting me", zap.Any("error", err))
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
err := fmt.Errorf("User does not exist for user id: %v", userID.String())
|
||||
svc.logger.Error("Failed getting me", zap.Any("error", err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MeResponseDTO{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
Name: user.Name,
|
||||
LexicalName: user.LexicalName,
|
||||
Role: user.Role,
|
||||
Phone: user.ProfileData.Phone,
|
||||
Country: user.ProfileData.Country,
|
||||
Timezone: user.Timezone,
|
||||
Region: user.ProfileData.Region,
|
||||
City: user.ProfileData.City,
|
||||
PostalCode: user.ProfileData.PostalCode,
|
||||
AddressLine1: user.ProfileData.AddressLine1,
|
||||
AddressLine2: user.ProfileData.AddressLine2,
|
||||
AgreePromotions: user.ProfileData.AgreePromotions,
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices: user.ProfileData.AgreeToTrackingAcrossThirdPartyAppsAndServices,
|
||||
ShareNotificationsEnabled: user.ProfileData.ShareNotificationsEnabled,
|
||||
CreatedAt: user.CreatedAt,
|
||||
Status: user.Status,
|
||||
}, nil
|
||||
}
|
||||
52
cloud/maplefile-backend/internal/service/me/provider.go
Normal file
52
cloud/maplefile-backend/internal/service/me/provider.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package me
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
|
||||
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
|
||||
svc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/user"
|
||||
)
|
||||
|
||||
// Wire providers for me services
|
||||
|
||||
func ProvideGetMeService(
|
||||
cfg *config.Configuration,
|
||||
logger *zap.Logger,
|
||||
userGetByIDUseCase uc_user.UserGetByIDUseCase,
|
||||
userCreateUseCase uc_user.UserCreateUseCase,
|
||||
userUpdateUseCase uc_user.UserUpdateUseCase,
|
||||
) GetMeService {
|
||||
return NewGetMeService(cfg, logger, userGetByIDUseCase, userCreateUseCase, userUpdateUseCase)
|
||||
}
|
||||
|
||||
func ProvideUpdateMeService(
|
||||
cfg *config.Configuration,
|
||||
logger *zap.Logger,
|
||||
userGetByIDUseCase uc_user.UserGetByIDUseCase,
|
||||
userGetByEmailUseCase uc_user.UserGetByEmailUseCase,
|
||||
userUpdateUseCase uc_user.UserUpdateUseCase,
|
||||
) UpdateMeService {
|
||||
return NewUpdateMeService(cfg, logger, userGetByIDUseCase, userGetByEmailUseCase, userUpdateUseCase)
|
||||
}
|
||||
|
||||
func ProvideDeleteMeService(
|
||||
cfg *config.Configuration,
|
||||
logger *zap.Logger,
|
||||
completeUserDeletionService svc_user.CompleteUserDeletionService,
|
||||
) DeleteMeService {
|
||||
return NewDeleteMeService(
|
||||
cfg,
|
||||
logger,
|
||||
completeUserDeletionService,
|
||||
)
|
||||
}
|
||||
|
||||
func ProvideVerifyProfileService(
|
||||
cfg *config.Configuration,
|
||||
logger *zap.Logger,
|
||||
userGetByIDUseCase uc_user.UserGetByIDUseCase,
|
||||
userUpdateUseCase uc_user.UserUpdateUseCase,
|
||||
) VerifyProfileService {
|
||||
return NewVerifyProfileService(cfg, logger, userGetByIDUseCase, userUpdateUseCase)
|
||||
}
|
||||
201
cloud/maplefile-backend/internal/service/me/update.go
Normal file
201
cloud/maplefile-backend/internal/service/me/update.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me/update.go
|
||||
package me
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
|
||||
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
|
||||
)
|
||||
|
||||
type UpdateMeRequestDTO struct {
|
||||
Email string `bson:"email" json:"email"`
|
||||
FirstName string `bson:"first_name" json:"first_name"`
|
||||
LastName string `bson:"last_name" json:"last_name"`
|
||||
Phone string `bson:"phone" json:"phone,omitempty"`
|
||||
Country string `bson:"country" json:"country,omitempty"`
|
||||
Region string `bson:"region" json:"region,omitempty"`
|
||||
Timezone string `bson:"timezone" json:"timezone"`
|
||||
AgreePromotions bool `bson:"agree_promotions" json:"agree_promotions,omitempty"`
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `bson:"agree_to_tracking_across_third_party_apps_and_services" json:"agree_to_tracking_across_third_party_apps_and_services,omitempty"`
|
||||
ShareNotificationsEnabled *bool `bson:"share_notifications_enabled" json:"share_notifications_enabled,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateMeService interface {
|
||||
Execute(sessCtx context.Context, req *UpdateMeRequestDTO) (*MeResponseDTO, error)
|
||||
}
|
||||
|
||||
type updateMeServiceImpl struct {
|
||||
config *config.Configuration
|
||||
logger *zap.Logger
|
||||
userGetByIDUseCase uc_user.UserGetByIDUseCase
|
||||
userGetByEmailUseCase uc_user.UserGetByEmailUseCase
|
||||
userUpdateUseCase uc_user.UserUpdateUseCase
|
||||
}
|
||||
|
||||
func NewUpdateMeService(
|
||||
config *config.Configuration,
|
||||
logger *zap.Logger,
|
||||
userGetByIDUseCase uc_user.UserGetByIDUseCase,
|
||||
userGetByEmailUseCase uc_user.UserGetByEmailUseCase,
|
||||
userUpdateUseCase uc_user.UserUpdateUseCase,
|
||||
) UpdateMeService {
|
||||
logger = logger.Named("UpdateMeService")
|
||||
|
||||
return &updateMeServiceImpl{
|
||||
config: config,
|
||||
logger: logger,
|
||||
userGetByIDUseCase: userGetByIDUseCase,
|
||||
userGetByEmailUseCase: userGetByEmailUseCase,
|
||||
userUpdateUseCase: userUpdateUseCase,
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *updateMeServiceImpl) Execute(sessCtx context.Context, req *UpdateMeRequestDTO) (*MeResponseDTO, error) {
|
||||
//
|
||||
// Get required from context.
|
||||
//
|
||||
|
||||
userID, ok := sessCtx.Value(constants.SessionUserID).(gocql.UUID)
|
||||
if !ok {
|
||||
svc.logger.Error("Failed getting local user id",
|
||||
zap.Any("error", "Not found in context: user_id"))
|
||||
return nil, errors.New("user id not found in context")
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 2: Validation
|
||||
//
|
||||
|
||||
if req == nil {
|
||||
svc.logger.Warn("Failed validation with nothing received")
|
||||
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Request is required in submission")
|
||||
}
|
||||
|
||||
// Sanitization
|
||||
req.Email = strings.ToLower(req.Email) // Ensure email is lowercase
|
||||
|
||||
e := make(map[string]string)
|
||||
// Add any specific field validations here if needed. Example:
|
||||
if req.FirstName == "" {
|
||||
e["first_name"] = "First name is required"
|
||||
}
|
||||
if req.LastName == "" {
|
||||
e["last_name"] = "Last name is required"
|
||||
}
|
||||
if req.Email == "" {
|
||||
e["email"] = "Email is required"
|
||||
}
|
||||
if len(req.Email) > 255 {
|
||||
e["email"] = "Email is too long"
|
||||
}
|
||||
if req.Phone == "" {
|
||||
e["phone"] = "Phone confirm is required"
|
||||
}
|
||||
if req.Country == "" {
|
||||
e["country"] = "Country is required"
|
||||
}
|
||||
if req.Timezone == "" {
|
||||
e["timezone"] = "Timezone is required"
|
||||
}
|
||||
if len(e) != 0 {
|
||||
svc.logger.Warn("Failed validation",
|
||||
zap.Any("error", e))
|
||||
return nil, httperror.NewForBadRequest(&e)
|
||||
}
|
||||
|
||||
//
|
||||
// Get related records.
|
||||
//
|
||||
|
||||
// Get the user account (aka "Me").
|
||||
user, err := svc.userGetByIDUseCase.Execute(sessCtx, userID)
|
||||
if err != nil {
|
||||
// Handle other potential errors during fetch.
|
||||
svc.logger.Error("Failed getting user by ID", zap.Any("error", err))
|
||||
return nil, err
|
||||
}
|
||||
// Defensive check, though GetByID should return ErrNoDocuments if not found.
|
||||
if user == nil {
|
||||
err := fmt.Errorf("user is nil after lookup for id: %v", userID.String())
|
||||
svc.logger.Error("Failed getting user", zap.Any("error", err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//
|
||||
// Check if the requested email is already taken by another user.
|
||||
//
|
||||
if req.Email != user.Email {
|
||||
existingUser, err := svc.userGetByEmailUseCase.Execute(sessCtx, req.Email)
|
||||
if err != nil {
|
||||
svc.logger.Error("Failed checking existing email", zap.String("email", validation.MaskEmail(req.Email)), zap.Any("error", err))
|
||||
return nil, err // Internal Server Error
|
||||
}
|
||||
if existingUser != nil {
|
||||
// Email exists and belongs to another user.
|
||||
svc.logger.Warn("Attempted to update to an email already in use",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("existing_user_id", existingUser.ID.String()),
|
||||
zap.String("email", validation.MaskEmail(req.Email)))
|
||||
e["email"] = "This email address is already in use."
|
||||
return nil, httperror.NewForBadRequest(&e)
|
||||
}
|
||||
// If err is mongo.ErrNoDocuments or existingUser is nil, the email is available.
|
||||
}
|
||||
|
||||
//
|
||||
// Update local database.
|
||||
//
|
||||
|
||||
// Apply changes from request DTO to the user object
|
||||
user.Email = req.Email
|
||||
user.FirstName = req.FirstName
|
||||
user.LastName = req.LastName
|
||||
user.Name = fmt.Sprintf("%s %s", req.FirstName, req.LastName)
|
||||
user.LexicalName = fmt.Sprintf("%s, %s", req.LastName, req.FirstName)
|
||||
user.ProfileData.Phone = req.Phone
|
||||
user.ProfileData.Country = req.Country
|
||||
user.ProfileData.Region = req.Region
|
||||
user.Timezone = req.Timezone
|
||||
user.ProfileData.AgreePromotions = req.AgreePromotions
|
||||
user.ProfileData.AgreeToTrackingAcrossThirdPartyAppsAndServices = req.AgreeToTrackingAcrossThirdPartyAppsAndServices
|
||||
if req.ShareNotificationsEnabled != nil {
|
||||
user.ProfileData.ShareNotificationsEnabled = req.ShareNotificationsEnabled
|
||||
}
|
||||
|
||||
// Persist changes
|
||||
if err := svc.userUpdateUseCase.Execute(sessCtx, user); err != nil {
|
||||
svc.logger.Error("Failed updating user", zap.Any("error", err), zap.String("user_id", user.ID.String()))
|
||||
// Consider mapping specific DB errors (like constraint violations) to HTTP errors if applicable
|
||||
return nil, err
|
||||
}
|
||||
|
||||
svc.logger.Debug("User updated successfully",
|
||||
zap.String("user_id", user.ID.String()))
|
||||
|
||||
// Return updated user details
|
||||
return &MeResponseDTO{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
Name: user.Name,
|
||||
LexicalName: user.LexicalName,
|
||||
Phone: user.ProfileData.Phone,
|
||||
Country: user.ProfileData.Country,
|
||||
Region: user.ProfileData.Region,
|
||||
Timezone: user.Timezone,
|
||||
AgreePromotions: user.ProfileData.AgreePromotions,
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices: user.ProfileData.AgreeToTrackingAcrossThirdPartyAppsAndServices,
|
||||
ShareNotificationsEnabled: user.ProfileData.ShareNotificationsEnabled,
|
||||
}, nil
|
||||
}
|
||||
314
cloud/maplefile-backend/internal/service/me/verifyprofile.go
Normal file
314
cloud/maplefile-backend/internal/service/me/verifyprofile.go
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me/verifyprofile.go
|
||||
package me
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gocql/gocql"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants"
|
||||
domain "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user"
|
||||
uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user"
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
|
||||
)
|
||||
|
||||
type VerifyProfileRequestDTO struct {
|
||||
// Common fields
|
||||
Country string `json:"country,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
PostalCode string `json:"postal_code,omitempty"`
|
||||
AddressLine1 string `json:"address_line1,omitempty"`
|
||||
AddressLine2 string `json:"address_line2,omitempty"`
|
||||
HasShippingAddress bool `json:"has_shipping_address,omitempty"`
|
||||
ShippingName string `json:"shipping_name,omitempty"`
|
||||
ShippingPhone string `json:"shipping_phone,omitempty"`
|
||||
ShippingCountry string `json:"shipping_country,omitempty"`
|
||||
ShippingRegion string `json:"shipping_region,omitempty"`
|
||||
ShippingCity string `json:"shipping_city,omitempty"`
|
||||
ShippingPostalCode string `json:"shipping_postal_code,omitempty"`
|
||||
ShippingAddressLine1 string `json:"shipping_address_line1,omitempty"`
|
||||
ShippingAddressLine2 string `json:"shipping_address_line2,omitempty"`
|
||||
HowDidYouHearAboutUs int8 `json:"how_did_you_hear_about_us,omitempty"`
|
||||
HowDidYouHearAboutUsOther string `json:"how_did_you_hear_about_us_other,omitempty"`
|
||||
WebsiteURL string `json:"website_url,omitempty"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
|
||||
// Customer specific fields
|
||||
HowLongCollectingComicBooksForGrading int8 `json:"how_long_collecting_comic_books_for_grading,omitempty"`
|
||||
HasPreviouslySubmittedComicBookForGrading int8 `json:"has_previously_submitted_comic_book_for_grading,omitempty"`
|
||||
HasOwnedGradedComicBooks int8 `json:"has_owned_graded_comic_books,omitempty"`
|
||||
HasRegularComicBookShop int8 `json:"has_regular_comic_book_shop,omitempty"`
|
||||
HasPreviouslyPurchasedFromAuctionSite int8 `json:"has_previously_purchased_from_auction_site,omitempty"`
|
||||
HasPreviouslyPurchasedFromFacebookMarketplace int8 `json:"has_previously_purchased_from_facebook_marketplace,omitempty"`
|
||||
HasRegularlyAttendedComicConsOrCollectibleShows int8 `json:"has_regularly_attended_comic_cons_or_collectible_shows,omitempty"`
|
||||
|
||||
// Retailer specific fields
|
||||
ComicBookStoreName string `json:"comic_book_store_name,omitempty"`
|
||||
StoreLogo string `json:"store_logo,omitempty"`
|
||||
HowLongStoreOperating int8 `json:"how_long_store_operating,omitempty"`
|
||||
GradingComicsExperience string `json:"grading_comics_experience,omitempty"`
|
||||
RetailPartnershipReason string `json:"retail_partnership_reason,omitempty"`
|
||||
ComicCoinPartnershipReason string `json:"comic_coin_partnership_reason,omitempty"`
|
||||
|
||||
EstimatedSubmissionsPerMonth int8 `json:"estimated_submissions_per_month,omitempty"`
|
||||
HasOtherGradingService int8 `json:"has_other_grading_service,omitempty"`
|
||||
OtherGradingServiceName string `json:"other_grading_service_name,omitempty"`
|
||||
RequestWelcomePackage int8 `json:"request_welcome_package,omitempty"`
|
||||
|
||||
// Explicitly specify user role if needed (overrides the user's current role)
|
||||
UserRole int8 `json:"user_role,omitempty"`
|
||||
}
|
||||
|
||||
type VerifyProfileResponseDTO struct {
|
||||
Message string `json:"message"`
|
||||
UserRole int8 `json:"user_role"`
|
||||
Status int8 `json:"profile_verification_status"`
|
||||
}
|
||||
|
||||
type VerifyProfileService interface {
|
||||
Execute(sessCtx context.Context, req *VerifyProfileRequestDTO) (*VerifyProfileResponseDTO, error)
|
||||
}
|
||||
|
||||
type verifyProfileServiceImpl struct {
|
||||
config *config.Configuration
|
||||
logger *zap.Logger
|
||||
userGetByIDUseCase uc_user.UserGetByIDUseCase
|
||||
userUpdateUseCase uc_user.UserUpdateUseCase
|
||||
}
|
||||
|
||||
func NewVerifyProfileService(
|
||||
config *config.Configuration,
|
||||
logger *zap.Logger,
|
||||
userGetByIDUseCase uc_user.UserGetByIDUseCase,
|
||||
userUpdateUseCase uc_user.UserUpdateUseCase,
|
||||
) VerifyProfileService {
|
||||
return &verifyProfileServiceImpl{
|
||||
config: config,
|
||||
logger: logger,
|
||||
userGetByIDUseCase: userGetByIDUseCase,
|
||||
userUpdateUseCase: userUpdateUseCase,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *verifyProfileServiceImpl) Execute(
|
||||
sessCtx context.Context,
|
||||
req *VerifyProfileRequestDTO,
|
||||
) (*VerifyProfileResponseDTO, error) {
|
||||
//
|
||||
// STEP 1: Get required from context.
|
||||
//
|
||||
userID, ok := sessCtx.Value(constants.SessionUserID).(gocql.UUID)
|
||||
if !ok {
|
||||
s.logger.Error("Failed getting local user id",
|
||||
zap.Any("error", "Not found in context: user_id"))
|
||||
return nil, errors.New("user id not found in context")
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 2: Retrieve user from database
|
||||
//
|
||||
user, err := s.userGetByIDUseCase.Execute(sessCtx, userID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed retrieving user", zap.Any("error", err))
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
s.logger.Error("User not found", zap.Any("userID", userID))
|
||||
return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "User not found")
|
||||
}
|
||||
|
||||
// Check if we need to override the user role based on the request
|
||||
if req.UserRole != 0 && (req.UserRole == domain.UserRoleIndividual || req.UserRole == domain.UserRoleCompany) {
|
||||
s.logger.Info("Setting user role based on request",
|
||||
zap.Int("original_role", int(user.Role)),
|
||||
zap.Int("new_role", int(req.UserRole)))
|
||||
user.Role = req.UserRole
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 3: Validate request based on user role
|
||||
//
|
||||
e := make(map[string]string)
|
||||
|
||||
// Validate common fields regardless of role
|
||||
s.validateCommonFields(req, e)
|
||||
|
||||
// Role-specific validation
|
||||
if user.Role == domain.UserRoleIndividual {
|
||||
s.validateCustomerFields(req, e)
|
||||
} else if user.Role == domain.UserRoleCompany {
|
||||
s.validateRetailerFields(req, e)
|
||||
} else {
|
||||
s.logger.Warn("Unrecognized user role", zap.Int("role", int(user.Role)))
|
||||
e["user_role"] = "Invalid user role. Must be either customer or retailer."
|
||||
}
|
||||
|
||||
// Return validation errors if any
|
||||
if len(e) != 0 {
|
||||
s.logger.Warn("Failed validation", zap.Any("errors", e))
|
||||
return nil, httperror.NewForBadRequest(&e)
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 4: Update user profile based on role
|
||||
//
|
||||
|
||||
// Update common fields
|
||||
s.updateCommonFields(user, req)
|
||||
|
||||
//
|
||||
// STEP 5: Save updated user to database
|
||||
//
|
||||
if err := s.userUpdateUseCase.Execute(sessCtx, user); err != nil {
|
||||
s.logger.Error("Failed to update user", zap.Any("error", err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//
|
||||
// STEP 6: Generate appropriate response
|
||||
//
|
||||
var responseMessage string
|
||||
if user.Role == domain.UserRoleIndividual {
|
||||
responseMessage = "Your profile has been submitted for verification. You'll be notified once it's been reviewed."
|
||||
} else if user.Role == domain.UserRoleCompany {
|
||||
responseMessage = "Your retailer profile has been submitted for verification. Our team will review your application and contact you soon."
|
||||
} else {
|
||||
responseMessage = "Your profile has been submitted for verification."
|
||||
}
|
||||
|
||||
return &VerifyProfileResponseDTO{
|
||||
Message: responseMessage,
|
||||
UserRole: user.Role,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateCommonFields validates fields common to all user types
|
||||
func (s *verifyProfileServiceImpl) validateCommonFields(req *VerifyProfileRequestDTO, e map[string]string) {
|
||||
if req.Country == "" {
|
||||
e["country"] = "Country is required"
|
||||
}
|
||||
if req.City == "" {
|
||||
e["city"] = "City is required"
|
||||
}
|
||||
if req.AddressLine1 == "" {
|
||||
e["address_line1"] = "Address is required"
|
||||
}
|
||||
if req.PostalCode == "" {
|
||||
e["postal_code"] = "Postal code is required"
|
||||
}
|
||||
if req.HowDidYouHearAboutUs == 0 {
|
||||
e["how_did_you_hear_about_us"] = "How did you hear about us is required"
|
||||
}
|
||||
if req.HowDidYouHearAboutUs == 7 && req.HowDidYouHearAboutUsOther == "" { // Assuming 7 is "Other"
|
||||
e["how_did_you_hear_about_us_other"] = "Please specify how you heard about us"
|
||||
}
|
||||
|
||||
// Validate shipping address if it's enabled
|
||||
if req.HasShippingAddress {
|
||||
if req.ShippingName == "" {
|
||||
e["shipping_name"] = "Shipping name is required"
|
||||
}
|
||||
if req.ShippingPhone == "" {
|
||||
e["shipping_phone"] = "Shipping phone is required"
|
||||
}
|
||||
if req.ShippingCountry == "" {
|
||||
e["shipping_country"] = "Shipping country is required"
|
||||
}
|
||||
if req.ShippingCity == "" {
|
||||
e["shipping_city"] = "Shipping city is required"
|
||||
}
|
||||
if req.ShippingAddressLine1 == "" {
|
||||
e["shipping_address_line1"] = "Shipping address is required"
|
||||
}
|
||||
if req.ShippingPostalCode == "" {
|
||||
e["shipping_postal_code"] = "Shipping postal code is required"
|
||||
}
|
||||
}
|
||||
|
||||
// More common fields...
|
||||
if req.WebsiteURL == "" {
|
||||
e["website_url"] = "Website URL is required"
|
||||
}
|
||||
if req.Description == "" {
|
||||
e["description"] = "Description is required"
|
||||
}
|
||||
}
|
||||
|
||||
// validateCustomerFields validates fields specific to customers
|
||||
func (s *verifyProfileServiceImpl) validateCustomerFields(req *VerifyProfileRequestDTO, e map[string]string) {
|
||||
if req.HowLongCollectingComicBooksForGrading == 0 {
|
||||
e["how_long_collecting_comic_books_for_grading"] = "How long you've been collecting comic books for grading is required"
|
||||
}
|
||||
if req.HasPreviouslySubmittedComicBookForGrading == 0 {
|
||||
e["has_previously_submitted_comic_book_for_grading"] = "Previous submission information is required"
|
||||
}
|
||||
if req.HasOwnedGradedComicBooks == 0 {
|
||||
e["has_owned_graded_comic_books"] = "Information about owning graded comic books is required"
|
||||
}
|
||||
if req.HasRegularComicBookShop == 0 {
|
||||
e["has_regular_comic_book_shop"] = "Regular comic book shop information is required"
|
||||
}
|
||||
if req.HasPreviouslyPurchasedFromAuctionSite == 0 {
|
||||
e["has_previously_purchased_from_auction_site"] = "Auction site purchase information is required"
|
||||
}
|
||||
if req.HasPreviouslyPurchasedFromFacebookMarketplace == 0 {
|
||||
e["has_previously_purchased_from_facebook_marketplace"] = "Facebook Marketplace purchase information is required"
|
||||
}
|
||||
if req.HasRegularlyAttendedComicConsOrCollectibleShows == 0 {
|
||||
e["has_regularly_attended_comic_cons_or_collectible_shows"] = "Comic convention attendance information is required"
|
||||
}
|
||||
}
|
||||
|
||||
// validateRetailerFields validates fields specific to retailers
|
||||
func (s *verifyProfileServiceImpl) validateRetailerFields(req *VerifyProfileRequestDTO, e map[string]string) {
|
||||
if req.ComicBookStoreName == "" {
|
||||
e["comic_book_store_name"] = "Store name is required"
|
||||
}
|
||||
if req.HowLongStoreOperating == 0 {
|
||||
e["how_long_store_operating"] = "Store operation duration is required"
|
||||
}
|
||||
if req.GradingComicsExperience == "" {
|
||||
e["grading_comics_experience"] = "Grading comics experience is required"
|
||||
}
|
||||
if req.RetailPartnershipReason == "" {
|
||||
e["retail_partnership_reason"] = "Retail partnership reason is required"
|
||||
}
|
||||
if req.ComicBookStoreName == "" {
|
||||
e["comic_book_store_name"] = "Comic book store name is required"
|
||||
}
|
||||
if req.EstimatedSubmissionsPerMonth == 0 {
|
||||
e["estimated_submissions_per_month"] = "Estimated submissions per month is required"
|
||||
}
|
||||
if req.HasOtherGradingService == 0 {
|
||||
e["has_other_grading_service"] = "Other grading service information is required"
|
||||
}
|
||||
if req.HasOtherGradingService == 1 && req.OtherGradingServiceName == "" {
|
||||
e["other_grading_service_name"] = "Please specify the grading service"
|
||||
}
|
||||
if req.RequestWelcomePackage == 0 {
|
||||
e["request_welcome_package"] = "Welcome package request information is required"
|
||||
}
|
||||
}
|
||||
|
||||
// updateCommonFields updates common fields for all user types
|
||||
func (s *verifyProfileServiceImpl) updateCommonFields(user *domain.User, req *VerifyProfileRequestDTO) {
|
||||
user.ProfileData.Country = req.Country
|
||||
user.ProfileData.Region = req.Region
|
||||
user.ProfileData.City = req.City
|
||||
user.ProfileData.PostalCode = req.PostalCode
|
||||
user.ProfileData.AddressLine1 = req.AddressLine1
|
||||
user.ProfileData.AddressLine2 = req.AddressLine2
|
||||
user.ProfileData.HasShippingAddress = req.HasShippingAddress
|
||||
user.ProfileData.ShippingName = req.ShippingName
|
||||
user.ProfileData.ShippingPhone = req.ShippingPhone
|
||||
user.ProfileData.ShippingCountry = req.ShippingCountry
|
||||
user.ProfileData.ShippingRegion = req.ShippingRegion
|
||||
user.ProfileData.ShippingCity = req.ShippingCity
|
||||
user.ProfileData.ShippingPostalCode = req.ShippingPostalCode
|
||||
user.ProfileData.ShippingAddressLine1 = req.ShippingAddressLine1
|
||||
user.ProfileData.ShippingAddressLine2 = req.ShippingAddressLine2
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue