Add Customer Discovery

This commit is contained in:
aditya.siregar 2024-08-03 20:01:25 +07:00
parent aeed6fde7b
commit 600d42d529
14 changed files with 620 additions and 19 deletions

View File

@ -32,6 +32,7 @@ type Config struct {
Brevo Brevo `mapstructure:"brevo"`
Email Email `mapstructure:"email"`
Withdraw Withdraw `mapstructure:"withdrawal"`
Discovery Discovery `mapstructure:"discovery"`
}
var (

17
config/discovery.go Normal file
View File

@ -0,0 +1,17 @@
package config
type Discovery struct {
ExploreDestinations []ExploreDestination `mapstructure:"explore_destinations"`
ExploreRegions []ExploreRegion `mapstructure:"explore_regions"`
}
type ExploreDestinations []ExploreDestination
type ExploreDestination struct {
Name string `mapstructure:"name"`
ImageURL string `mapstructure:"image_url"`
}
type ExploreRegion struct {
Name string `mapstructure:"name"`
}

View File

@ -25,7 +25,7 @@ postgresql:
max-idle-connections-in-second: 600
max-open-connections-in-second: 600
connection-max-life-time-in-second: 600
debug: true
debug: false
oss:
access_key_id: e50b31e5eddf63c0ZKB2
@ -54,3 +54,23 @@ email:
withdrawal:
platform_fee: 5000
discovery:
explore_destinations:
- name: "Jakarta"
image_url: "https://obs.eranyacloud.com/furtuna-dev/file/03c0b046-43ab-4d35-a743-6a173bc66b90-1722680749.png"
- name: "Banten"
image_url: "https://obs.eranyacloud.com/furtuna-dev/file/c8e7dd8a-17be-449f-afdc-0c07eda438ce-1722680809.png"
- name: "Yogyakarta"
image_url: "https://obs.eranyacloud.com/furtuna-dev/file/83b78c19-4c97-48c9-bc97-a7403e1c4eed-1722680828.png"
- name: "Jawa Barat"
image_url: "https://obs.eranyacloud.com/furtuna-dev/file/07c35ab1-3e20-4858-8d7d-b29517239dc3-1722680848.png"
- name: "Jawa Tengah"
image_url: "https://obs.eranyacloud.com/furtuna-dev/file/a1915a98-c2aa-4997-8e75-bd4e43789b0c-1722680874.png"
- name: "Jawa Timur"
image_url: "https://obs.eranyacloud.com/furtuna-dev/file/7b5d2b86-e8a8-4703-a153-c186021cf088-1722680894.png"
explore_regions:
- name: "Jawa"
- name: "Sumatera"
- name: "Kalimantan"
- name: "Sulawesi"

View File

@ -0,0 +1,39 @@
package entity
type DiscoverySearch struct {
Lat float64
Long float64
Name string
Region string
Discover string
Offset int
Limit int
Radius int
}
type DiscoverySearchResp struct {
ExploreRegions []ExploreRegion `json:"exploreRegions"`
ExploreDestinations []ExploreDestination `json:"exploreDestinations"`
MustVisit []MustVisit `json:"mustVisit"`
}
type ExploreRegion struct {
Name string `json:"name"`
}
type ExploreDestination struct {
Name string `json:"name"`
ImageURL string `json:"image_url"`
}
type MustVisit struct {
SiteID int64 `json:"site_id"`
Name string `json:"name"`
Location string `json:"location"`
Rating float64 `json:"rating"`
ReviewCount int `json:"reviewCount"`
Price float64 `json:"price"`
ImageURL string `json:"imageUrl"`
Region string `json:"region"`
Regency string `json:"regency"`
}

View File

@ -25,6 +25,10 @@ type Site struct {
CreatedBy int64 `gorm:"type:int;column:created_by"`
UpdatedBy int64 `gorm:"type:int;column:updated_by"`
Products []Product `gorm:"foreignKey:SiteID;constraint:OnDelete:CASCADE;"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
Region string `json:"region"`
Distance float64 `json:"distance"`
}
type SiteSearch struct {
@ -166,3 +170,34 @@ func (e *SiteCountDB) ToSiteCount() *SiteCount {
Count: e.Count,
}
}
type SiteProductInfo struct {
SiteID int64 `json:"site_id"`
SiteName string `json:"site_name"`
PartnerID int64 `json:"partner_id"`
Image string `json:"image"`
Address string `json:"address"`
LocationLink string `json:"location_link"`
Description string `json:"description"`
Highlight string `json:"highlight"`
ContactPerson string `json:"contact_person"`
TnC string `json:"tnc"`
AdditionalInfo string `json:"additional_info"`
Status string `json:"status"`
IsSeasonTicket bool `json:"is_season_ticket"`
IsDiscountActive bool `json:"is_discount_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Distance float64 `json:"distance"` // Calculated field
ProductID int64 `json:"product_id"`
ProductName string `json:"product_name"`
ProductType string `json:"product_type"`
ProductPrice float64 `json:"product_price"`
IsWeekendTicket bool `json:"is_weekend_ticket"`
ProductStatus string `json:"product_status"`
ProductDescription string `json:"product_description"`
Region string `json:"region"`
Regency string `json:"regency"`
}

View File

@ -0,0 +1,134 @@
package discovery
import (
"furtuna-be/internal/entity"
"github.com/gin-gonic/gin"
"net/http"
"furtuna-be/internal/common/errors"
"furtuna-be/internal/handlers/request"
"furtuna-be/internal/handlers/response"
"furtuna-be/internal/services"
)
type Handler struct {
service services.DiscoverService
}
func (h *Handler) Route(group *gin.RouterGroup, jwt gin.HandlerFunc) {
route := group.Group("/discovery")
route.GET("/home", h.DisoveryHome)
route.GET("/search", h.DisoverySearch)
}
func NewHandler(service services.DiscoverService) *Handler {
return &Handler{
service: service,
}
}
func (h *Handler) DisoveryHome(c *gin.Context) {
var req request.DiscoveryHomeParam
if err := c.ShouldBindQuery(&req); err != nil {
response.ErrorWrapper(c, errors.ErrorBadRequest)
return
}
res, err := h.service.Home(c.Request.Context(), req.ToEntity())
if err != nil {
response.ErrorWrapper(c, err)
return
}
c.JSON(http.StatusOK, response.BaseResponse{
Success: true,
Status: http.StatusOK,
Data: ConvertEntityToResponse(res),
})
}
func (h *Handler) DisoverySearch(c *gin.Context) {
var req request.DiscoveryHomeParam
if err := c.ShouldBindQuery(&req); err != nil {
response.ErrorWrapper(c, errors.ErrorBadRequest)
return
}
res, total, err := h.service.Search(c.Request.Context(), req.ToEntity())
if err != nil {
response.ErrorWrapper(c, err)
return
}
c.JSON(http.StatusOK, response.BaseResponse{
Success: true,
Status: http.StatusOK,
Data: ConvertEntityToSearchResponse(res, total, req),
})
}
func ConvertEntityToResponse(entityResp *entity.DiscoverySearchResp) *response.ExploreResponse {
// Convert ExploreRegions
exploreRegions := make([]response.Region, len(entityResp.ExploreRegions))
for i, region := range entityResp.ExploreRegions {
exploreRegions[i] = response.Region{
Name: region.Name,
}
}
// Convert ExploreDestinations
exploreDestinations := make([]response.Destination, len(entityResp.ExploreDestinations))
for i, destination := range entityResp.ExploreDestinations {
exploreDestinations[i] = response.Destination{
Name: destination.Name,
ImageURL: destination.ImageURL,
}
}
mustVisit := make([]response.MustVisit, len(entityResp.MustVisit))
for i, mv := range entityResp.MustVisit {
mustVisit[i] = response.MustVisit{
Name: mv.Name,
Region: mv.Region,
Rating: mv.Rating,
ReviewCount: mv.ReviewCount,
Price: mv.Price,
ImageURL: mv.ImageURL,
SiteID: mv.SiteID,
Regency: mv.Regency,
}
}
return &response.ExploreResponse{
ExploreRegions: exploreRegions,
ExploreDestinations: exploreDestinations,
MustVisit: mustVisit,
}
}
func ConvertEntityToSearchResponse(entityResp *entity.DiscoverySearchResp, total int64, req request.DiscoveryHomeParam) *response.SearchResponse {
data := make([]response.SiteSeach, len(entityResp.MustVisit))
for i, mv := range entityResp.MustVisit {
data[i] = response.SiteSeach{
Name: mv.Name,
Region: mv.Region,
Rating: mv.Rating,
ReviewCount: mv.ReviewCount,
Price: mv.Price,
ImageURL: mv.ImageURL,
SiteID: mv.SiteID,
Regency: mv.Regency,
}
}
return &response.SearchResponse{
Data: data,
Total: int(total),
Limit: req.Limit,
Offset: req.Offset,
}
}

View File

@ -0,0 +1,33 @@
package request
import (
"furtuna-be/internal/entity"
)
type DiscoveryHomeParam struct {
Lat float64 `form:"lat" json:"lat" example:"10"`
Long float64 `form:"long" json:"long" example:"0"`
Name string `form:"name" json:"name" example:"0"`
Region string `form:"region" json:"region" example:"0"`
Radius int `form:"radius" json:"radius" example:"0"`
Limit int `form:"limit" json:"limit" example:"0"`
Offset int `form:"offset" json:"offset" example:"0"`
Discover string `form:"discover" json:"discover" example:"0"`
}
func (d *DiscoveryHomeParam) ToEntity() *entity.DiscoverySearch {
if d.Limit == 0 {
d.Limit = 10
}
return &entity.DiscoverySearch{
Lat: d.Lat,
Long: d.Long,
Name: d.Name,
Region: d.Region,
Radius: d.Radius,
Limit: d.Limit,
Offset: d.Offset,
Discover: d.Discover,
}
}

View File

@ -0,0 +1,49 @@
package response
type ExploreResponse struct {
ExploreRegions []Region `json:"exploreRegions"`
ExploreDestinations []Destination `json:"exploreDestinations"`
MustVisit []MustVisit `json:"mustVisit"`
}
type CurrentLocation struct {
City string `json:"city"`
}
type Region struct {
Name string `json:"name"`
}
type Destination struct {
Name string `json:"name"`
ImageURL string `json:"image_url"`
}
type MustVisit struct {
SiteID int64 `json:"site_id"`
Name string `json:"name"`
Region string `json:"region"`
Rating float64 `json:"rating"`
ReviewCount int `json:"reviewCount"`
Price float64 `json:"price"`
ImageURL string `json:"imageUrl"`
Regency string `json:"regency"`
}
type SearchResponse struct {
Offset int `json:"offset"`
Total int `json:"total"`
Limit int `json:"limit"`
Data []SiteSeach `json:"data"`
}
type SiteSeach struct {
SiteID int64 `json:"site_id"`
Name string `json:"name"`
Region string `json:"region"`
Rating float64 `json:"rating"`
ReviewCount int `json:"reviewCount"`
Price float64 `json:"price"`
ImageURL string `json:"imageUrl"`
Regency string `json:"regency"`
}

View File

@ -172,6 +172,8 @@ type SiteRepository interface {
GetAll(ctx context.Context, req entity.SiteSearch) (entity.SiteList, int, error)
Delete(ctx context.Context, id int64) error
Count(ctx mycontext.Context, req entity.SiteSearch) (*entity.SiteCountDB, error)
GetNearestSites(ctx context.Context, latitude, longitude, radius float64) ([]entity.SiteProductInfo, error)
SearchSites(ctx context.Context, search *entity.DiscoverySearch) ([]entity.SiteProductInfo, int64, error)
}
type TransactionManager interface {

View File

@ -156,3 +156,119 @@ func (r *SiteRepository) Count(ctx mycontext.Context, req entity.SiteSearch) (*e
return count, nil
}
func (r *SiteRepository) GetNearestSites(ctx context.Context, latitude, longitude, radius float64) ([]entity.SiteProductInfo, error) {
const limit = 5
var siteProducts []entity.SiteProductInfo
distanceQuery := `
(6371 * acos(cos(radians(?)) * cos(radians(latitude)) * cos(radians(longitude) - radians(?)) + sin(radians(?)) * sin(radians(latitude))))
`
// Primary query for sites within the radius
err := r.db.WithContext(ctx).Raw(`
SELECT s.id AS site_id, s.name AS site_name, s.region, s.regency, s.partner_id, s.image, s.address, s.location_link, s.description,
s.highlight, s.contact_person, s.tnc, s.additional_info, s.status, s.is_season_ticket, s.is_discount_active,
s.latitude, s.longitude, s.created_at, s.updated_at,
`+distanceQuery+` AS distance,
p.id AS product_id, p.name AS product_name, p.type AS product_type, p.price AS product_price,
p.is_weekend_ticket, p.is_season_ticket, p.status AS product_status, p.description AS product_description
FROM sites s
LEFT JOIN (
SELECT *, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY price ASC) AS rn
FROM products
) p ON s.id = p.site_id AND p.rn = 1
WHERE `+distanceQuery+` < ?
ORDER BY distance
LIMIT ?`,
latitude, longitude, latitude, latitude, longitude, latitude, radius, limit).Scan(&siteProducts).Error
if err != nil {
return nil, err
}
// If fewer than 5 sites found, fetch additional ones regardless of distance
if len(siteProducts) < limit {
additionalLimit := limit - len(siteProducts)
err = r.db.WithContext(ctx).Raw(`
SELECT s.id AS site_id, s.name AS site_name, s.region, s.regency, s.partner_id, s.image, s.address, s.location_link, s.description,
s.highlight, s.contact_person, s.tnc, s.additional_info, s.status, s.is_season_ticket, s.is_discount_active,
s.latitude, s.longitude, s.created_at, s.updated_at,
`+distanceQuery+` AS distance,
p.id AS product_id, p.name AS product_name, p.type AS product_type, p.price AS product_price,
p.is_weekend_ticket, p.is_season_ticket, p.status AS product_status, p.description AS product_description
FROM sites s
LEFT JOIN (
SELECT *, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY price ASC) AS rn
FROM products
) p ON s.id = p.site_id AND p.rn = 1
ORDER BY distance
LIMIT ?`,
latitude, longitude, latitude, additionalLimit).Scan(&siteProducts).Error
if err != nil {
return nil, err
}
}
return siteProducts, nil
}
func (r *SiteRepository) SearchSites(ctx context.Context, search *entity.DiscoverySearch) ([]entity.SiteProductInfo, int64, error) {
var siteProducts []entity.SiteProductInfo
var total int64
// Adding wildcard for partial matching
searchName := "%" + search.Name + "%"
// Base conditions and parameters
conditions := "s.name ILIKE ?"
params := []interface{}{searchName}
// Add region filtering if region is provided
if search.Region != "" {
conditions += " AND s.region = ?"
params = append(params, search.Region)
}
if search.Discover != "" {
conditions += " AND s.address ILIKE ?"
params = append(params, "%"+search.Discover+"%")
}
// Count query to get the total number of matching records
countQuery := `
SELECT COUNT(*)
FROM sites s
WHERE ` + conditions
err := r.db.WithContext(ctx).Raw(countQuery, params...).Scan(&total).Error
if err != nil {
return nil, 0, err
}
// Add limit and offset for the data query
dataParams := append(params, search.Limit, search.Offset)
// Primary query for sites matching name and region, with pagination
dataQuery := `
SELECT s.id AS site_id, s.name AS site_name, s.region, s.regency, s.partner_id, s.image, s.address, s.location_link, s.description,
s.highlight, s.contact_person, s.tnc, s.additional_info, s.status, s.is_season_ticket, s.is_discount_active,
s.latitude, s.longitude, s.created_at, s.updated_at,
p.id AS product_id, p.name AS product_name, p.type AS product_type, p.price AS product_price,
p.is_weekend_ticket, p.is_season_ticket, p.status AS product_status, p.description AS product_description
FROM sites s
LEFT JOIN (
SELECT *, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY price ASC) AS rn
FROM products
) p ON s.id = p.site_id AND p.rn = 1
WHERE ` + conditions + `
ORDER BY s.name
LIMIT ? OFFSET ?
`
err = r.db.WithContext(ctx).Raw(dataQuery, dataParams...).Scan(&siteProducts).Error
if err != nil {
return nil, 0, err
}
return siteProducts, total, nil
}

View File

@ -0,0 +1,25 @@
package routes
import (
"furtuna-be/internal/handlers/http/discovery"
"furtuna-be/internal/middlewares"
"furtuna-be/internal/app"
"furtuna-be/internal/repository"
"furtuna-be/internal/services"
)
func RegisterCustomerRoutes(app *app.Server, serviceManager *services.ServiceManagerImpl,
repoManager *repository.RepoManagerImpl) {
approute := app.Group("/api/v1/customer")
authMiddleware := middlewares.AuthorizationMiddleware(repoManager.Crypto)
serverRoutes := []HTTPHandlerRoutes{
discovery.NewHandler(serviceManager.DiscoverService),
}
for _, handler := range serverRoutes {
handler.Route(approute, authMiddleware)
}
}

View File

@ -0,0 +1,121 @@
package discovery
import (
"context"
"furtuna-be/config"
"furtuna-be/internal/entity"
"furtuna-be/internal/repository"
)
const (
defaultLatitude = -6.2088
defaultLongitude = 106.8456
radius = 10000
)
type DiscoveryService struct {
repo repository.SiteRepository
cfg config.Discovery
}
func NewDiscoveryService(repo repository.SiteRepository, cfg config.Discovery) *DiscoveryService {
return &DiscoveryService{
repo: repo,
cfg: cfg,
}
}
func (s *DiscoveryService) Home(ctx context.Context, search *entity.DiscoverySearch) (*entity.DiscoverySearchResp, error) {
if search.Lat == 0 || search.Long == 0 {
search.Lat = defaultLatitude
search.Long = defaultLongitude
}
siteProducts, err := s.repo.GetNearestSites(ctx, search.Lat, search.Long, radius)
if err != nil {
return nil, err
}
exploreDestinations := []entity.ExploreDestination{}
for _, exploreDestination := range s.cfg.ExploreDestinations {
exploreDestinations = append(exploreDestinations, entity.ExploreDestination{
Name: exploreDestination.Name,
ImageURL: exploreDestination.ImageURL,
})
}
exploreRegions := []entity.ExploreRegion{}
for _, exploreRegion := range s.cfg.ExploreRegions {
exploreRegions = append(exploreRegions, entity.ExploreRegion{
Name: exploreRegion.Name,
})
}
mustVisits := []entity.MustVisit{}
for _, siteProduct := range siteProducts {
mustVisits = append(mustVisits, entity.MustVisit{
Name: siteProduct.SiteName,
Price: siteProduct.ProductPrice,
Region: siteProduct.Region,
SiteID: siteProduct.SiteID,
ImageURL: siteProduct.Image,
})
}
response := &entity.DiscoverySearchResp{
ExploreRegions: exploreRegions,
ExploreDestinations: exploreDestinations,
MustVisit: mustVisits,
}
return response, nil
}
func (s *DiscoveryService) Search(ctx context.Context, search *entity.DiscoverySearch) (*entity.DiscoverySearchResp, int64, error) {
if search.Lat == 0 || search.Long == 0 {
search.Lat = defaultLatitude
search.Long = defaultLongitude
search.Radius = radius
}
siteProducts, total, err := s.repo.SearchSites(ctx, search)
if err != nil {
return nil, 0, err
}
exploreDestinations := []entity.ExploreDestination{}
for _, exploreDestination := range s.cfg.ExploreDestinations {
exploreDestinations = append(exploreDestinations, entity.ExploreDestination{
Name: exploreDestination.Name,
ImageURL: exploreDestination.ImageURL,
})
}
exploreRegions := []entity.ExploreRegion{}
for _, exploreRegion := range s.cfg.ExploreRegions {
exploreRegions = append(exploreRegions, entity.ExploreRegion{
Name: exploreRegion.Name,
})
}
mustVisits := []entity.MustVisit{}
for _, siteProduct := range siteProducts {
mustVisits = append(mustVisits, entity.MustVisit{
Name: siteProduct.SiteName,
Price: siteProduct.ProductPrice,
Region: siteProduct.Region,
SiteID: siteProduct.SiteID,
ImageURL: siteProduct.Image,
Regency: siteProduct.Regency,
})
}
response := &entity.DiscoverySearchResp{
ExploreRegions: exploreRegions,
ExploreDestinations: exploreDestinations,
MustVisit: mustVisits,
}
return response, total, nil
}

View File

@ -5,6 +5,7 @@ import (
"furtuna-be/internal/common/mycontext"
"furtuna-be/internal/services/balance"
"furtuna-be/internal/services/branch"
"furtuna-be/internal/services/discovery"
service "furtuna-be/internal/services/license"
"furtuna-be/internal/services/order"
"furtuna-be/internal/services/oss"
@ -25,19 +26,20 @@ import (
)
type ServiceManagerImpl struct {
AuthSvc Auth
EventSvc Event
UserSvc User
BranchSvc Branch
StudioSvc Studio
ProductSvc Product
OrderSvc Order
OSSSvc OSSService
PartnerSvc Partner
SiteSvc Site
LicenseSvc License
Transaction Transaction
Balance Balance
AuthSvc Auth
EventSvc Event
UserSvc User
BranchSvc Branch
StudioSvc Studio
ProductSvc Product
OrderSvc Order
OSSSvc OSSService
PartnerSvc Partner
SiteSvc Site
LicenseSvc License
Transaction Transaction
Balance Balance
DiscoverService DiscoverService
}
func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl) *ServiceManagerImpl {
@ -52,10 +54,11 @@ func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl)
OSSSvc: oss.NewOSSService(repo.OSS),
PartnerSvc: partner.NewPartnerService(
repo.Partner, users.NewUserService(repo.User, repo.Branch), repo.Trx, repo.Wallet),
SiteSvc: site.NewSiteService(repo.Site),
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),
SiteSvc: site.NewSiteService(repo.Site),
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),
}
}
@ -156,3 +159,8 @@ type Balance interface {
WithdrawInquiry(ctx context.Context, req *entity.BalanceWithdrawInquiry) (*entity.BalanceWithdrawInquiryResponse, error)
WithdrawExecute(ctx mycontext.Context, req *entity.WalletWithdrawRequest) (*entity.WalletWithdrawResponse, error)
}
type DiscoverService interface {
Home(ctx context.Context, search *entity.DiscoverySearch) (*entity.DiscoverySearchResp, error)
Search(ctx context.Context, search *entity.DiscoverySearch) (*entity.DiscoverySearchResp, int64, error)
}

View File

@ -32,6 +32,7 @@ func main() {
routes.RegisterPublicRoutes(server, service, repo)
routes.RegisterPrivateRoutes(server, service, repo)
routes.RegisterCustomerRoutes(server, service, repo)
server.StartScheduler()