206 lines
6.1 KiB
Go
206 lines
6.1 KiB
Go
|
|
package service
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
|
||
|
|
fwingestion "github.com/yourorg/go-dw-platform/framework/ingestion"
|
||
|
|
fwlogger "github.com/yourorg/go-dw-platform/framework/logger"
|
||
|
|
|
||
|
|
"github.com/yourorg/go-dw-platform/services/omnix-broadcast/client"
|
||
|
|
"github.com/yourorg/go-dw-platform/services/omnix-broadcast/domain"
|
||
|
|
"github.com/yourorg/go-dw-platform/services/omnix-broadcast/dto"
|
||
|
|
"github.com/yourorg/go-dw-platform/services/omnix-broadcast/repository"
|
||
|
|
"github.com/yourorg/go-dw-platform/services/omnix-broadcast/transformer"
|
||
|
|
"github.com/yourorg/go-dw-platform/services/omnix-broadcast/validator"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Service struct {
|
||
|
|
repository *repository.Repository
|
||
|
|
transformer *transformer.Transformer
|
||
|
|
sopiga *client.SopigaClient
|
||
|
|
retrier *fwingestion.Retrier
|
||
|
|
logger *fwlogger.Logger
|
||
|
|
}
|
||
|
|
|
||
|
|
func New(
|
||
|
|
repo *repository.Repository,
|
||
|
|
tf *transformer.Transformer,
|
||
|
|
sopigaClient *client.SopigaClient,
|
||
|
|
retrier *fwingestion.Retrier,
|
||
|
|
log *fwlogger.Logger,
|
||
|
|
) *Service {
|
||
|
|
return &Service{
|
||
|
|
repository: repo,
|
||
|
|
transformer: tf,
|
||
|
|
sopiga: sopigaClient,
|
||
|
|
retrier: retrier,
|
||
|
|
logger: log,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// FetchPending returns the next batch of broadcasts waiting to be dispatched.
|
||
|
|
func (s *Service) FetchPending(ctx context.Context, limit int) ([]*domain.Broadcast, error) {
|
||
|
|
entities, err := s.repository.FindPending(ctx, limit)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("fetch pending: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
broadcasts := make([]*domain.Broadcast, 0, len(entities))
|
||
|
|
for _, e := range entities {
|
||
|
|
b, err := s.transformer.EntityToDomain(e)
|
||
|
|
if err != nil {
|
||
|
|
s.logger.Error("skip malformed record", "broadcast_id", e.ID, "error", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
broadcasts = append(broadcasts, b)
|
||
|
|
}
|
||
|
|
|
||
|
|
return broadcasts, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Dispatch builds the templated message for a broadcast and sends it to Sopiga,
|
||
|
|
// updating status in the database based on the outcome.
|
||
|
|
func (s *Service) Dispatch(ctx context.Context, b *domain.Broadcast) error {
|
||
|
|
if err := validator.ValidateBroadcastPayload(b); err != nil {
|
||
|
|
s.markFailed(ctx, b.ID, err)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
variableEntities, err := s.repository.FindTemplateVariables(ctx, b.SopigaTemplateID)
|
||
|
|
if err != nil {
|
||
|
|
s.markFailed(ctx, b.ID, err)
|
||
|
|
return fmt.Errorf("dispatch %d: %w", b.ID, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
variables := make([]*domain.TemplateVariable, len(variableEntities))
|
||
|
|
for i, ve := range variableEntities {
|
||
|
|
variables[i] = s.transformer.EntityToDomainVariable(ve)
|
||
|
|
}
|
||
|
|
|
||
|
|
message, err := s.transformer.BuildDynamicMessage(variables, b.MessagePayload)
|
||
|
|
if err != nil {
|
||
|
|
s.markFailed(ctx, b.ID, err)
|
||
|
|
return fmt.Errorf("dispatch %d: %w", b.ID, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
recipientPhone, _ := b.RecipientPhone()
|
||
|
|
invoiceURL, _ := b.InvoiceURL()
|
||
|
|
|
||
|
|
req := dto.CollarAddRecipientRequest{
|
||
|
|
BroadcastID: b.SopigaCollarID,
|
||
|
|
TemplateID: b.SopigaTemplateID,
|
||
|
|
Details: dto.CollarDetails{
|
||
|
|
Recipient: recipientPhone,
|
||
|
|
Message: message,
|
||
|
|
},
|
||
|
|
Attachment: &dto.CollarAttachment{
|
||
|
|
Type: "document",
|
||
|
|
Caption: fmt.Sprintf("Invoice - %s", b.CustomerName()),
|
||
|
|
File: invoiceURL,
|
||
|
|
},
|
||
|
|
Labels: map[string]string{"module": "gadai_collection"},
|
||
|
|
}
|
||
|
|
|
||
|
|
resp, err := s.retrier.Do(ctx, func() (int64, error) {
|
||
|
|
r, err := s.sopiga.AddRecipient(ctx, req)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
if !r.Success {
|
||
|
|
return 0, fmt.Errorf("sopiga error: %s", r.Message)
|
||
|
|
}
|
||
|
|
return r.Data.RecipientDetailID, nil
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
s.markFailed(ctx, b.ID, err)
|
||
|
|
return fmt.Errorf("dispatch %d: %w", b.ID, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.repository.UpdateDispatched(ctx, b.ID, resp); err != nil {
|
||
|
|
return fmt.Errorf("dispatch %d: %w", b.ID, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// FetchDispatched returns broadcasts awaiting a delivery status update from Sopiga.
|
||
|
|
func (s *Service) FetchDispatched(ctx context.Context, limit int) ([]*domain.Broadcast, error) {
|
||
|
|
entities, err := s.repository.FindDispatched(ctx, limit)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("fetch dispatched: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
broadcasts := make([]*domain.Broadcast, 0, len(entities))
|
||
|
|
for _, e := range entities {
|
||
|
|
b, err := s.transformer.EntityToDomain(e)
|
||
|
|
if err != nil {
|
||
|
|
s.logger.Error("skip malformed record", "broadcast_id", e.ID, "error", err)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
broadcasts = append(broadcasts, b)
|
||
|
|
}
|
||
|
|
|
||
|
|
return broadcasts, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// SyncDelivery checks the current delivery status of a dispatched broadcast
|
||
|
|
// with Sopiga and, if it has reached a terminal state, updates our record.
|
||
|
|
func (s *Service) SyncDelivery(ctx context.Context, b *domain.Broadcast) error {
|
||
|
|
if b.SopigaRecipientDetailID == nil {
|
||
|
|
return fmt.Errorf("sync delivery %d: missing recipient_detail_id", b.ID)
|
||
|
|
}
|
||
|
|
|
||
|
|
resp, err := s.sopiga.GetRecipientDetail(ctx, *b.SopigaRecipientDetailID)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("sync delivery %d: %w", b.ID, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
status, ok := domain.MapSopigaDeliveryStatus(resp.Data.Status)
|
||
|
|
if !ok {
|
||
|
|
return nil // still in-flight (e.g. "sent"), nothing to update yet
|
||
|
|
}
|
||
|
|
|
||
|
|
errMsg := ""
|
||
|
|
if resp.Data.ErrorMessage != nil {
|
||
|
|
errMsg = *resp.Data.ErrorMessage
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.repository.UpdateStatus(ctx, b.ID, string(status), errMsg, resp.Data); err != nil {
|
||
|
|
return fmt.Errorf("sync delivery %d: %w", b.ID, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// SyncDeliveryFromWebhook applies a delivery status pushed by an Omnix
|
||
|
|
// callback, avoiding the extra GET round-trip the polling sync worker needs.
|
||
|
|
func (s *Service) SyncDeliveryFromWebhook(ctx context.Context, payload dto.DeliveryStatusWebhook) error {
|
||
|
|
e, err := s.repository.FindByRecipientDetailID(ctx, payload.RecipientDetailID)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("sync delivery from webhook: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
status, ok := domain.MapSopigaDeliveryStatus(payload.Status)
|
||
|
|
if !ok {
|
||
|
|
return nil // in-flight status (e.g. "sent"), nothing to update yet
|
||
|
|
}
|
||
|
|
|
||
|
|
errMsg := ""
|
||
|
|
if status == domain.StatusFailed {
|
||
|
|
errMsg = fmt.Sprintf("delivery failed via gateway %s", payload.Gateway)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.repository.UpdateStatus(ctx, e.ID, string(status), errMsg, payload); err != nil {
|
||
|
|
return fmt.Errorf("sync delivery from webhook %d: %w", e.ID, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) markFailed(ctx context.Context, id int64, cause error) {
|
||
|
|
if err := s.repository.UpdateStatus(ctx, id, string(domain.StatusFailed), cause.Error(), nil); err != nil {
|
||
|
|
s.logger.Error("failed to mark broadcast failed", "broadcast_id", id, "error", err)
|
||
|
|
}
|
||
|
|
}
|