Main #33
+26
-14
@@ -160,6 +160,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
services.pointPaymentService,
|
||||
services.customerOrderPaymentService,
|
||||
services.customerWalletService,
|
||||
services.customerDeviceService,
|
||||
a.redisClient,
|
||||
)
|
||||
|
||||
@@ -396,10 +397,14 @@ type processors struct {
|
||||
walletExchangeProcessor *processor.WalletExchangeProcessor
|
||||
walletTransferProcessor *processor.WalletTransferProcessor
|
||||
walletTraceProcessor *processor.WalletTraceProcessor
|
||||
customerDeviceProcessor *processor.CustomerDeviceProcessor
|
||||
}
|
||||
|
||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||
fileClient := client.NewFileClient(cfg.S3Config)
|
||||
fcmClient := buildFCMClient(cfg)
|
||||
// Devices of the customer app, for pushes through FCM
|
||||
customerDeviceProcessor := processor.NewCustomerDeviceProcessor(repository.NewCustomerDeviceRepository(a.db), fcmClient)
|
||||
fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
|
||||
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
|
||||
// Customer PIN (docs/prd-point-coin.md F11)
|
||||
@@ -417,8 +422,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor)
|
||||
// Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4)
|
||||
walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
// Send EnakPoint or EnakCoin to another customer; the recipient is told by WhatsApp (docs/prd-point-coin.md F5)
|
||||
walletTransferProcessor := processor.NewWalletTransferProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, otpProcessor)
|
||||
// Send EnakPoint or EnakCoin to another customer; the recipient gets a push through FCM (docs/prd-point-coin.md F5)
|
||||
walletTransferProcessor := processor.NewWalletTransferProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, customerDeviceProcessor)
|
||||
|
||||
return &processors{
|
||||
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
|
||||
@@ -461,7 +466,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
fileClient: fileClient,
|
||||
inventoryMovementService: inventoryMovementService,
|
||||
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
|
||||
notificationProcessor: buildNotificationProcessor(cfg, repos),
|
||||
notificationProcessor: buildNotificationProcessor(repos, fcmClient),
|
||||
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
|
||||
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
|
||||
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
|
||||
@@ -474,6 +479,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
walletExchangeProcessor: walletExchangeProcessor,
|
||||
walletTransferProcessor: walletTransferProcessor,
|
||||
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
|
||||
customerDeviceProcessor: customerDeviceProcessor,
|
||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
|
||||
}
|
||||
}
|
||||
@@ -523,6 +529,7 @@ type services struct {
|
||||
pointPaymentService *service.PointPaymentServiceImpl
|
||||
customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl
|
||||
customerWalletService *service.CustomerWalletServiceImpl
|
||||
customerDeviceService *service.CustomerDeviceServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -610,6 +617,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor),
|
||||
customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor),
|
||||
customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor),
|
||||
customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,19 +701,23 @@ func (a *App) initValidators() *validators {
|
||||
}
|
||||
}
|
||||
|
||||
// buildFCMClient connects to FCM, or returns nil when it is not configured or fails to
|
||||
// start. FCM failing is non-fatal: pushes are skipped and notifications are still
|
||||
// persisted.
|
||||
func buildFCMClient(cfg *config.Config) client.FCMClient {
|
||||
if cfg.FCM.CredentialsFile == "" {
|
||||
return nil
|
||||
}
|
||||
fcmClient, err := client.NewFCMClient(&cfg.FCM)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return fcmClient
|
||||
}
|
||||
|
||||
// buildNotificationProcessor creates the notification processor with FCM integration.
|
||||
// If FCM is not configured, it returns a processor with a nil FCM client (FCM dispatch will be skipped).
|
||||
func buildNotificationProcessor(cfg *config.Config, repos *repositories) *processor.NotificationProcessorImpl {
|
||||
var fcmClient client.FCMClient
|
||||
if cfg.FCM.CredentialsFile != "" {
|
||||
var err error
|
||||
fcmClient, err = client.NewFCMClient(&cfg.FCM)
|
||||
if err != nil {
|
||||
// FCM init failure is non-fatal; notifications will still be persisted.
|
||||
fcmClient = nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildNotificationProcessor(repos *repositories, fcmClient client.FCMClient) *processor.NotificationProcessorImpl {
|
||||
return processor.NewNotificationProcessor(
|
||||
repos.notificationRepo,
|
||||
repos.notificationReceiverRepo,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package contract
|
||||
|
||||
// RegisterCustomerDeviceRequest is PUT /customer/devices: the FCM token the customer
|
||||
// app got for this device. Send it after login and whenever FCM refreshes the token.
|
||||
type RegisterCustomerDeviceRequest struct {
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
FCMToken string `json:"fcm_token" binding:"required"`
|
||||
// android, ios or web.
|
||||
Platform *string `json:"platform"`
|
||||
AppVersion *string `json:"app_version"`
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerDeviceHandler serves /customer/devices, where the customer app registers
|
||||
// the FCM token of each device it runs on.
|
||||
type CustomerDeviceHandler struct {
|
||||
devices service.CustomerDeviceService
|
||||
}
|
||||
|
||||
func NewCustomerDeviceHandler(devices service.CustomerDeviceService) *CustomerDeviceHandler {
|
||||
return &CustomerDeviceHandler{devices: devices}
|
||||
}
|
||||
|
||||
// Register is PUT /customer/devices.
|
||||
func (h *CustomerDeviceHandler) Register(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Register")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RegisterCustomerDeviceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "device_id and fcm_token are required"),
|
||||
}), "CustomerDeviceHandler::Register")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.devices.Register(c.Request.Context(), customerID, &req), "CustomerDeviceHandler::Register")
|
||||
}
|
||||
|
||||
// Unregister is DELETE /customer/devices/:device_id, for logout.
|
||||
func (h *CustomerDeviceHandler) Unregister(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Unregister")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.devices.Unregister(c.Request.Context(), customerID, c.Param("device_id")), "CustomerDeviceHandler::Unregister")
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrInvalidCustomerDevice wraps every rejection of a device registration.
|
||||
var ErrInvalidCustomerDevice = errors.New("invalid customer device")
|
||||
|
||||
type customerPushSender interface {
|
||||
SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error
|
||||
}
|
||||
|
||||
// CustomerDeviceProcessor keeps the customer app's FCM tokens and sends push
|
||||
// notifications to a customer's devices.
|
||||
type CustomerDeviceProcessor struct {
|
||||
repo repository.CustomerDeviceRepository
|
||||
// Nil when FCM is not configured; notifications are then skipped.
|
||||
fcm customerPushSender
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewCustomerDeviceProcessor(repo repository.CustomerDeviceRepository, fcm customerPushSender) *CustomerDeviceProcessor {
|
||||
return &CustomerDeviceProcessor{repo: repo, fcm: fcm, now: time.Now}
|
||||
}
|
||||
|
||||
// Register saves the FCM token the app got for this device. The app calls it after
|
||||
// login and whenever FCM gives it a new token.
|
||||
func (p *CustomerDeviceProcessor) Register(ctx context.Context, device repository.CustomerDevice) error {
|
||||
device.DeviceID = strings.TrimSpace(device.DeviceID)
|
||||
device.FCMToken = strings.TrimSpace(device.FCMToken)
|
||||
switch {
|
||||
case device.DeviceID == "" || len(device.DeviceID) > 255:
|
||||
return fmt.Errorf("%w: device_id is required, at most 255 characters", ErrInvalidCustomerDevice)
|
||||
case device.FCMToken == "" || len(device.FCMToken) > 512:
|
||||
return fmt.Errorf("%w: fcm_token is required, at most 512 characters", ErrInvalidCustomerDevice)
|
||||
}
|
||||
if device.Platform != nil {
|
||||
platform := strings.ToLower(strings.TrimSpace(*device.Platform))
|
||||
if platform != "android" && platform != "ios" && platform != "web" {
|
||||
return fmt.Errorf("%w: platform must be android, ios or web", ErrInvalidCustomerDevice)
|
||||
}
|
||||
device.Platform = &platform
|
||||
}
|
||||
return p.repo.Register(ctx, device, p.now())
|
||||
}
|
||||
|
||||
// Unregister forgets a device, so it stops getting the customer's notifications.
|
||||
func (p *CustomerDeviceProcessor) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error {
|
||||
return p.repo.Unregister(ctx, customerID, strings.TrimSpace(deviceID))
|
||||
}
|
||||
|
||||
// Notify pushes a notification to every device of the customer through FCM. A
|
||||
// customer without a registered device gets nothing, which is not an error.
|
||||
func (p *CustomerDeviceProcessor) Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error {
|
||||
if p.fcm == nil {
|
||||
logger.NonContext.Info(fmt.Sprintf("FCM is not configured; not notifying customer %s", customerID))
|
||||
return nil
|
||||
}
|
||||
tokens, err := p.repo.ListTokens(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
return p.fcm.SendMulticastNotification(ctx, tokens, title, body, data)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// customerDeviceRepoFake keeps devices the way the table does: one row per customer
|
||||
// and device, and a token belongs to one row only.
|
||||
type customerDeviceRepoFake struct{ devices []repository.CustomerDevice }
|
||||
|
||||
func (f *customerDeviceRepoFake) Register(_ context.Context, d repository.CustomerDevice, _ time.Time) error {
|
||||
kept := f.devices[:0]
|
||||
for _, existing := range f.devices {
|
||||
sameRow := existing.CustomerID == d.CustomerID && existing.DeviceID == d.DeviceID
|
||||
if !sameRow && existing.FCMToken != d.FCMToken {
|
||||
kept = append(kept, existing)
|
||||
}
|
||||
}
|
||||
f.devices = append(kept, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *customerDeviceRepoFake) Unregister(_ context.Context, customerID uuid.UUID, deviceID string) error {
|
||||
kept := f.devices[:0]
|
||||
for _, d := range f.devices {
|
||||
if d.CustomerID != customerID || d.DeviceID != deviceID {
|
||||
kept = append(kept, d)
|
||||
}
|
||||
}
|
||||
f.devices = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *customerDeviceRepoFake) ListTokens(_ context.Context, customerID uuid.UUID) ([]string, error) {
|
||||
var tokens []string
|
||||
for _, d := range f.devices {
|
||||
if d.CustomerID == customerID {
|
||||
tokens = append(tokens, d.FCMToken)
|
||||
}
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
type fcmFake struct {
|
||||
tokens [][]string
|
||||
title string
|
||||
body string
|
||||
data map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fcmFake) SendMulticastNotification(_ context.Context, tokens []string, title, body string, data map[string]string) error {
|
||||
f.tokens = append(f.tokens, tokens)
|
||||
f.title, f.body, f.data = title, body, data
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestCustomerDevice_NotifiesEveryDeviceOfTheCustomer(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
|
||||
p := NewCustomerDeviceProcessor(repo, fcm)
|
||||
ctx := context.Background()
|
||||
budi, anita := uuid.New(), uuid.New()
|
||||
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1", Platform: ptr("Android")}))
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "tablet", FCMToken: "t2"}))
|
||||
// A refreshed token replaces the old one of the same device.
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1b"}))
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "t3"}))
|
||||
|
||||
require.NoError(t, p.Notify(ctx, budi, "EnakPoint masuk", "Kamu menerima 10 EnakPoint", map[string]string{"type": "X"}))
|
||||
assert.Equal(t, [][]string{{"t2", "t1b"}}, fcm.tokens)
|
||||
assert.Equal(t, "EnakPoint masuk", fcm.title)
|
||||
assert.Equal(t, map[string]string{"type": "X"}, fcm.data)
|
||||
}
|
||||
|
||||
func TestCustomerDevice_TokenMovesToWhoeverLogsInOnThePhone(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
|
||||
p := NewCustomerDeviceProcessor(repo, fcm)
|
||||
ctx := context.Background()
|
||||
budi, anita := uuid.New(), uuid.New()
|
||||
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "shared"}))
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "shared"}))
|
||||
|
||||
// Budi's notifications no longer reach the phone Anita is now logged in on.
|
||||
require.NoError(t, p.Notify(ctx, budi, "t", "b", nil))
|
||||
require.NoError(t, p.Notify(ctx, anita, "t", "b", nil))
|
||||
assert.Equal(t, [][]string{{"shared"}}, fcm.tokens)
|
||||
}
|
||||
|
||||
func TestCustomerDevice_NothingToSend(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
|
||||
ctx := context.Background()
|
||||
customer := uuid.New()
|
||||
|
||||
// No device registered, or logged out: nothing is sent and nothing fails.
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil))
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"}))
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Unregister(ctx, customer, "phone"))
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil))
|
||||
assert.Empty(t, fcm.tokens)
|
||||
|
||||
// FCM not configured.
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, nil).Notify(ctx, customer, "t", "b", nil))
|
||||
}
|
||||
|
||||
func TestCustomerDevice_FCMFailureIsReported(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{err: errors.New("unavailable")}
|
||||
p := NewCustomerDeviceProcessor(repo, fcm)
|
||||
ctx := context.Background()
|
||||
customer := uuid.New()
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"}))
|
||||
|
||||
assert.Error(t, p.Notify(ctx, customer, "t", "b", nil))
|
||||
}
|
||||
|
||||
func TestCustomerDevice_RejectsIncompleteRegistrations(t *testing.T) {
|
||||
p := NewCustomerDeviceProcessor(&customerDeviceRepoFake{}, nil)
|
||||
ctx := context.Background()
|
||||
for name, d := range map[string]repository.CustomerDevice{
|
||||
"no device": {FCMToken: "t"},
|
||||
"no token": {DeviceID: "phone", FCMToken: " "},
|
||||
"bad platform": {DeviceID: "phone", FCMToken: "t", Platform: ptr("symbian")},
|
||||
} {
|
||||
assert.ErrorIs(t, p.Register(ctx, d), ErrInvalidCustomerDevice, name)
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,8 @@ func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor {
|
||||
return p
|
||||
}
|
||||
|
||||
func (e *walletMoveEnv) transfers(messenger walletMessenger) *WalletTransferProcessor {
|
||||
p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, messenger)
|
||||
func (e *walletMoveEnv) transfers(notifier customerNotifier) *WalletTransferProcessor {
|
||||
p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, notifier)
|
||||
p.now = func() time.Time { return e.now }
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -21,12 +22,16 @@ import (
|
||||
// check does not reveal who uses the app elsewhere.
|
||||
var ErrWalletRecipientNotFound = errors.New("no customer of this organization has that phone number")
|
||||
|
||||
// walletMessenger tells a customer something happened to their wallet. There is no
|
||||
// push channel to customers yet, so the app sends it by WhatsApp.
|
||||
type walletMessenger interface {
|
||||
SendWhatsAppMessage(phoneNumber, message string) error
|
||||
// customerNotifier pushes a notification to a customer's app through FCM.
|
||||
// CustomerDeviceProcessor is one.
|
||||
type customerNotifier interface {
|
||||
Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error
|
||||
}
|
||||
|
||||
// NotificationTypeWalletTransferIn is the data type of the push a transfer recipient
|
||||
// gets, so the app can open the transaction.
|
||||
const NotificationTypeWalletTransferIn = "WALLET_TRANSFER_IN"
|
||||
|
||||
// WalletTransferProcessor sends EnakPoint or EnakCoin from one customer to another in
|
||||
// the same organization (docs/prd-point-coin.md F5).
|
||||
type WalletTransferProcessor struct {
|
||||
@@ -36,12 +41,12 @@ type WalletTransferProcessor struct {
|
||||
pins pinVerifier
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
messenger walletMessenger
|
||||
notifier customerNotifier
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWalletTransferProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner, messenger walletMessenger) *WalletTransferProcessor {
|
||||
return &WalletTransferProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, messenger: messenger, now: time.Now}
|
||||
func NewWalletTransferProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletTransferProcessor {
|
||||
return &WalletTransferProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, notifier: notifier, now: time.Now}
|
||||
}
|
||||
|
||||
// Recipient is GET /customer/wallet/transfer/recipient: the masked name and number
|
||||
@@ -118,6 +123,7 @@ func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UU
|
||||
outKey := fmt.Sprintf("transfer:%s:%s:out", senderID, key)
|
||||
inKey := fmt.Sprintf("transfer:%s:%s:in", senderID, key)
|
||||
result := &models.WalletTransferResult{Currency: currency, Amount: in.Amount, Recipient: *to}
|
||||
var receivedID uuid.UUID
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if err := p.wallet.LockWallets(ctx, senderID, recipient.ID); err != nil {
|
||||
return err
|
||||
@@ -185,6 +191,7 @@ func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UU
|
||||
result.GroupID = groupID
|
||||
result.Lots = movedLots(received.Lots)
|
||||
result.Replayed = out.Replayed
|
||||
receivedID = received.Transaction.ID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -192,7 +199,7 @@ func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UU
|
||||
}
|
||||
|
||||
if !result.Replayed {
|
||||
p.tellRecipient(recipient, from, currency, in.Amount)
|
||||
p.tellRecipient(ctx, recipient.ID, from, currency, in.Amount, receivedID, result.GroupID)
|
||||
}
|
||||
balances, err := p.spendable.SpendableBalances(ctx, senderID, p.now())
|
||||
if err != nil {
|
||||
@@ -228,16 +235,24 @@ func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *reposit
|
||||
return recipient, nil
|
||||
}
|
||||
|
||||
// tellRecipient is best effort: the transfer has already happened, so a failure to
|
||||
// send the message is only logged.
|
||||
func (p *WalletTransferProcessor) tellRecipient(recipient *repository.WalletMoveCustomer, sender *models.WalletTransferRecipient, currency string, amount int64) {
|
||||
if p.messenger == nil || recipient.PhoneNumber == nil {
|
||||
// tellRecipient pushes the transfer to the recipient's app (F5). It is best effort:
|
||||
// the transfer has already happened, so a failure to send is only logged.
|
||||
func (p *WalletTransferProcessor) tellRecipient(ctx context.Context, recipientID uuid.UUID, sender *models.WalletTransferRecipient, currency string, amount int64, transactionID, groupID uuid.UUID) {
|
||||
if p.notifier == nil {
|
||||
return
|
||||
}
|
||||
message := fmt.Sprintf("Kamu menerima %d %s dari %s (%s). Cek riwayatnya di aplikasi.",
|
||||
amount, walletCurrencyName(currency), sender.Name, sender.PhoneNumber)
|
||||
if err := p.messenger.SendWhatsAppMessage(*recipient.PhoneNumber, message); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about a transfer", recipient.ID), err)
|
||||
name := walletCurrencyName(currency)
|
||||
title := name + " masuk"
|
||||
body := fmt.Sprintf("Kamu menerima %d %s dari %s (%s).", amount, name, sender.Name, sender.PhoneNumber)
|
||||
data := map[string]string{
|
||||
"type": NotificationTypeWalletTransferIn,
|
||||
"transaction_id": transactionID.String(),
|
||||
"group_id": groupID.String(),
|
||||
"currency": currency,
|
||||
"amount": strconv.FormatInt(amount, 10),
|
||||
}
|
||||
if err := p.notifier.Notify(ctx, recipientID, title, body, data); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about a transfer", recipientID), err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,13 +15,19 @@ import (
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
type messengerFake struct{ sent map[string][]string }
|
||||
type pushFake struct {
|
||||
title, body string
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (f *messengerFake) SendWhatsAppMessage(phone, message string) error {
|
||||
if f.sent == nil {
|
||||
f.sent = map[string][]string{}
|
||||
// notifierFake records the pushes each customer would get.
|
||||
type notifierFake struct{ pushes map[uuid.UUID][]pushFake }
|
||||
|
||||
func (f *notifierFake) Notify(_ context.Context, customerID uuid.UUID, title, body string, data map[string]string) error {
|
||||
if f.pushes == nil {
|
||||
f.pushes = map[uuid.UUID][]pushFake{}
|
||||
}
|
||||
f.sent[phone] = append(f.sent[phone], message)
|
||||
f.pushes[customerID] = append(f.pushes[customerID], pushFake{title: title, body: body, data: data})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,10 +42,10 @@ func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
|
||||
dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour)
|
||||
first := e.credit(t, earn(a, 100, dec))
|
||||
second := e.credit(t, earn(a, 50, jan))
|
||||
messenger := &messengerFake{}
|
||||
notifier := ¬ifierFake{}
|
||||
|
||||
// The example in §8: A sends 120, 100 from the lot expiring first and 20 from the next.
|
||||
res, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
res, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(30), res.Balance)
|
||||
@@ -74,7 +81,18 @@ func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
|
||||
assert.GreaterOrEqual(t, e.repo.locks[a], 1)
|
||||
assert.GreaterOrEqual(t, e.repo.locks[b], 1)
|
||||
|
||||
assert.Equal(t, []string{"Kamu menerima 120 EnakPoint dari An*** (08**-****-5678). Cek riwayatnya di aplikasi."}, messenger.sent["081234561234"])
|
||||
assert.Equal(t, []pushFake{{
|
||||
title: "EnakPoint masuk",
|
||||
body: "Kamu menerima 120 EnakPoint dari An*** (08**-****-5678).",
|
||||
data: map[string]string{
|
||||
"type": NotificationTypeWalletTransferIn,
|
||||
"transaction_id": in.ID.String(),
|
||||
"group_id": in.GroupID.String(),
|
||||
"currency": constants.WalletCurrencyPoint,
|
||||
"amount": "120",
|
||||
},
|
||||
}}, notifier.pushes[b])
|
||||
assert.Empty(t, notifier.pushes[a], "the sender gets no push")
|
||||
}
|
||||
|
||||
func TestWalletTransfer_Coins(t *testing.T) {
|
||||
@@ -191,20 +209,20 @@ func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) {
|
||||
b := e.member("Budi", "081234561234")
|
||||
e.member("Citra", "081255550000")
|
||||
e.credit(t, earn(a, 100, nil))
|
||||
messenger := &messengerFake{}
|
||||
notifier := ¬ifierFake{}
|
||||
|
||||
first, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
first, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
again, err := e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
again, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, again.Replayed)
|
||||
assert.Equal(t, first.GroupID, again.GroupID)
|
||||
assert.Equal(t, int64(40), e.balance(t, b))
|
||||
assert.Len(t, messenger.sent["081234561234"], 1)
|
||||
assert.Len(t, notifier.pushes[b], 1)
|
||||
|
||||
// The same key to someone else is not a retry.
|
||||
_, err = e.transfers(messenger).Transfer(e.ctx, a, sendPoints(40, "081255550000"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
_, err = e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081255550000"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CustomerDevice is one install of the customer app that can receive push
|
||||
// notifications.
|
||||
type CustomerDevice struct {
|
||||
CustomerID uuid.UUID
|
||||
DeviceID string
|
||||
Platform *string
|
||||
FCMToken string
|
||||
AppVersion *string
|
||||
}
|
||||
|
||||
// CustomerDeviceRepository keeps the FCM tokens of the customer app.
|
||||
type CustomerDeviceRepository interface {
|
||||
// Register saves the device's current token. A token is only ever one customer's:
|
||||
// registering it takes it away from whoever had it before on that phone, so they
|
||||
// stop getting this customer's notifications.
|
||||
Register(ctx context.Context, device CustomerDevice, now time.Time) error
|
||||
// Unregister forgets a device, for instance on logout. An unknown device is not an error.
|
||||
Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error
|
||||
ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error)
|
||||
}
|
||||
|
||||
type customerDeviceRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomerDeviceRepository(db *gorm.DB) CustomerDeviceRepository {
|
||||
return &customerDeviceRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *customerDeviceRepository) Register(ctx context.Context, d CustomerDevice, now time.Time) error {
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Exec(`DELETE FROM customer_devices WHERE fcm_token = ? AND NOT (customer_id = ? AND device_id = ?)`,
|
||||
d.FCMToken, d.CustomerID, d.DeviceID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to release FCM token: %w", err)
|
||||
}
|
||||
err = tx.Exec(`INSERT INTO customer_devices (customer_id, device_id, platform, fcm_token, app_version, last_active_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (customer_id, device_id) DO UPDATE SET
|
||||
platform = EXCLUDED.platform,
|
||||
fcm_token = EXCLUDED.fcm_token,
|
||||
app_version = EXCLUDED.app_version,
|
||||
last_active_at = EXCLUDED.last_active_at,
|
||||
updated_at = NOW()`,
|
||||
d.CustomerID, d.DeviceID, d.Platform, d.FCMToken, d.AppVersion, now).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register customer device: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *customerDeviceRepository) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error {
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Exec(`DELETE FROM customer_devices WHERE customer_id = ? AND device_id = ?`, customerID, deviceID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unregister customer device: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *customerDeviceRepository) ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error) {
|
||||
var tokens []string
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("customer_devices").
|
||||
Where("customer_id = ? AND fcm_token <> ''", customerID).
|
||||
Order("last_active_at DESC NULLS LAST").
|
||||
Pluck("fcm_token", &tokens).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list customer FCM tokens: %w", err)
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
@@ -60,12 +60,13 @@ type Router struct {
|
||||
pointPaymentHandler *handler.PointPaymentHandler
|
||||
customerOrderPaymentHandler *handler.CustomerOrderPaymentHandler
|
||||
customerWalletHandler *handler.CustomerWalletHandler
|
||||
customerDeviceHandler *handler.CustomerDeviceHandler
|
||||
authMiddleware *middleware.AuthMiddleware
|
||||
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
||||
redisClient *redis.Client
|
||||
}
|
||||
|
||||
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, redisClient *redis.Client) *Router {
|
||||
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, redisClient *redis.Client) *Router {
|
||||
|
||||
return &Router{
|
||||
config: cfg,
|
||||
@@ -117,6 +118,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
|
||||
pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService),
|
||||
customerOrderPaymentHandler: handler.NewCustomerOrderPaymentHandler(customerOrderPaymentService),
|
||||
customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService),
|
||||
customerDeviceHandler: handler.NewCustomerDeviceHandler(customerDeviceService),
|
||||
redisClient: redisClient,
|
||||
}
|
||||
}
|
||||
@@ -175,6 +177,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange)
|
||||
customer.GET("/wallet/transfer/recipient", r.customerWalletHandler.TransferRecipient)
|
||||
customer.POST("/wallet/transfer", r.customerWalletHandler.Transfer)
|
||||
customer.PUT("/devices", r.customerDeviceHandler.Register)
|
||||
customer.DELETE("/devices/:device_id", r.customerDeviceHandler.Unregister)
|
||||
customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints)
|
||||
// PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11)
|
||||
customer.GET("/pin/status", r.customerPinHandler.Status)
|
||||
|
||||
@@ -41,6 +41,8 @@ func TestAllRoutesRegister(t *testing.T) {
|
||||
"POST /api/v1/customer/wallet/exchange",
|
||||
"GET /api/v1/customer/wallet/transfer/recipient",
|
||||
"POST /api/v1/customer/wallet/transfer",
|
||||
"PUT /api/v1/customer/devices",
|
||||
"DELETE /api/v1/customer/devices/:device_id",
|
||||
"GET /api/v1/orders/:id/point-payment/preview",
|
||||
"POST /api/v1/customer/orders/:id/pay-with-points",
|
||||
"GET /api/v1/customer/pin/status",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// CustomerDeviceService registers the customer app's devices for push notifications.
|
||||
type CustomerDeviceService interface {
|
||||
Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response
|
||||
Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response
|
||||
}
|
||||
|
||||
type CustomerDeviceServiceImpl struct {
|
||||
devices *processor.CustomerDeviceProcessor
|
||||
}
|
||||
|
||||
func NewCustomerDeviceService(devices *processor.CustomerDeviceProcessor) *CustomerDeviceServiceImpl {
|
||||
return &CustomerDeviceServiceImpl{devices: devices}
|
||||
}
|
||||
|
||||
func (s *CustomerDeviceServiceImpl) Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response {
|
||||
err := s.devices.Register(ctx, repository.CustomerDevice{
|
||||
CustomerID: customerID,
|
||||
DeviceID: req.DeviceID,
|
||||
FCMToken: req.FCMToken,
|
||||
Platform: req.Platform,
|
||||
AppVersion: req.AppVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return customerDeviceErrorResponse(err)
|
||||
}
|
||||
return contract.BuildSuccessResponse(map[string]interface{}{"device_id": req.DeviceID})
|
||||
}
|
||||
|
||||
func (s *CustomerDeviceServiceImpl) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response {
|
||||
if err := s.devices.Unregister(ctx, customerID, deviceID); err != nil {
|
||||
return customerDeviceErrorResponse(err)
|
||||
}
|
||||
return contract.BuildSuccessResponse(map[string]interface{}{"device_id": deviceID})
|
||||
}
|
||||
|
||||
func customerDeviceErrorResponse(err error) *contract.Response {
|
||||
code := constants.InternalServerErrorCode
|
||||
if errors.Is(err, processor.ErrInvalidCustomerDevice) {
|
||||
code = constants.ValidationErrorCode
|
||||
}
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(code, constants.RequestEntity, err.Error()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS customer_devices;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Devices of the customer app, so customers can get push notifications through FCM
|
||||
-- (docs/prd-point-coin.md F5: the recipient of a transfer is notified). user_devices
|
||||
-- only holds staff devices.
|
||||
CREATE TABLE customer_devices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
device_id VARCHAR(255) NOT NULL,
|
||||
platform VARCHAR(50) CHECK (platform IN ('android', 'ios', 'web')),
|
||||
fcm_token VARCHAR(512) NOT NULL,
|
||||
app_version VARCHAR(50),
|
||||
last_active_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_customer_devices_customer_device ON customer_devices(customer_id, device_id);
|
||||
CREATE INDEX idx_customer_devices_fcm_token ON customer_devices(fcm_token);
|
||||
Reference in New Issue
Block a user