omnix-sopiga/service/broadcast.go

206 lines
6.0 KiB
Go
Raw Permalink Normal View History

package service
import (
"context"
"fmt"
fwingestion "repository.promas.id/prana/go-dw-framework/ingestion"
fwlogger "repository.promas.id/prana/go-dw-framework/logger"
"repository.promas.id/prana/omnix-sopiga/client"
"repository.promas.id/prana/omnix-sopiga/domain"
"repository.promas.id/prana/omnix-sopiga/dto"
"repository.promas.id/prana/omnix-sopiga/repository"
"repository.promas.id/prana/omnix-sopiga/transformer"
"repository.promas.id/prana/omnix-sopiga/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)
}
}