From c41826bb1b77061634f38cdf8010d2d6f9eeaa85 Mon Sep 17 00:00:00 2001 From: "aditya.siregar" Date: Sat, 15 Mar 2025 15:51:18 +0800 Subject: [PATCH] Update Member --- go.mod | 1 + go.sum | 1 + internal/common/errors/errors.go | 36 +- internal/constants/constants.go | 12 + internal/entity/auth.go | 7 + internal/entity/cust.go | 9 + internal/entity/member.go | 70 ++++ internal/entity/order.go | 2 +- internal/entity/user.go | 2 + internal/handlers/http/customer.go | 81 +++++ internal/handlers/http/member.go | 154 ++++++++ internal/handlers/request/member.go | 34 ++ internal/handlers/response/customer.go | 35 ++ internal/handlers/response/member.go | 111 ++++++ internal/repository/customer_repo.go | 134 ++++++- internal/repository/member_repo.go | 138 ++++++++ internal/repository/models/customer.go | 27 +- internal/repository/models/member.go | 25 ++ internal/repository/repository.go | 18 +- internal/routes/routes.go | 2 + internal/services/member/member.go | 51 +++ .../services/member/member_registration.go | 262 ++++++++++++++ internal/services/service.go | 22 +- internal/services/v2/customer/customer.go | 99 +++++- internal/services/v2/member/member.go | 1 + internal/services/v2/order/execute_order.go | 9 +- internal/utils/member_generator.go | 14 + templates/member_registration_otp.html | 217 ++++++++++++ templates/welcome_member.html | 331 ++++++++++++++++++ 29 files changed, 1840 insertions(+), 65 deletions(-) create mode 100644 internal/entity/member.go create mode 100644 internal/handlers/http/customer.go create mode 100644 internal/handlers/http/member.go create mode 100644 internal/handlers/request/member.go create mode 100644 internal/handlers/response/customer.go create mode 100644 internal/handlers/response/member.go create mode 100644 internal/repository/member_repo.go create mode 100644 internal/repository/models/member.go create mode 100644 internal/services/member/member.go create mode 100644 internal/services/member/member_registration.go create mode 100644 internal/services/v2/member/member.go create mode 100644 internal/utils/member_generator.go create mode 100644 templates/member_registration_otp.html create mode 100644 templates/welcome_member.html diff --git a/go.mod b/go.mod index 5db9d96..45702a0 100644 --- a/go.mod +++ b/go.mod @@ -85,6 +85,7 @@ require ( github.com/xuri/excelize/v2 v2.9.0 go.uber.org/zap v1.21.0 golang.org/x/crypto v0.28.0 + golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 golang.org/x/net v0.30.0 gorm.io/driver/postgres v1.5.0 gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11 diff --git a/go.sum b/go.sum index fe00bc6..54243be 100644 --- a/go.sum +++ b/go.sum @@ -343,6 +343,7 @@ golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= diff --git a/internal/common/errors/errors.go b/internal/common/errors/errors.go index f3dba47..2d912a3 100644 --- a/internal/common/errors/errors.go +++ b/internal/common/errors/errors.go @@ -5,23 +5,24 @@ import "net/http" type ErrType string const ( - errRequestTimeOut ErrType = "Request Timeout to 3rd Party" - errConnectTimeOut ErrType = "Connect Timeout to 3rd Party" - errFailedExternalCall ErrType = "Failed response from 3rd Party call" - errExternalCall ErrType = "error on 3rd Party call" - errInvalidRequest ErrType = "Invalid Request" - errBadRequest ErrType = "Bad Request" - errOrderNotFound ErrType = "Astria order is not found" - errCheckoutIDNotDefined ErrType = "Checkout client id not found" - errInternalServer ErrType = "Internal Server error" - errExternalServer ErrType = "External Server error" - errUserIsNotFound ErrType = "User is not found" - errInvalidLogin ErrType = "User email or password is invalid" - errUnauthorized ErrType = "Unauthorized" - errInsufficientBalance ErrType = "Insufficient Balance" - errInactivePartner ErrType = "Partner's license is invalid or has expired. Please contact Admin Support." - errTicketAlreadyUsed ErrType = "Ticket Already Used." - errProductIsRequired ErrType = "Product" + errRequestTimeOut ErrType = "Request Timeout to 3rd Party" + errConnectTimeOut ErrType = "Connect Timeout to 3rd Party" + errFailedExternalCall ErrType = "Failed response from 3rd Party call" + errExternalCall ErrType = "error on 3rd Party call" + errInvalidRequest ErrType = "Invalid Request" + errBadRequest ErrType = "Bad Request" + errOrderNotFound ErrType = "Astria order is not found" + errCheckoutIDNotDefined ErrType = "Checkout client id not found" + errInternalServer ErrType = "Internal Server error" + errExternalServer ErrType = "External Server error" + errUserIsNotFound ErrType = "User is not found" + errInvalidLogin ErrType = "User email or password is invalid" + errUnauthorized ErrType = "Unauthorized" + errInsufficientBalance ErrType = "Insufficient Balance" + errInactivePartner ErrType = "Partner's license is invalid or has expired. Please contact Admin Support." + errTicketAlreadyUsed ErrType = "Ticket Already Used." + errProductIsRequired ErrType = "Product" + errEmailAndPhoneNumberRequired ErrType = "Email or Phone is required" ) var ( @@ -41,6 +42,7 @@ var ( ErrorInsufficientBalance = NewServiceException(errInsufficientBalance) ErrorInvalidLicense = NewServiceException(errInactivePartner) ErrorTicketInvalidOrAlreadyUsed = NewServiceException(errTicketAlreadyUsed) + ErrorPhoneNumberEmailIsRequired = NewServiceException(errEmailAndPhoneNumberRequired) ) type Error interface { diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 0e543ef..05f7a41 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -52,3 +52,15 @@ func GenerateRefID() string { var TimeNow = func() time.Time { return time.Now() } + +type RegistrationStatus string + +const ( + RegistrationSuccess RegistrationStatus = "SUCCESS" + RegistrationPending RegistrationStatus = "PENDING" + RegistrationFailed RegistrationStatus = "FAILED" +) + +func (u RegistrationStatus) String() string { + return string(u) +} diff --git a/internal/entity/auth.go b/internal/entity/auth.go index 69e0e94..047a3c2 100644 --- a/internal/entity/auth.go +++ b/internal/entity/auth.go @@ -189,6 +189,7 @@ func (o *UserDB) SetDeleted(updatedby int64) { o.Status = userstatus.Inactive } +type MemberList []*Customer type CustomerList []*UserDB type CustomerSearch struct { @@ -210,3 +211,9 @@ func (b *CustomerList) ToCustomerList() []*Customer { } return users } + +type MemberSearch struct { + Search string + Limit int + Offset int +} diff --git a/internal/entity/cust.go b/internal/entity/cust.go index 680f43b..03db4a3 100644 --- a/internal/entity/cust.go +++ b/internal/entity/cust.go @@ -1,8 +1,17 @@ package entity +import "time" + type CustomerResolutionRequest struct { ID *int64 Name string Email string PhoneNumber string + BirthDate time.Time +} + +type CustomerCheckResponse struct { + Exists bool + Customer *Customer + Message string } diff --git a/internal/entity/member.go b/internal/entity/member.go new file mode 100644 index 0000000..ca1113b --- /dev/null +++ b/internal/entity/member.go @@ -0,0 +1,70 @@ +package entity + +import ( + "enaklo-pos-be/internal/constants" + "time" +) + +type MemberRegistrationRequest struct { + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required,email"` + Phone string `json:"phone" validate:"required"` + BirthDate time.Time `json:"birth_date"` + BranchID int64 `json:"branch_id" validate:"required"` + CashierID int64 `json:"cashier_id" validate:"required"` +} + +type MemberRegistrationResponse struct { + Token string `json:"token"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + Message string `json:"message"` +} + +type MemberRegistration struct { + ID string `json:"id"` + Token string `json:"token"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + BirthDate time.Time `json:"birth_date"` + OTP string `json:"-"` // Not exposed in JSON responses + Status constants.RegistrationStatus `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + BranchID int64 `json:"branch_id"` + CashierID int64 `json:"cashier_id"` +} + +type MemberVerificationRequest struct { + Token string `json:"token" validate:"required"` + OTP string `json:"otp" validate:"required"` +} + +type MemberVerificationResponse struct { + CustomerID int64 `json:"customer_id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + Points int `json:"points"` + Status string `json:"status"` +} + +type MemberRegistrationStatus struct { + Token string `json:"token"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + IsExpired bool `json:"is_expired"` + CreatedAt time.Time `json:"created_at"` +} + +type ResendOTPRequest struct { + Token string `json:"token" validate:"required"` +} + +type ResendOTPResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + Message string `json:"message"` +} diff --git a/internal/entity/order.go b/internal/entity/order.go index 6b57f43..42aa965 100644 --- a/internal/entity/order.go +++ b/internal/entity/order.go @@ -75,7 +75,7 @@ type OrderItem struct { ItemID int64 `gorm:"type:int;column:item_id"` ItemType string `gorm:"type:varchar;column:item_type"` Price float64 `gorm:"type:numeric;not null;column:price"` - Quantity int `gorm:"type:int;column:qty"` + Quantity int `gorm:"type:int;column:quantity"` CreatedAt time.Time `gorm:"autoCreateTime;column:created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at"` CreatedBy int64 `gorm:"type:int;column:created_by"` diff --git a/internal/entity/user.go b/internal/entity/user.go index 9d4e425..19cf26a 100644 --- a/internal/entity/user.go +++ b/internal/entity/user.go @@ -49,6 +49,8 @@ type Customer struct { SiteName string PartnerName string ResetPassword bool + CustomerID string + BirthDate time.Time } type AuthenticateUser struct { diff --git a/internal/handlers/http/customer.go b/internal/handlers/http/customer.go new file mode 100644 index 0000000..020bf76 --- /dev/null +++ b/internal/handlers/http/customer.go @@ -0,0 +1,81 @@ +package http + +import ( + "enaklo-pos-be/internal/services/v2/customer" + "net/http" + "strconv" + + "enaklo-pos-be/internal/common/errors" + "enaklo-pos-be/internal/entity" + "enaklo-pos-be/internal/handlers/request" + "enaklo-pos-be/internal/handlers/response" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +type CustomerHandler struct { + service customer.Service +} + +func NewCustomerHandler(service customer.Service) *CustomerHandler { + return &CustomerHandler{ + service: service, + } +} + +func (h *CustomerHandler) Route(group *gin.RouterGroup, jwt gin.HandlerFunc) { + route := group.Group("/customers") + + route.GET("/list", jwt, h.GetCustomerList) +} + +func (h *CustomerHandler) GetCustomerList(c *gin.Context) { + ctx := request.GetMyContext(c) + + searchQuery := c.DefaultQuery("search", "") + limitStr := c.DefaultQuery("limit", "10") + offsetStr := c.DefaultQuery("offset", "0") + + // Convert limit and offset to integers + limit, err := strconv.Atoi(limitStr) + if err != nil { + response.ErrorWrapper(c, errors.ErrorBadRequest) + return + } + + offset, err := strconv.Atoi(offsetStr) + if err != nil { + response.ErrorWrapper(c, errors.ErrorBadRequest) + return + } + + req := &entity.MemberSearch{ + Search: searchQuery, + Limit: limit, + Offset: offset, + } + + validate := validator.New() + if err := validate.Struct(req); err != nil { + response.ErrorWrapper(c, err) + return + } + + customerList, totalCount, err := h.service.GetAllCustomers(ctx, req) + if err != nil { + response.ErrorWrapper(c, err) + return + } + + c.JSON(http.StatusOK, response.BaseResponse{ + Success: true, + Status: http.StatusOK, + Data: response.MapToCustomerListResponse(customerList), + PagingMeta: &response.PagingMeta{ + Page: offset + 1, + Total: int64(totalCount), + Limit: limit, + }, + }) +} diff --git a/internal/handlers/http/member.go b/internal/handlers/http/member.go new file mode 100644 index 0000000..c309dbb --- /dev/null +++ b/internal/handlers/http/member.go @@ -0,0 +1,154 @@ +package http + +import ( + "enaklo-pos-be/internal/common/errors" + "enaklo-pos-be/internal/entity" + "enaklo-pos-be/internal/handlers/request" + "enaklo-pos-be/internal/handlers/response" + "enaklo-pos-be/internal/services/member" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "net/http" +) + +type MemberHandler struct { + service member.RegistrationService +} + +func NewMemberRegistrationHandler(service member.RegistrationService) *MemberHandler { + return &MemberHandler{ + service: service, + } +} + +func (h *MemberHandler) Route(group *gin.RouterGroup, jwt gin.HandlerFunc) { + route := group.Group("/member") + + route.POST("/register", jwt, h.InitiateRegistration) + route.POST("/verify", jwt, h.VerifyOTP) + route.GET("/status", jwt, h.GetRegistrationStatus) + route.POST("/resend-otp", jwt, h.ResendOTP) + route.GET("/list", jwt, h.GetRegistrationStatus) +} + +func (h *MemberHandler) InitiateRegistration(c *gin.Context) { + ctx := request.GetMyContext(c) + userID := ctx.RequestedBy() + + var req request.InitiateRegistrationRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.ErrorWrapper(c, errors.ErrorBadRequest) + return + } + + validate := validator.New() + if err := validate.Struct(req); err != nil { + response.ErrorWrapper(c, err) + return + } + + birthDate, err := req.GetBirthdate() + if err != nil { + response.ErrorWrapper(c, err) + return + } + + memberReq := &entity.MemberRegistrationRequest{ + Name: req.Name, + Email: req.Email, + Phone: req.Phone, + BirthDate: birthDate, + BranchID: *ctx.GetPartnerID(), + CashierID: userID, + } + + result, err := h.service.InitiateRegistration(ctx, memberReq) + if err != nil { + response.ErrorWrapper(c, err) + return + } + + c.JSON(http.StatusOK, response.BaseResponse{ + Success: true, + Status: http.StatusOK, + Data: response.MapToMemberRegistrationResponse(result), + }) +} + +func (h *MemberHandler) VerifyOTP(c *gin.Context) { + ctx := request.GetMyContext(c) + + var req request.VerifyOTPRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.ErrorWrapper(c, errors.ErrorBadRequest) + return + } + + validate := validator.New() + if err := validate.Struct(req); err != nil { + response.ErrorWrapper(c, err) + return + } + + result, err := h.service.VerifyOTP(ctx, req.Token, req.OTP) + if err != nil { + response.ErrorWrapper(c, err) + return + } + + c.JSON(http.StatusOK, response.BaseResponse{ + Success: true, + Status: http.StatusOK, + Data: response.MapToMemberVerificationResponse(result), + }) +} + +func (h *MemberHandler) GetRegistrationStatus(c *gin.Context) { + ctx := request.GetMyContext(c) + token := c.Query("token") + + if token == "" { + response.ErrorWrapper(c, errors.ErrorBadRequest) + return + } + + result, err := h.service.GetRegistrationStatus(ctx, token) + if err != nil { + response.ErrorWrapper(c, err) + return + } + + c.JSON(http.StatusOK, response.BaseResponse{ + Success: true, + Status: http.StatusOK, + Data: response.MapToMemberRegistrationStatus(result), + }) +} + +func (h *MemberHandler) ResendOTP(c *gin.Context) { + ctx := request.GetMyContext(c) + + var req entity.ResendOTPRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.ErrorWrapper(c, errors.ErrorBadRequest) + return + } + + validate := validator.New() + if err := validate.Struct(req); err != nil { + response.ErrorWrapper(c, err) + return + } + + result, err := h.service.ResendOTP(ctx, req.Token) + if err != nil { + response.ErrorWrapper(c, err) + return + } + + c.JSON(http.StatusOK, response.BaseResponse{ + Success: true, + Status: http.StatusOK, + Data: response.MapToResendOTPResponse(result), + }) +} diff --git a/internal/handlers/request/member.go b/internal/handlers/request/member.go new file mode 100644 index 0000000..9eca421 --- /dev/null +++ b/internal/handlers/request/member.go @@ -0,0 +1,34 @@ +package request + +import ( + "time" +) + +type InitiateRegistrationRequest struct { + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required,email"` + Phone string `json:"phone" validate:"required"` + BirthDate string `json:"birth_date" validate:"required"` +} + +func (i *InitiateRegistrationRequest) GetBirthdate() (time.Time, error) { + parsedDate, err := time.Parse("02-01-2006", i.BirthDate) + if err != nil { + return time.Time{}, err + } + return parsedDate, nil +} + +type VerifyOTPRequest struct { + Token string `json:"token" validate:"required"` + OTP string `json:"otp" validate:"required"` +} + +type ResendOTPRequest struct { + Token string `json:"token" validate:"required"` +} + +type CheckCustomerRequest struct { + Email string `json:"email"` + Phone string `json:"phone"` +} diff --git a/internal/handlers/response/customer.go b/internal/handlers/response/customer.go new file mode 100644 index 0000000..f480a77 --- /dev/null +++ b/internal/handlers/response/customer.go @@ -0,0 +1,35 @@ +package response + +import ( + "enaklo-pos-be/internal/entity" +) + +func MapToCustomerResponse(customer *entity.Customer) CustomerResponse { + if customer == nil { + return CustomerResponse{} + } + + return CustomerResponse{ + ID: customer.ID, + Name: customer.Name, + Email: customer.Email, + Phone: customer.Phone, + Points: customer.Points, + CustomerID: customer.CustomerID, + CreatedAt: customer.CreatedAt.Format("2006-01-02"), + BirthDate: customer.BirthDate.Format("2006-01-02"), + } +} + +func MapToCustomerListResponse(customers *entity.MemberList) []CustomerResponse { + if customers == nil { + return []CustomerResponse{} + } + + responseList := []CustomerResponse{} + for _, customer := range *customers { + responseList = append(responseList, MapToCustomerResponse(customer)) + } + + return responseList +} diff --git a/internal/handlers/response/member.go b/internal/handlers/response/member.go new file mode 100644 index 0000000..9053d46 --- /dev/null +++ b/internal/handlers/response/member.go @@ -0,0 +1,111 @@ +package response + +import ( + "enaklo-pos-be/internal/entity" + "time" +) + +type MemberRegistrationResponse struct { + Token string `json:"token"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + Message string `json:"message"` +} + +type MemberVerificationResponse struct { + CustomerID int64 `json:"customer_id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + Points int `json:"points"` + Status string `json:"status"` +} + +type MemberRegistrationStatus struct { + Token string `json:"token"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + IsExpired bool `json:"is_expired"` + CreatedAt time.Time `json:"created_at"` +} + +type ResendOTPResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + Message string `json:"message"` +} + +type CustomerCheckResponse struct { + Exists bool `json:"exists"` + Customer *CustomerResponse `json:"customer,omitempty"` + Message string `json:"message,omitempty"` +} + +type CustomerResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + BirthDate string `json:"birth_date,omitempty"` + Points int `json:"points"` + CreatedAt string `json:"created_at"` + CustomerID string `json:"customer_id"` +} + +func MapToMemberRegistrationResponse(entity *entity.MemberRegistrationResponse) MemberRegistrationResponse { + return MemberRegistrationResponse{ + Token: entity.Token, + Status: entity.Status, + ExpiresAt: entity.ExpiresAt, + Message: entity.Message, + } +} + +func MapToMemberVerificationResponse(entity *entity.MemberVerificationResponse) MemberVerificationResponse { + return MemberVerificationResponse{ + CustomerID: entity.CustomerID, + Name: entity.Name, + Email: entity.Email, + Phone: entity.Phone, + Points: entity.Points, + Status: entity.Status, + } +} + +func MapToMemberRegistrationStatus(entity *entity.MemberRegistrationStatus) MemberRegistrationStatus { + return MemberRegistrationStatus{ + Token: entity.Token, + Status: entity.Status, + ExpiresAt: entity.ExpiresAt, + IsExpired: entity.IsExpired, + CreatedAt: entity.CreatedAt, + } +} + +func MapToResendOTPResponse(entity *entity.ResendOTPResponse) ResendOTPResponse { + return ResendOTPResponse{ + Token: entity.Token, + ExpiresAt: entity.ExpiresAt, + Message: entity.Message, + } +} + +func MapToCustomerCheckResponse(entity *entity.CustomerCheckResponse) CustomerCheckResponse { + response := CustomerCheckResponse{ + Exists: entity.Exists, + Message: entity.Message, + } + + if entity.Customer != nil { + customer := &CustomerResponse{ + ID: entity.Customer.ID, + Name: entity.Customer.Name, + Email: entity.Customer.Email, + Phone: entity.Customer.Phone, + CreatedAt: entity.Customer.CreatedAt.Format("2006-01-02"), + } + response.Customer = customer + } + + return response +} diff --git a/internal/repository/customer_repo.go b/internal/repository/customer_repo.go index 1d9bc53..3a069e1 100644 --- a/internal/repository/customer_repo.go +++ b/internal/repository/customer_repo.go @@ -15,6 +15,8 @@ type CustomerRepo interface { FindByPhone(ctx mycontext.Context, phone string) (*entity.Customer, error) FindByEmail(ctx mycontext.Context, email string) (*entity.Customer, error) AddPoints(ctx mycontext.Context, id int64, points int) error + FindSequence(ctx mycontext.Context, partnerID int64) (int64, error) + GetAllCustomers(ctx mycontext.Context, req entity.MemberSearch) (entity.MemberList, int, error) } type customerRepository struct { @@ -105,24 +107,128 @@ func (r *customerRepository) AddPoints(ctx mycontext.Context, id int64, points i func (r *customerRepository) toCustomerDBModel(customer *entity.Customer) models.CustomerDB { return models.CustomerDB{ - ID: customer.ID, - Name: customer.Name, - Email: customer.Email, - Phone: customer.Phone, - Points: customer.Points, - CreatedAt: customer.CreatedAt, - UpdatedAt: customer.UpdatedAt, + ID: customer.ID, + Name: customer.Name, + Email: customer.Email, + Phone: customer.Phone, + Points: customer.Points, + CreatedAt: customer.CreatedAt, + UpdatedAt: customer.UpdatedAt, + CustomerID: customer.CustomerID, + BirthDate: customer.BirthDate, } } +func (r *customerRepository) FindSequence(ctx mycontext.Context, partnerID int64) (int64, error) { + tx := r.db.Begin() + if tx.Error != nil { + return 0, errors.Wrap(tx.Error, "failed to begin transaction") + } + + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + var sequence models.PartnerMemberSequence + + result := tx.Where("partner_id = ?", partnerID).First(&sequence) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + now := time.Now() + newSequence := models.PartnerMemberSequence{ + PartnerID: partnerID, + LastSequence: 1, + UpdatedAt: now, + } + + if err := tx.Create(&newSequence).Error; err != nil { + tx.Rollback() + return 0, errors.Wrap(err, "failed to create new sequence") + } + + if err := tx.Commit().Error; err != nil { + return 0, errors.Wrap(err, "failed to commit transaction") + } + + return 1, nil + } + + tx.Rollback() + return 0, errors.Wrap(result.Error, "failed to query sequence") + } + + newSequenceValue := sequence.LastSequence + 1 + updates := map[string]interface{}{ + "last_sequence": newSequenceValue, + "updated_at": time.Now(), + } + + if err := tx.Model(&sequence).Updates(updates).Error; err != nil { + tx.Rollback() + return 0, errors.Wrap(err, "failed to update sequence") + } + + if err := tx.Commit().Error; err != nil { + return 0, errors.Wrap(err, "failed to commit transaction") + } + + return newSequenceValue, nil +} + func (r *customerRepository) toDomainCustomerModel(dbModel *models.CustomerDB) *entity.Customer { return &entity.Customer{ - ID: dbModel.ID, - Name: dbModel.Name, - Email: dbModel.Email, - Phone: dbModel.Phone, - Points: dbModel.Points, - CreatedAt: dbModel.CreatedAt, - UpdatedAt: dbModel.UpdatedAt, + ID: dbModel.ID, + Name: dbModel.Name, + Email: dbModel.Email, + Phone: dbModel.Phone, + Points: dbModel.Points, + CreatedAt: dbModel.CreatedAt, + UpdatedAt: dbModel.UpdatedAt, + CustomerID: dbModel.CustomerID, + BirthDate: dbModel.BirthDate, } } + +func (r *customerRepository) GetAllCustomers(ctx mycontext.Context, req entity.MemberSearch) (entity.MemberList, int, error) { + if req.Limit <= 0 { + req.Limit = 10 + } + if req.Offset < 0 { + req.Offset = 0 + } + + query := r.db.Model(&models.CustomerDB{}) + + if req.Search != "" { + searchTerm := "%" + req.Search + "%" + query = query.Where( + "name ILIKE ? OR email ILIKE ? OR phone ILIKE ?", + searchTerm, searchTerm, searchTerm, + ) + } + + var totalCount int64 + if err := query.Count(&totalCount).Error; err != nil { + return nil, 0, errors.Wrap(err, "failed to count customers") + } + + var customersDB []models.CustomerDB + result := query. + Order("created_at DESC"). + Limit(req.Limit). + Offset(req.Offset). + Find(&customersDB) + + if result.Error != nil { + return nil, 0, errors.Wrap(result.Error, "failed to retrieve customers") + } + + customers := make(entity.MemberList, len(customersDB)) + for i, customerDB := range customersDB { + customers[i] = r.toDomainCustomerModel(&customerDB) + } + + return customers, int(totalCount), nil +} diff --git a/internal/repository/member_repo.go b/internal/repository/member_repo.go new file mode 100644 index 0000000..c0de2c8 --- /dev/null +++ b/internal/repository/member_repo.go @@ -0,0 +1,138 @@ +package repository + +import ( + "enaklo-pos-be/internal/common/logger" + "enaklo-pos-be/internal/common/mycontext" + "enaklo-pos-be/internal/constants" + "enaklo-pos-be/internal/entity" + "enaklo-pos-be/internal/repository/models" + "errors" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +type MemberRepository interface { + CreateRegistration(ctx mycontext.Context, registration *entity.MemberRegistration) (*entity.MemberRegistration, error) + GetRegistrationByToken(ctx mycontext.Context, token string) (*entity.MemberRegistration, error) + UpdateRegistrationStatus(ctx mycontext.Context, token string, status constants.RegistrationStatus) error + UpdateRegistrationOTP(ctx mycontext.Context, token string, otp string, expiresAt time.Time) error +} + +type memberRepository struct { + db *gorm.DB +} + +func NewMemberRepository(db *gorm.DB) MemberRepository { + return &memberRepository{ + db: db, + } +} + +func (r *memberRepository) CreateRegistration(ctx mycontext.Context, registration *entity.MemberRegistration) (*entity.MemberRegistration, error) { + registrationDB := r.toRegistrationDBModel(registration) + + if err := r.db.Create(®istrationDB).Error; err != nil { + logger.ContextLogger(ctx).Error("failed to create member registration", zap.Error(err)) + return nil, errors.New("failed to insert member registration") + } + + return registration, nil +} + +func (r *memberRepository) GetRegistrationByToken(ctx mycontext.Context, token string) (*entity.MemberRegistration, error) { + var registrationDB models.MemberRegistrationDB + + if err := r.db.Where("token = ?", token).First(®istrationDB).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("registration not found") + } + logger.ContextLogger(ctx).Error("failed to get registration by token", zap.Error(err)) + return nil, errors.New("failed to get registration by token") + } + + registration := r.toDomainRegistrationModel(®istrationDB) + return registration, nil +} + +func (r *memberRepository) UpdateRegistrationStatus(ctx mycontext.Context, token string, status constants.RegistrationStatus) error { + now := time.Now() + + result := r.db.Model(&models.MemberRegistrationDB{}). + Where("token = ?", token). + Updates(map[string]interface{}{ + "status": status, + "updated_at": now, + }) + + if result.Error != nil { + logger.ContextLogger(ctx).Error("failed to update registration status", zap.Error(result.Error)) + return errors.New("failed to update registration status") + } + + if result.RowsAffected == 0 { + return errors.New("registration not found") + } + + return nil +} + +func (r *memberRepository) UpdateRegistrationOTP(ctx mycontext.Context, token string, otp string, expiresAt time.Time) error { + now := time.Now() + + result := r.db.Model(&models.MemberRegistrationDB{}). + Where("token = ?", token). + Updates(map[string]interface{}{ + "otp": otp, + "expires_at": expiresAt, + "updated_at": now, + }) + + if result.Error != nil { + logger.ContextLogger(ctx).Error("failed to update registration OTP", zap.Error(result.Error)) + return errors.New("failed to update registration OTP") + } + + if result.RowsAffected == 0 { + return errors.New("registration not found") + } + + return nil +} + +func (r *memberRepository) toRegistrationDBModel(registration *entity.MemberRegistration) models.MemberRegistrationDB { + return models.MemberRegistrationDB{ + ID: registration.ID, + Token: registration.Token, + Name: registration.Name, + Email: registration.Email, + Phone: registration.Phone, + BirthDate: registration.BirthDate, + OTP: registration.OTP, + Status: registration.Status.String(), + ExpiresAt: registration.ExpiresAt, + CreatedAt: registration.CreatedAt, + UpdatedAt: registration.UpdatedAt, + BranchID: registration.BranchID, + CashierID: registration.CashierID, + } +} + +func (r *memberRepository) toDomainRegistrationModel(dbModel *models.MemberRegistrationDB) *entity.MemberRegistration { + return &entity.MemberRegistration{ + ID: dbModel.ID, + Token: dbModel.Token, + Name: dbModel.Name, + Email: dbModel.Email, + Phone: dbModel.Phone, + BirthDate: dbModel.BirthDate, + OTP: dbModel.OTP, + Status: constants.RegistrationStatus(dbModel.Status), + ExpiresAt: dbModel.ExpiresAt, + CreatedAt: dbModel.CreatedAt, + UpdatedAt: dbModel.UpdatedAt, + BranchID: dbModel.BranchID, + CashierID: dbModel.CashierID, + } +} diff --git a/internal/repository/models/customer.go b/internal/repository/models/customer.go index 0ea80c4..ae43218 100644 --- a/internal/repository/models/customer.go +++ b/internal/repository/models/customer.go @@ -5,15 +5,28 @@ import ( ) type CustomerDB struct { - ID int64 `gorm:"primaryKey;column:id"` - Name string `gorm:"column:name"` - Email string `gorm:"column:email"` - Phone string `gorm:"column:phone"` - Points int `gorm:"column:points"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time `gorm:"column:updated_at"` + ID int64 `gorm:"primaryKey;column:id"` + Name string `gorm:"column:name"` + Email string `gorm:"column:email"` + Phone string `gorm:"column:phone"` + Points int `gorm:"column:points"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` + CustomerID string `gorm:"column:customer_id"` + BirthDate time.Time `gorm:"column:birth_date"` } func (CustomerDB) TableName() string { return "customers" } + +type PartnerMemberSequence struct { + ID int64 `gorm:"column:id;primary_key;auto_increment"` + PartnerID int64 `gorm:"column:partner_id;not null;index:idx_partner_month,unique"` + LastSequence int64 `gorm:"column:last_sequence;not null;default:0"` + UpdatedAt time.Time `gorm:"column:updated_at;not null"` +} + +func (PartnerMemberSequence) TableName() string { + return "partner_member_sequences" +} diff --git a/internal/repository/models/member.go b/internal/repository/models/member.go new file mode 100644 index 0000000..053df3c --- /dev/null +++ b/internal/repository/models/member.go @@ -0,0 +1,25 @@ +package models + +import ( + "time" +) + +type MemberRegistrationDB struct { + ID string `gorm:"column:id;primary_key"` + Token string `gorm:"column:token;unique_index"` + Name string `gorm:"column:name"` + Email string `gorm:"column:email"` + Phone string `gorm:"column:phone"` + BirthDate time.Time `gorm:"column:birth_date"` + OTP string `gorm:"column:otp"` + Status string `gorm:"column:status"` + ExpiresAt time.Time `gorm:"column:expires_at"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` + BranchID int64 `gorm:"column:branch_id"` + CashierID int64 `gorm:"column:cashier_id"` +} + +func (MemberRegistrationDB) TableName() string { + return "member_registrations" +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index f8b387c..0144ed5 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -52,10 +52,11 @@ type RepoManagerImpl struct { PG PaymentGateway LinkQu LinkQu - OrderRepo OrderRepository - CustomerRepo CustomerRepo - ProductRepo ProductRepository - TransactionRepo TransactionRepo + OrderRepo OrderRepository + CustomerRepo CustomerRepo + ProductRepo ProductRepository + TransactionRepo TransactionRepo + MemberRepository MemberRepository } func NewRepoManagerImpl(db *gorm.DB, cfg *config.Config) *RepoManagerImpl { @@ -80,10 +81,11 @@ func NewRepoManagerImpl(db *gorm.DB, cfg *config.Config) *RepoManagerImpl { PG: pg.NewPaymentGatewayRepo(&cfg.Midtrans, &cfg.LinkQu), LinkQu: linkqu.NewLinkQuService(&cfg.LinkQu), - OrderRepo: NeworderRepository(db), - CustomerRepo: NewCustomerRepository(db), - ProductRepo: NewproductRepository(db), - TransactionRepo: NewTransactionRepository(db), + OrderRepo: NeworderRepository(db), + CustomerRepo: NewCustomerRepository(db), + ProductRepo: NewproductRepository(db), + TransactionRepo: NewTransactionRepository(db), + MemberRepository: NewMemberRepository(db), } } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 6f1c269..69a3aef 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -78,6 +78,8 @@ func RegisterPrivateRoutesV2(app *app.Server, serviceManager *services.ServiceMa serverRoutes := []HTTPHandlerRoutes{ http2.NewOrderHandler(serviceManager.OrderV2Svc), + http2.NewMemberRegistrationHandler(serviceManager.MemberRegistrationSvc), + http2.NewCustomerHandler(serviceManager.CustomerV2Svc), } for _, handler := range serverRoutes { diff --git a/internal/services/member/member.go b/internal/services/member/member.go new file mode 100644 index 0000000..375bda2 --- /dev/null +++ b/internal/services/member/member.go @@ -0,0 +1,51 @@ +package member + +import ( + "context" + "enaklo-pos-be/internal/common/mycontext" + "enaklo-pos-be/internal/constants" + "enaklo-pos-be/internal/entity" + "time" +) + +type RegistrationService interface { + InitiateRegistration(ctx mycontext.Context, request *entity.MemberRegistrationRequest) (*entity.MemberRegistrationResponse, error) + VerifyOTP(ctx mycontext.Context, token string, otp string) (*entity.MemberVerificationResponse, error) + GetRegistrationStatus(ctx mycontext.Context, token string) (*entity.MemberRegistrationStatus, error) + ResendOTP(ctx mycontext.Context, token string) (*entity.ResendOTPResponse, error) +} + +type memberSvc struct { + repo Repository + notification NotificationService + customerSvc CustomerService +} + +type Repository interface { + CreateRegistration(ctx mycontext.Context, registration *entity.MemberRegistration) (*entity.MemberRegistration, error) + GetRegistrationByToken(ctx mycontext.Context, token string) (*entity.MemberRegistration, error) + UpdateRegistrationStatus(ctx mycontext.Context, token string, status constants.RegistrationStatus) error + UpdateRegistrationOTP(ctx mycontext.Context, token string, otp string, expiresAt time.Time) error +} + +type NotificationService interface { + SendEmailTransactional(ctx context.Context, param entity.SendEmailNotificationParam) error +} + +type CustomerService interface { + ResolveCustomer(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (int64, error) + GetCustomer(ctx mycontext.Context, id int64) (*entity.Customer, error) + CustomerCheck(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (*entity.CustomerCheckResponse, error) +} + +func NewMemberRegistrationService( + repo Repository, + notification NotificationService, + customerSvc CustomerService, +) RegistrationService { + return &memberSvc{ + repo: repo, + notification: notification, + customerSvc: customerSvc, + } +} diff --git a/internal/services/member/member_registration.go b/internal/services/member/member_registration.go new file mode 100644 index 0000000..73f4054 --- /dev/null +++ b/internal/services/member/member_registration.go @@ -0,0 +1,262 @@ +package member + +import ( + "enaklo-pos-be/internal/common/logger" + "enaklo-pos-be/internal/common/mycontext" + "enaklo-pos-be/internal/constants" + "enaklo-pos-be/internal/entity" + "errors" + "go.uber.org/zap" + "golang.org/x/exp/rand" + "time" +) + +func (s *memberSvc) InitiateRegistration( + ctx mycontext.Context, + request *entity.MemberRegistrationRequest, +) (*entity.MemberRegistrationResponse, error) { + customerResolution := &entity.CustomerResolutionRequest{ + Email: request.Email, + PhoneNumber: request.Phone, + } + + checkResult, err := s.customerSvc.CustomerCheck(ctx, customerResolution) + if checkResult.Exists { + return nil, errors.New(checkResult.Message) + } + + otp := generateOTP(6) + + token := constants.GenerateUUID() + + registration := &entity.MemberRegistration{ + ID: constants.GenerateUUID(), + Token: token, + Name: request.Name, + Email: request.Email, + Phone: request.Phone, + BirthDate: request.BirthDate, + OTP: otp, + Status: constants.RegistrationPending, + ExpiresAt: constants.TimeNow().Add(10 * time.Minute), // OTP expires in 10 minutes + CreatedAt: constants.TimeNow(), + UpdatedAt: constants.TimeNow(), + BranchID: request.BranchID, + CashierID: request.CashierID, + } + + savedRegistration, err := s.repo.CreateRegistration(ctx, registration) + if err != nil { + logger.ContextLogger(ctx).Error("failed to create member registration", zap.Error(err)) + return nil, err + } + + err = s.sendRegistrationOTP(ctx, savedRegistration) + if err != nil { + logger.ContextLogger(ctx).Warn("failed to send OTP", zap.Error(err)) + } + + return &entity.MemberRegistrationResponse{ + Token: token, + Status: savedRegistration.Status.String(), + ExpiresAt: savedRegistration.ExpiresAt, + Message: "Registration initiated. Please verify with OTP sent to your email.", + }, nil +} + +func (s *memberSvc) VerifyOTP( + ctx mycontext.Context, + token string, + otp string, +) (*entity.MemberVerificationResponse, error) { + logger.ContextLogger(ctx).Info("verifying OTP for member registration", zap.String("token", token)) + + registration, err := s.repo.GetRegistrationByToken(ctx, token) + if err != nil { + logger.ContextLogger(ctx).Error("failed to get registration", zap.Error(err)) + return nil, errors.New("invalid registration token") + } + + if registration.Status == constants.RegistrationSuccess { + return nil, errors.New("registration already completed") + } + + if registration.ExpiresAt.Before(constants.TimeNow()) { + return nil, errors.New("registration expired") + } + + if registration.OTP != otp { + return nil, errors.New("invalid OTP") + } + + customerResolution := &entity.CustomerResolutionRequest{ + Name: registration.Name, + Email: registration.Email, + PhoneNumber: registration.Phone, + BirthDate: registration.BirthDate, + } + + customerID, err := s.customerSvc.ResolveCustomer(ctx, customerResolution) + if err != nil { + logger.ContextLogger(ctx).Error("failed to create customer", zap.Error(err)) + return nil, errors.New("failed to create member record") + } + + err = s.repo.UpdateRegistrationStatus(ctx, token, constants.RegistrationSuccess) + if err != nil { + logger.ContextLogger(ctx).Warn("failed to update registration status", zap.Error(err)) + } + + customer, err := s.customerSvc.GetCustomer(ctx, customerID) + if err != nil { + logger.ContextLogger(ctx).Warn("failed to get created customer", zap.Error(err)) + + return &entity.MemberVerificationResponse{ + CustomerID: customerID, + Name: registration.Name, + Email: registration.Email, + Phone: registration.Phone, + Status: "Registration completed successfully", + }, nil + } + + err = s.sendWelcomeEmail(ctx, customer) + if err != nil { + logger.ContextLogger(ctx).Warn("failed to send welcome email", zap.Error(err)) + } + + return &entity.MemberVerificationResponse{ + CustomerID: customer.ID, + Name: customer.Name, + Email: customer.Email, + Phone: customer.Phone, + Points: customer.Points, + Status: "Registration completed successfully", + }, nil +} + +func (s *memberSvc) GetRegistrationStatus( + ctx mycontext.Context, + token string, +) (*entity.MemberRegistrationStatus, error) { + logger.ContextLogger(ctx).Info("checking registration status", zap.String("token", token)) + + registration, err := s.repo.GetRegistrationByToken(ctx, token) + if err != nil { + logger.ContextLogger(ctx).Error("failed to get registration", zap.Error(err)) + return nil, errors.New("invalid registration token") + } + + return &entity.MemberRegistrationStatus{ + Token: registration.Token, + Status: registration.Status.String(), + ExpiresAt: registration.ExpiresAt, + IsExpired: registration.ExpiresAt.Before(constants.TimeNow()), + CreatedAt: registration.CreatedAt, + }, nil +} + +func (s *memberSvc) ResendOTP( + ctx mycontext.Context, + token string, +) (*entity.ResendOTPResponse, error) { + logger.ContextLogger(ctx).Info("resending OTP", zap.String("token", token)) + + // Get registration by token + registration, err := s.repo.GetRegistrationByToken(ctx, token) + if err != nil { + logger.ContextLogger(ctx).Error("failed to get registration", zap.Error(err)) + return nil, errors.New("invalid registration token") + } + + if registration.Status == constants.RegistrationSuccess { + return nil, errors.New("registration already completed") + } + + newOTP := generateOTP(6) + newExpiresAt := constants.TimeNow().Add(10 * time.Minute) + + err = s.repo.UpdateRegistrationOTP(ctx, token, newOTP, newExpiresAt) + if err != nil { + logger.ContextLogger(ctx).Error("failed to update OTP", zap.Error(err)) + return nil, errors.New("failed to generate new OTP") + } + + registration.OTP = newOTP + registration.ExpiresAt = newExpiresAt + + err = s.sendRegistrationOTP(ctx, registration) + if err != nil { + logger.ContextLogger(ctx).Warn("failed to send OTP", zap.Error(err)) + } + + return &entity.ResendOTPResponse{ + Token: token, + ExpiresAt: newExpiresAt, + Message: "OTP has been resent to your email and phone", + }, nil +} + +func (s *memberSvc) sendRegistrationOTP( + ctx mycontext.Context, + registration *entity.MemberRegistration, +) error { + emailData := map[string]interface{}{ + "UserName": registration.Name, + "OTPCode": registration.OTP, + } + + err := s.notification.SendEmailTransactional(ctx, entity.SendEmailNotificationParam{ + Sender: "noreply@enaklo.co.id", + Recipient: registration.Email, + Subject: "Enaklo - Registration Verification Code", + TemplateName: "member_registration_otp", + TemplatePath: "templates/member_registration_otp.html", + Data: emailData, + }) + + if err != nil { + return err + } + + //if registration.Phone != "" { + // smsMessage := fmt.Sprintf("Your Enaklo registration code is: %s. Please provide this code to our staff to complete your registration.", registration.OTP) + // _ = s.notification.SendSMS(ctx, registration.Phone, smsMessage) + //} + + return nil +} + +func (s *memberSvc) sendWelcomeEmail( + ctx mycontext.Context, + customer *entity.Customer, +) error { + + welcomeData := map[string]interface{}{ + "UserName": customer.Name, + "MemberID": customer.CustomerID, + "PointsName": "PoinLo", + "PointsBalance": customer.Points, + "RedeemLink": "https://enaklo.co.id/redeem", + "CurrentDate": time.Now().Format("01-20006"), + } + + return s.notification.SendEmailTransactional(ctx, entity.SendEmailNotificationParam{ + Sender: "noreply@enaklo.co.id", + Recipient: customer.Email, + Subject: "Welcome to Enaklo Membership Program", + TemplateName: "welcome_member", + TemplatePath: "templates/welcome_member.html", + Data: welcomeData, + }) +} + +func generateOTP(length int) string { + rand.Seed(uint64(time.Now().Nanosecond())) + digits := "0123456789" + otp := "" + for i := 0; i < length; i++ { + otp += string(digits[rand.Intn(len(digits))]) + } + return otp +} diff --git a/internal/services/service.go b/internal/services/service.go index 39e5b82..8e7baca 100644 --- a/internal/services/service.go +++ b/internal/services/service.go @@ -6,6 +6,7 @@ import ( "enaklo-pos-be/internal/services/balance" "enaklo-pos-be/internal/services/discovery" service "enaklo-pos-be/internal/services/license" + "enaklo-pos-be/internal/services/member" "enaklo-pos-be/internal/services/order" "enaklo-pos-be/internal/services/oss" "enaklo-pos-be/internal/services/partner" @@ -42,9 +43,10 @@ type ServiceManagerImpl struct { Balance Balance DiscoverService DiscoverService - OrderV2Svc orderSvc.Service - CustomerV2Svc customerSvc.Service - ProductV2Svc productSvc.Service + OrderV2Svc orderSvc.Service + CustomerV2Svc customerSvc.Service + ProductV2Svc productSvc.Service + MemberRegistrationSvc member.RegistrationService } func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl) *ServiceManagerImpl { @@ -62,12 +64,14 @@ func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl) OSSSvc: oss.NewOSSService(repo.OSS), PartnerSvc: partner.NewPartnerService( repo.Partner, users.NewUserService(repo.User), repo.Trx, repo.Wallet, repo.User), - SiteSvc: site.NewSiteService(repo.Site, repo.User), - LicenseSvc: service.NewLicenseService(repo.License), - Transaction: transaction.New(repo.Transaction, repo.Wallet, repo.Trx), - Balance: balance.NewBalanceService(repo.Wallet, repo.Trx, repo.Crypto, &cfg.Withdraw, repo.Transaction), - DiscoverService: discovery.NewDiscoveryService(repo.Site, cfg.Discovery, repo.Product), - OrderV2Svc: orderSvc.New(repo.OrderRepo, productSvcV2, custSvcV2, repo.TransactionRepo, repo.Crypto, &cfg.Order, repo.EmailService), + SiteSvc: site.NewSiteService(repo.Site, repo.User), + LicenseSvc: service.NewLicenseService(repo.License), + Transaction: transaction.New(repo.Transaction, repo.Wallet, repo.Trx), + Balance: balance.NewBalanceService(repo.Wallet, repo.Trx, repo.Crypto, &cfg.Withdraw, repo.Transaction), + DiscoverService: discovery.NewDiscoveryService(repo.Site, cfg.Discovery, repo.Product), + OrderV2Svc: orderSvc.New(repo.OrderRepo, productSvcV2, custSvcV2, repo.TransactionRepo, repo.Crypto, &cfg.Order, repo.EmailService), + MemberRegistrationSvc: member.NewMemberRegistrationService(repo.MemberRepository, repo.EmailService, custSvcV2), + CustomerV2Svc: custSvcV2, } } diff --git a/internal/services/v2/customer/customer.go b/internal/services/v2/customer/customer.go index ea45ad3..50660b1 100644 --- a/internal/services/v2/customer/customer.go +++ b/internal/services/v2/customer/customer.go @@ -5,6 +5,7 @@ import ( "enaklo-pos-be/internal/common/mycontext" "enaklo-pos-be/internal/constants" "enaklo-pos-be/internal/entity" + "enaklo-pos-be/internal/utils" "github.com/pkg/errors" "go.uber.org/zap" "strings" @@ -16,12 +17,16 @@ type Repository interface { FindByPhone(ctx mycontext.Context, phone string) (*entity.Customer, error) FindByEmail(ctx mycontext.Context, email string) (*entity.Customer, error) AddPoints(ctx mycontext.Context, id int64, points int) error + FindSequence(ctx mycontext.Context, partnerID int64) (int64, error) + GetAllCustomers(ctx mycontext.Context, req entity.MemberSearch) (entity.MemberList, int, error) } type Service interface { ResolveCustomer(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (int64, error) AddPoints(ctx mycontext.Context, customerID int64, points int) error GetCustomer(ctx mycontext.Context, id int64) (*entity.Customer, error) + CustomerCheck(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (*entity.CustomerCheckResponse, error) + GetAllCustomers(ctx mycontext.Context, req *entity.MemberSearch) (*entity.MemberList, int, error) } type customerSvc struct { @@ -76,13 +81,20 @@ func (s *customerSvc) ResolveCustomer(ctx mycontext.Context, req *entity.Custome return 0, errors.New("customer name is required to create a new customer") } + lastSeq, err := s.repo.FindSequence(ctx, *ctx.GetPartnerID()) + if err != nil { + return 0, errors.New("failed to resolve customer sequence") + } + newCustomer := &entity.Customer{ - Name: req.Name, - Email: req.Email, - Phone: req.PhoneNumber, - Points: 0, - CreatedAt: constants.TimeNow(), - UpdatedAt: constants.TimeNow(), + Name: req.Name, + Email: req.Email, + Phone: req.PhoneNumber, + Points: 0, + CreatedAt: constants.TimeNow(), + UpdatedAt: constants.TimeNow(), + CustomerID: utils.GenerateMemberID(ctx, *ctx.GetPartnerID(), lastSeq), + BirthDate: req.BirthDate, } customer, err := s.repo.Create(ctx, newCustomer) @@ -115,3 +127,78 @@ func (s *customerSvc) GetCustomer(ctx mycontext.Context, id int64) (*entity.Cust return customer, nil } + +func (s *customerSvc) CustomerCheck(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (*entity.CustomerCheckResponse, error) { + logger.ContextLogger(ctx).Info("checking customer existence before registration", + zap.String("email", req.Email), + zap.String("phone", req.PhoneNumber)) + + if req.Email == "" && req.PhoneNumber == "" { + return nil, errors.New("email dan phone number is mandatory") + } + + response := &entity.CustomerCheckResponse{ + Exists: false, + Customer: nil, + } + + if req.PhoneNumber != "" { + customer, err := s.repo.FindByPhone(ctx, req.PhoneNumber) + if err != nil { + if !strings.Contains(err.Error(), "not found") { + logger.ContextLogger(ctx).Error("error checking customer by phone", zap.Error(err)) + return nil, errors.Wrap(err, "failed to find customer by phone") + } + } else { + logger.ContextLogger(ctx).Info("found existing customer by phone", + zap.Int64("customerId", customer.ID)) + + return &entity.CustomerCheckResponse{ + Exists: true, + Customer: customer, + Message: "Customer already exists with this phone number", + }, nil + } + } + + if req.Email != "" { + customer, err := s.repo.FindByEmail(ctx, req.Email) + if err != nil { + if !strings.Contains(err.Error(), "not found") { + logger.ContextLogger(ctx).Error("error checking customer by email", zap.Error(err)) + return nil, errors.Wrap(err, "failed to find customer by email") + } + } else { + logger.ContextLogger(ctx).Info("found existing customer by email", + zap.Int64("customerId", customer.ID)) + + return &entity.CustomerCheckResponse{ + Exists: true, + Customer: customer, + Message: "Customer already exists with this email", + }, nil + } + } + + return response, nil +} + +func (s *customerSvc) GetAllCustomers(ctx mycontext.Context, req *entity.MemberSearch) (*entity.MemberList, int, error) { + if req.Limit <= 0 { + req.Limit = 10 + } + if req.Offset < 0 { + req.Offset = 0 + } + + customers, totalCount, err := s.repo.GetAllCustomers(ctx, *req) + if err != nil { + logger.ContextLogger(ctx).Error("failed to retrieve customers", + zap.Error(err), + zap.String("search", req.Search), + ) + return nil, 0, errors.Wrap(err, "failed to get customers") + } + + return &customers, totalCount, nil +} diff --git a/internal/services/v2/member/member.go b/internal/services/v2/member/member.go new file mode 100644 index 0000000..363b41e --- /dev/null +++ b/internal/services/v2/member/member.go @@ -0,0 +1 @@ +package member diff --git a/internal/services/v2/order/execute_order.go b/internal/services/v2/order/execute_order.go index d8a43fa..d1804f7 100644 --- a/internal/services/v2/order/execute_order.go +++ b/internal/services/v2/order/execute_order.go @@ -136,6 +136,9 @@ func (s *orderSvc) sendTransactionReceipt(ctx mycontext.Context, order *entity.O emailData := map[string]interface{}{ "UserName": customer.Name, + "PointsName": "PoinLo", + "PointsBalance": "20", + "RedeemLink": "enaklo.co.id", "BranchName": branchName, "TransactionNumber": order.ID, "TransactionDate": transactionDate, @@ -148,9 +151,9 @@ func (s *orderSvc) sendTransactionReceipt(ctx mycontext.Context, order *entity.O return s.notification.SendEmailTransactional(ctx, entity.SendEmailNotificationParam{ Sender: "noreply@enaklo.co.id", Recipient: customer.Email, - Subject: "Enaklo - Resi Pembelian", - TemplateName: "transaction_receipt", - TemplatePath: "templates/transaction_receipt.html", + Subject: "Enaklo - Membership Statement", + TemplateName: "monthly_points", + TemplatePath: "templates/monthly_points.html", Data: emailData, }) } diff --git a/internal/utils/member_generator.go b/internal/utils/member_generator.go new file mode 100644 index 0000000..ba04719 --- /dev/null +++ b/internal/utils/member_generator.go @@ -0,0 +1,14 @@ +package utils + +import ( + "enaklo-pos-be/internal/common/mycontext" + "fmt" + "time" +) + +func GenerateMemberID(ctx mycontext.Context, branchID, sequence int64) string { + now := time.Now() + yearMonth := now.Format("200601") + + return fmt.Sprintf("%s%04d%04d", yearMonth, branchID, sequence) +} diff --git a/templates/member_registration_otp.html b/templates/member_registration_otp.html new file mode 100644 index 0000000..e6af64a --- /dev/null +++ b/templates/member_registration_otp.html @@ -0,0 +1,217 @@ + + + + + Kode Verifikasi Pendaftaran Member Enaklo + + + + + + + +
+
+ +
Kode Verifikasi Pendaftaran Member
+
+ Hai {{ .UserName }},

+ Terima kasih telah mendaftar sebagai member Enaklo. Berikan kode verifikasi berikut kepada staf kasir kami untuk menyelesaikan pendaftaran Anda: +
+
{{ .OTPCode }}
+
Kode ini berlaku selama 10 menit
+ +
+ Cara Menyelesaikan Pendaftaran:
+ 1. Tunjukkan email ini kepada staf kasir Enaklo
+ 2. Staf akan memverifikasi identitas Anda
+ 3. Staf akan memasukkan kode OTP ini ke sistem POS
+ 4. Pendaftaran member Anda akan segera aktif! +
+ +
+ Dengan menjadi member Enaklo, Anda akan menikmati berbagai keuntungan eksklusif: +
+ +
+
+ Kumpulkan poin dengan setiap pembelian +
+
+ Diskon khusus member pada menu pilihan +
+
+ Penawaran eksklusif saat ulang tahun Anda +
+
+ Akses awal ke menu baru dan promo spesial +
+
+ +
+
+ Jika Anda tidak sedang mendaftar sebagai member Enaklo, abaikan email ini atau hubungi tim support kami segera. +
+ +
+
+ + + \ No newline at end of file diff --git a/templates/welcome_member.html b/templates/welcome_member.html new file mode 100644 index 0000000..a725ce8 --- /dev/null +++ b/templates/welcome_member.html @@ -0,0 +1,331 @@ + + + + + Selamat Datang di Enaklo Membership + + + + + + + +
+ + + + +
+
+ + + + +
Selamat Datang di Program Membership Enaklo!
+ + +
+ Halo {{ .UserName }},

+ Terima kasih telah bergabung dengan program membership Enaklo. Kami senang Anda menjadi bagian dari keluarga kami! +
+ + + + + +
+ + + + +
+
{{ .UserName }}
+
ID Member: {{ .MemberID }}
+
+ + + + + +
+
{{ .PointsName }}
+
{{ .PointsBalance }}
+
+ Logo +
+
Member sejak {{ .CurrentDate }}
+
+
+ +
+ Sebagai member Enaklo, Anda berhak mendapatkan berbagai keuntungan eksklusif. +
+ + +
+ +
+
1
+
+ Kumpulkan {{ .PointsName }} + Dapatkan {{ .PointsName }} setiap kali Anda bertransaksi di Enaklo. Setiap Rp 1.000 = 1 {{ .PointsName }}. +
+
+ + +
+
2
+
+ Tukarkan dengan Reward Menarik + {{ .PointsName }} Anda dapat ditukarkan dengan berbagai menu favorit atau diskon khusus. +
+
+ + +
+
3
+
+ Penawaran Eksklusif + Dapatkan akses ke penawaran dan promosi khusus yang hanya tersedia untuk member. +
+
+ + +
+
4
+
+ Kejutan di Hari Spesial + Dapatkan hadiah spesial di hari ulang tahun Anda dan acara spesial lainnya. +
+
+
+ +
+ Untuk melihat {{ .PointsName }} Anda dan menukarkan hadiah, kunjungi halaman rewards kami. +
+ + + + + + +
+ Lihat Rewards +
+ +
+ +
+ Tunjukkan kartu member digital Anda (di email ini) atau sebutkan nomor telepon Anda saat bertransaksi di Enaklo untuk mengumpulkan {{ .PointsName }}. +
+ + +
+
+
+ + + \ No newline at end of file