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) } }