Update Member

This commit is contained in:
aditya.siregar
2025-03-15 15:51:18 +08:00
parent 18003313dd
commit c41826bb1b
29 changed files with 1840 additions and 65 deletions
+19 -17
View File
@@ -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 {
+12
View File
@@ -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)
}
+7
View File
@@ -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
}
+9
View File
@@ -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
}
+70
View File
@@ -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"`
}
+1 -1
View File
@@ -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"`
+2
View File
@@ -49,6 +49,8 @@ type Customer struct {
SiteName string
PartnerName string
ResetPassword bool
CustomerID string
BirthDate time.Time
}
type AuthenticateUser struct {
+81
View File
@@ -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,
},
})
}
+154
View File
@@ -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),
})
}
+34
View File
@@ -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"`
}
+35
View File
@@ -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
}
+111
View File
@@ -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
}
+120 -14
View File
@@ -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
}
+138
View File
@@ -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(&registrationDB).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(&registrationDB).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(&registrationDB)
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,
}
}
+20 -7
View File
@@ -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"
}
+25
View File
@@ -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"
}
+10 -8
View File
@@ -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),
}
}
+2
View File
@@ -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 {
+51
View File
@@ -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,
}
}
@@ -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
}
+13 -9
View File
@@ -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,
}
}
+93 -6
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
package member
+6 -3
View File
@@ -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,
})
}
+14
View File
@@ -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)
}