Initial commit: extract omnix-broadcast as standalone repo omnix-sopiga
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
21780412b8
15
.env.example
Normal file
15
.env.example
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
DATABASE_DSN=postgres://user:pass@localhost:5432/gadai_mulia
|
||||||
|
DB_MAX_CONNS=25
|
||||||
|
DB_MIN_CONNS=5
|
||||||
|
|
||||||
|
SOPIGA_BASE_URL=https://omnix-dev.promas.site
|
||||||
|
SOPIGA_TOKEN=
|
||||||
|
|
||||||
|
WORKER_CHECK_INTERVAL_SEC=30
|
||||||
|
WORKER_BATCH_SIZE=100
|
||||||
|
WORKER_MAX_RETRIES=3
|
||||||
|
WORKER_SYNC_INTERVAL_SEC=300
|
||||||
|
WORKER_SYNC_BATCH_SIZE=100
|
||||||
|
|
||||||
|
WEBHOOK_PORT=8081
|
||||||
|
WEBHOOK_SECRET=
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.go-cache/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# compiled binary from `go build`
|
||||||
|
/omnix-sopiga
|
||||||
|
/omnix-sopiga.exe
|
||||||
51
.gitlab-ci.yml
Normal file
51
.gitlab-ci.yml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
stages:
|
||||||
|
- test
|
||||||
|
- build
|
||||||
|
|
||||||
|
variables:
|
||||||
|
GO_VERSION: "1.25"
|
||||||
|
# TODO: confirm final remote URL once go-dw-framework is pushed, and set up
|
||||||
|
# a deploy token / CI_JOB_TOKEN with read access if the repo is private.
|
||||||
|
FRAMEWORK_REPO_URL: "https://repository.promas.id/prana/go-dw-framework.git"
|
||||||
|
|
||||||
|
# go.mod currently has `replace repository.promas.id/prana/go-dw-framework => ../go-dw-framework`
|
||||||
|
# for pre-release local development. CI clones it into that exact relative
|
||||||
|
# path so the replace directive resolves. Once go-dw-framework is tagged
|
||||||
|
# (e.g. v0.1.0), remove the replace line from go.mod, drop this clone step,
|
||||||
|
# and let `go mod download` fetch the tagged version normally.
|
||||||
|
.with-framework:
|
||||||
|
image: golang:${GO_VERSION}-alpine
|
||||||
|
before_script:
|
||||||
|
- apk add --no-cache git
|
||||||
|
- git clone --depth 1 "$FRAMEWORK_REPO_URL" ../go-dw-framework
|
||||||
|
cache:
|
||||||
|
key: go-mod-cache
|
||||||
|
paths:
|
||||||
|
- .go-cache/
|
||||||
|
variables:
|
||||||
|
GOPATH: "$CI_PROJECT_DIR/.go-cache"
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
extends: .with-framework
|
||||||
|
script:
|
||||||
|
- go vet ./...
|
||||||
|
- go test ./tests/... -v
|
||||||
|
|
||||||
|
# NOTE: assumes the GitLab project path checks out into a folder literally
|
||||||
|
# named `omnix-sopiga` (matches CI_PROJECT_DIR's basename) so the Dockerfile's
|
||||||
|
# `COPY omnix-sopiga ./omnix-sopiga` line lines up. Adjust if your project slug differs.
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
image: docker:24
|
||||||
|
services:
|
||||||
|
- docker:24-dind
|
||||||
|
before_script:
|
||||||
|
- apk add --no-cache git
|
||||||
|
- git clone --depth 1 "$FRAMEWORK_REPO_URL" ../go-dw-framework
|
||||||
|
script:
|
||||||
|
- docker build -f Dockerfile -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" ..
|
||||||
|
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
|
||||||
|
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
|
||||||
|
rules:
|
||||||
|
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Build context: PARENT directory of this repo, i.e. the folder that
|
||||||
|
# contains both `omnix-sopiga/` and `go-dw-framework/` as siblings:
|
||||||
|
#
|
||||||
|
# docker build -f omnix-sopiga/Dockerfile -t omnix-sopiga ..
|
||||||
|
#
|
||||||
|
# Why: go.mod still uses a local `replace` directive pointing at
|
||||||
|
# ../go-dw-framework (see go.mod) for pre-release development, since
|
||||||
|
# go-dw-framework isn't tagged/published yet. Once it is tagged (e.g. v0.1.0)
|
||||||
|
# and reachable from the Docker build environment:
|
||||||
|
# 1. go.mod: remove the `replace` line, keep `require ... v0.1.0`
|
||||||
|
# 2. This Dockerfile: build context becomes just this repo (`.`), and the
|
||||||
|
# COPY lines below collapse to `COPY . .`
|
||||||
|
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go-dw-framework ./go-dw-framework
|
||||||
|
COPY omnix-sopiga ./omnix-sopiga
|
||||||
|
|
||||||
|
WORKDIR /src/omnix-sopiga
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/omnix-sopiga .
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata && \
|
||||||
|
adduser -D -u 10001 app
|
||||||
|
USER app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /out/omnix-sopiga .
|
||||||
|
COPY omnix-sopiga/migrations ./migrations
|
||||||
|
|
||||||
|
EXPOSE 8081
|
||||||
|
ENTRYPOINT ["./omnix-sopiga"]
|
||||||
124
README.md
Normal file
124
README.md
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
# omnix-sopiga
|
||||||
|
|
||||||
|
Collection broadcast worker: mengirim invoice via WhatsApp (Sopiga Collar API) ke nasabah Gadai Mulia.
|
||||||
|
|
||||||
|
> Repo standalone (bukan bagian monorepo). Dependency internal ada di
|
||||||
|
> [`go-dw-framework`](../go-dw-framework) — lihat `go.mod` untuk detail
|
||||||
|
> `replace` directive yang dipakai selama development lokal.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Gadai Collection Service
|
||||||
|
→ INSERT broadcast_staging (message_payload JSONB, status=pending)
|
||||||
|
→ BroadcastWorker (poll every 30s)
|
||||||
|
→ Query template_variable_mapping
|
||||||
|
→ Build message dynamically
|
||||||
|
→ POST Sopiga Collar API
|
||||||
|
→ status=dispatched (sopiga_recipient_detail_id disimpan)
|
||||||
|
→ DeliverySyncWorker (poll every 5 min)
|
||||||
|
→ GET recipient detail dari Sopiga
|
||||||
|
→ status=delivered (delivered_at diisi) atau status=failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Tiga komponen jalan bersamaan (lihat `main.go`):
|
||||||
|
- **BroadcastWorker** (`worker/broadcast_worker.go`) — poll `pending`, dispatch ke Sopiga.
|
||||||
|
- **Webhook receiver** (`handler/webhook.go`, `POST /webhooks/sopiga/delivery-status`) —
|
||||||
|
jalur utama update status delivery. Begitu Omnix push callback, status langsung
|
||||||
|
diupdate tanpa perlu polling balik ke Sopiga sama sekali.
|
||||||
|
- **DeliverySyncWorker** (`worker/delivery_sync_worker.go`) — polling **fallback/backstop**
|
||||||
|
untuk record `dispatched` yang tidak kunjung dapat webhook (mis. delivery gagal
|
||||||
|
terkirim/network hiccup di sisi Omnix). Karena webhook jadi jalur utama, interval ini
|
||||||
|
bisa diperlonggar jauh lebih besar dari 5 menit tanpa menambah beban signifikan ke Omnix.
|
||||||
|
|
||||||
|
> ⚠️ **Catatan:** payload webhook di `dto/webhook.go` dan skema signature (`X-Sopiga-Signature`,
|
||||||
|
> HMAC-SHA256) masih **asumsi kita sendiri** — belum dikonfirmasi tim Omnix, karena endpoint
|
||||||
|
> registrasi callback tidak ditemukan di dokumentasi resmi (`/docs?api-docs.yaml`) saat
|
||||||
|
> implementasi ini dibuat. Sebelum pakai di production: (1) konfirmasi ke tim Omnix apakah
|
||||||
|
> mereka support outgoing webhook untuk status collar recipient, (2) minta format payload &
|
||||||
|
> skema signature asli mereka, (3) sesuaikan `dto/webhook.go` dan `handler/webhook.go`,
|
||||||
|
> (4) daftarkan URL `/webhooks/sopiga/delivery-status` ke mereka.
|
||||||
|
|
||||||
|
Template baru cukup didaftarkan lewat database (`sopiga_template_config` +
|
||||||
|
`template_variable_mapping` + `sopiga_collar_config`) — tanpa redeploy kode.
|
||||||
|
Lihat [docs/implementasi_guide.md](docs/implementasi_guide.md) dan
|
||||||
|
[docs/quick_start.md](docs/quick_start.md) untuk panduan lengkap. Detail
|
||||||
|
permintaan integrasi webhook ke tim Omnix ada di
|
||||||
|
[docs/webhook_integration_request.md](docs/webhook_integration_request.md).
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
| Layer | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `worker/` | Poller loop untuk dispatch + delivery sync fallback, fan-out ke concurrent processing |
|
||||||
|
| `handler/` | `WebhookHandler` — HTTP receiver untuk callback status delivery dari Omnix |
|
||||||
|
| `transformer/` | Entity ↔ Domain mapping, `BuildDynamicMessage` interpolasi template |
|
||||||
|
| `service/` | Business logic: fetch pending/dispatched, validate, dispatch, sync delivery (polling & webhook), retry, mark failed |
|
||||||
|
| `repository/` | Query `broadcast_staging` & `template_variable_mapping`, update status, lookup by `recipient_detail_id` |
|
||||||
|
| `client/` | HTTP client ke Sopiga Collar API (add-recipient + get recipient detail) |
|
||||||
|
| `domain/` | `Broadcast`, `TemplateVariable` |
|
||||||
|
| `entity/` | DB row mapping |
|
||||||
|
| `dto/` | Sopiga API request/response |
|
||||||
|
| `validator/` | Validasi payload sebelum dispatch |
|
||||||
|
| `migrations/` | Schema `collection_broadcasts` (tables, views, functions) |
|
||||||
|
|
||||||
|
## Configuration (env vars)
|
||||||
|
|
||||||
|
| Var | Default |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_DSN` | — |
|
||||||
|
| `DB_MAX_CONNS` | 25 |
|
||||||
|
| `DB_MIN_CONNS` | 5 |
|
||||||
|
| `SOPIGA_BASE_URL` | `https://omnix.promas.site` |
|
||||||
|
| `SOPIGA_TOKEN` | — |
|
||||||
|
| `WORKER_CHECK_INTERVAL_SEC` | 30 |
|
||||||
|
| `WORKER_BATCH_SIZE` | 100 |
|
||||||
|
| `WORKER_MAX_RETRIES` | 3 |
|
||||||
|
| `WORKER_SYNC_INTERVAL_SEC` | 300 (fallback saja — perlonggar kalau webhook sudah aktif) |
|
||||||
|
| `WORKER_SYNC_BATCH_SIZE` | 100 |
|
||||||
|
| `WEBHOOK_PORT` | 8081 |
|
||||||
|
| `WEBHOOK_SECRET` | — (HMAC-SHA256 shared secret; kosongkan untuk skip verifikasi saat dev) |
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
Clone this repo and `go-dw-framework` as **siblings** (same parent folder) —
|
||||||
|
`go.mod` uses a local `replace` directive during pre-release development:
|
||||||
|
|
||||||
|
```
|
||||||
|
some-folder/
|
||||||
|
├── go-dw-framework/
|
||||||
|
└── omnix-sopiga/ ← you are here
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <go-dw-framework-remote> ../go-dw-framework
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Once `go-dw-framework` is tagged (e.g. `v0.1.0`) and pushed to its remote,
|
||||||
|
switch `go.mod` to a plain `require repository.promas.id/prana/go-dw-framework v0.1.0`
|
||||||
|
(drop the `replace` line) and `go mod tidy`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -d gadai_mulia -f migrations/001_create_collection_broadcasts.up.sql
|
||||||
|
|
||||||
|
export DATABASE_DSN="postgres://user:pass@localhost:5432/gadai_mulia"
|
||||||
|
export SOPIGA_TOKEN="your_api_token"
|
||||||
|
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from the parent folder containing both omnix-sopiga/ and go-dw-framework/
|
||||||
|
docker build -f omnix-sopiga/Dockerfile -t omnix-sopiga ..
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./tests/... -v
|
||||||
|
```
|
||||||
107
client/sopiga.go
Normal file
107
client/sopiga.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SopigaClient struct {
|
||||||
|
baseURL string
|
||||||
|
token string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSopigaClient(baseURL, token string) *SopigaClient {
|
||||||
|
return &SopigaClient{
|
||||||
|
baseURL: baseURL,
|
||||||
|
token: token,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SopigaClient) AddRecipient(ctx context.Context, req dto.CollarAddRecipientRequest) (*dto.CollarAPIResponse, error) {
|
||||||
|
body, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
fmt.Sprintf("%s/api/client/collar/add-recipient", c.baseURL),
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
httpResp, err := c.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("http request: %w", err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(httpResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp dto.CollarAPIResponse
|
||||||
|
if err := json.Unmarshal(bodyBytes, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResp.StatusCode != http.StatusAccepted && httpResp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("http status %d: %s", httpResp.StatusCode, resp.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SopigaClient) GetRecipientDetail(ctx context.Context, recipientDetailID int64) (*dto.CollarRecipientDetailResponse, error) {
|
||||||
|
httpReq, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodGet,
|
||||||
|
fmt.Sprintf("%s/api/client/collar/add-recipient/%d/detail", c.baseURL, recipientDetailID),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
|
||||||
|
|
||||||
|
httpResp, err := c.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("http request: %w", err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(httpResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp dto.CollarRecipientDetailResponse
|
||||||
|
if err := json.Unmarshal(bodyBytes, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("http status %d: %s", httpResp.StatusCode, resp.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
69
config.go
Normal file
69
config.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fwconfig "repository.promas.id/prana/go-dw-framework/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DB DBConfig
|
||||||
|
Sopiga SopigaConfig
|
||||||
|
Worker WorkerConfig
|
||||||
|
Webhook WebhookConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type DBConfig struct {
|
||||||
|
DSN string
|
||||||
|
MaxConns int32
|
||||||
|
MinConns int32
|
||||||
|
MaxConnLifetime time.Duration
|
||||||
|
MaxConnIdleTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type SopigaConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkerConfig struct {
|
||||||
|
CheckInterval time.Duration
|
||||||
|
BatchSize int
|
||||||
|
MaxRetries int
|
||||||
|
RetryDelay time.Duration
|
||||||
|
SyncCheckInterval time.Duration
|
||||||
|
SyncBatchSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebhookConfig struct {
|
||||||
|
Port string
|
||||||
|
Secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
DB: DBConfig{
|
||||||
|
DSN: fwconfig.GetString("DATABASE_DSN", ""),
|
||||||
|
MaxConns: int32(fwconfig.GetInt("DB_MAX_CONNS", 25)),
|
||||||
|
MinConns: int32(fwconfig.GetInt("DB_MIN_CONNS", 5)),
|
||||||
|
MaxConnLifetime: 15 * time.Minute,
|
||||||
|
MaxConnIdleTime: 5 * time.Minute,
|
||||||
|
},
|
||||||
|
Sopiga: SopigaConfig{
|
||||||
|
BaseURL: fwconfig.GetString("SOPIGA_BASE_URL", "https://omnix.promas.site"),
|
||||||
|
Token: fwconfig.GetString("SOPIGA_TOKEN", ""),
|
||||||
|
},
|
||||||
|
Worker: WorkerConfig{
|
||||||
|
CheckInterval: time.Duration(fwconfig.GetInt("WORKER_CHECK_INTERVAL_SEC", 30)) * time.Second,
|
||||||
|
BatchSize: fwconfig.GetInt("WORKER_BATCH_SIZE", 100),
|
||||||
|
MaxRetries: fwconfig.GetInt("WORKER_MAX_RETRIES", 3),
|
||||||
|
RetryDelay: time.Second,
|
||||||
|
SyncCheckInterval: time.Duration(fwconfig.GetInt("WORKER_SYNC_INTERVAL_SEC", 300)) * time.Second,
|
||||||
|
SyncBatchSize: fwconfig.GetInt("WORKER_SYNC_BATCH_SIZE", 100),
|
||||||
|
},
|
||||||
|
Webhook: WebhookConfig{
|
||||||
|
Port: fwconfig.GetString("WEBHOOK_PORT", "8081"),
|
||||||
|
Secret: fwconfig.GetString("WEBHOOK_SECRET", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
452
docs/Collection_broadcast_worker_simplified.go
Normal file
452
docs/Collection_broadcast_worker_simplified.go
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============ DATABASE MODELS ============
|
||||||
|
|
||||||
|
type BroadcastRecord struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
SopigaCollarID int `db:"sopiga_collar_id"`
|
||||||
|
SopigaTemplateID int `db:"sopiga_template_id"`
|
||||||
|
MessagePayload map[string]interface{} `db:"message_payload"` // JSONB
|
||||||
|
Status string `db:"status"`
|
||||||
|
ErrorMessage *string `db:"error_message"`
|
||||||
|
ErrorCount int `db:"error_count"`
|
||||||
|
SopigaRecipientDetailID *int64 `db:"sopiga_recipient_detail_id"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateVariable struct {
|
||||||
|
VariableOrder int `db:"variable_order"`
|
||||||
|
SopigaVarName string `db:"sopiga_variable_name"`
|
||||||
|
VariableType string `db:"variable_type"`
|
||||||
|
DBFieldSource string `db:"db_field_source"`
|
||||||
|
IsRequired bool `db:"is_required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ SOPIGA API MODELS ============
|
||||||
|
|
||||||
|
type CollarAddRecipientRequest struct {
|
||||||
|
BroadcastID int `json:"broadcast_id"`
|
||||||
|
TemplateID int `json:"template_id"`
|
||||||
|
Details CollarDetails `json:"details"`
|
||||||
|
Attachment *CollarAttachment `json:"attachment,omitempty"`
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollarDetails struct {
|
||||||
|
Recipient string `json:"recipient"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollarAttachment struct {
|
||||||
|
Type string `json:"type"` // document
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
File string `json:"file"` // URL
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollarAPIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data CollarResponseData `json:"data"`
|
||||||
|
Errors map[string][]string `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollarResponseData struct {
|
||||||
|
BroadcastID int `json:"broadcast_id"`
|
||||||
|
RecipientDetailID int64 `json:"recipient_detail_id"`
|
||||||
|
Recipient string `json:"recipient"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ WORKER ============
|
||||||
|
|
||||||
|
type CollectionBroadcastWorker struct {
|
||||||
|
db *sql.DB
|
||||||
|
sopigaBaseURL string
|
||||||
|
sopigaToken string
|
||||||
|
httpClient *http.Client
|
||||||
|
checkInterval time.Duration
|
||||||
|
batchSize int
|
||||||
|
maxRetries int
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCollectionBroadcastWorker(
|
||||||
|
db *sql.DB,
|
||||||
|
sopigaBaseURL string,
|
||||||
|
sopigaToken string,
|
||||||
|
logger *log.Logger,
|
||||||
|
) *CollectionBroadcastWorker {
|
||||||
|
return &CollectionBroadcastWorker{
|
||||||
|
db: db,
|
||||||
|
sopigaBaseURL: sopigaBaseURL,
|
||||||
|
sopigaToken: sopigaToken,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
checkInterval: 30 * time.Second,
|
||||||
|
batchSize: 100,
|
||||||
|
maxRetries: 3,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start — Main worker loop
|
||||||
|
func (w *CollectionBroadcastWorker) Start(ctx context.Context) {
|
||||||
|
w.logger.Println("Collection Broadcast Worker started")
|
||||||
|
|
||||||
|
ticker := time.NewTicker(w.checkInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
w.logger.Println("Collection Broadcast Worker stopped")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w.processPendingRecords(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processPendingRecords — Fetch & process pending records
|
||||||
|
func (w *CollectionBroadcastWorker) processPendingRecords(ctx context.Context) {
|
||||||
|
records, err := w.getPendingRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Printf("ERROR fetching pending records: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(records) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.logger.Printf("Found %d pending records to process", len(records))
|
||||||
|
|
||||||
|
// Process concurrently (max 5)
|
||||||
|
sem := make(chan struct{}, 5)
|
||||||
|
for _, rec := range records {
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func(r BroadcastRecord) {
|
||||||
|
defer func() { <-sem }()
|
||||||
|
w.processSingleRecord(ctx, r)
|
||||||
|
}(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
sem <- struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPendingRecords — Query broadcast_staging untuk pending records
|
||||||
|
// SIMPLIFIED: Hanya query dari broadcast_staging, semua data ada di message_payload
|
||||||
|
func (w *CollectionBroadcastWorker) getPendingRecords(ctx context.Context) ([]BroadcastRecord, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, sopiga_collar_id, sopiga_template_id, message_payload,
|
||||||
|
status, error_message, error_count, sopiga_recipient_detail_id,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := w.db.QueryContext(ctx, query, w.batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var records []BroadcastRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var rec BroadcastRecord
|
||||||
|
var payloadJSON []byte
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&rec.ID, &rec.SopigaCollarID, &rec.SopigaTemplateID, &payloadJSON,
|
||||||
|
&rec.Status, &rec.ErrorMessage, &rec.ErrorCount, &rec.SopigaRecipientDetailID,
|
||||||
|
&rec.CreatedAt, &rec.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Printf("ERROR scanning record: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSONB message_payload
|
||||||
|
err = json.Unmarshal(payloadJSON, &rec.MessagePayload)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Printf("ERROR parsing message_payload for record %d: %v", rec.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
records = append(records, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// processSingleRecord — Call Sopiga API
|
||||||
|
func (w *CollectionBroadcastWorker) processSingleRecord(ctx context.Context, rec BroadcastRecord) {
|
||||||
|
// Build message dinamis dari template variable mapping
|
||||||
|
message, err := w.buildDynamicMessage(ctx, rec.SopigaTemplateID, rec.MessagePayload)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Printf("Record ID %d: ERROR building message: %v", rec.ID, err)
|
||||||
|
w.updateStatus(ctx, rec.ID, "failed", fmt.Sprintf("message build error: %v", err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recipient phone dari message_payload
|
||||||
|
recipientPhone, ok := rec.MessagePayload["nasabah_phone"].(string)
|
||||||
|
if !ok {
|
||||||
|
w.logger.Printf("Record ID %d: ERROR - nasabah_phone not in payload", rec.ID)
|
||||||
|
w.updateStatus(ctx, rec.ID, "failed", "nasabah_phone not in payload", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get invoice URL dari message_payload
|
||||||
|
invoiceURL, ok := rec.MessagePayload["invoice_pdf_url"].(string)
|
||||||
|
if !ok {
|
||||||
|
w.logger.Printf("Record ID %d: ERROR - invoice_pdf_url not in payload", rec.ID)
|
||||||
|
w.updateStatus(ctx, rec.ID, "failed", "invoice_pdf_url not in payload", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get nasabah_nama untuk caption
|
||||||
|
nasabahNama, _ := rec.MessagePayload["nasabah_nama"].(string)
|
||||||
|
|
||||||
|
// Build attachment
|
||||||
|
attachment := &CollarAttachment{
|
||||||
|
Type: "document",
|
||||||
|
Caption: fmt.Sprintf("Invoice - %s", nasabahNama),
|
||||||
|
File: invoiceURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build labels
|
||||||
|
labels := map[string]string{
|
||||||
|
"module": "gadai_collection",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
collarReq := CollarAddRecipientRequest{
|
||||||
|
BroadcastID: rec.SopigaCollarID,
|
||||||
|
TemplateID: rec.SopigaTemplateID,
|
||||||
|
Details: CollarDetails{
|
||||||
|
Recipient: recipientPhone,
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
Attachment: attachment,
|
||||||
|
Labels: labels,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call API dengan retry
|
||||||
|
resp, err := w.callCollarAPIWithRetry(ctx, collarReq, 0)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Printf("Record ID %d: FAILED after %d retries: %v", rec.ID, w.maxRetries, err)
|
||||||
|
w.updateStatus(ctx, rec.ID, "failed", err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
w.logger.Printf("Record ID %d: DISPATCHED (recipient_detail_id: %d)", rec.ID, resp.Data.RecipientDetailID)
|
||||||
|
w.updateDispatchedStatus(ctx, rec.ID, resp.Data.RecipientDetailID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDynamicMessage — Build message dari template variables mapping
|
||||||
|
// Query template_variable_mapping, interpolate sesuai urutan
|
||||||
|
func (w *CollectionBroadcastWorker) buildDynamicMessage(
|
||||||
|
ctx context.Context,
|
||||||
|
templateID int,
|
||||||
|
messagePayload map[string]interface{},
|
||||||
|
) (string, error) {
|
||||||
|
// Query template variables
|
||||||
|
query := `
|
||||||
|
SELECT variable_order, sopiga_variable_name, variable_type, db_field_source, is_required
|
||||||
|
FROM collection_broadcasts.template_variable_mapping
|
||||||
|
WHERE sopiga_template_id = $1
|
||||||
|
ORDER BY variable_order ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := w.db.QueryContext(ctx, query, templateID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("query template variables failed: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
for rows.Next() {
|
||||||
|
var tv TemplateVariable
|
||||||
|
if err := rows.Scan(&tv.VariableOrder, &tv.SopigaVarName, &tv.VariableType, &tv.DBFieldSource, &tv.IsRequired); err != nil {
|
||||||
|
return "", fmt.Errorf("scan template variable failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get value dari message_payload
|
||||||
|
value, exists := messagePayload[tv.DBFieldSource]
|
||||||
|
if !exists {
|
||||||
|
if tv.IsRequired {
|
||||||
|
return "", fmt.Errorf("missing required field: %s", tv.DBFieldSource)
|
||||||
|
}
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format sesuai type
|
||||||
|
var formattedValue string
|
||||||
|
switch tv.VariableType {
|
||||||
|
case "string":
|
||||||
|
formattedValue = fmt.Sprintf("%v", value)
|
||||||
|
case "integer", "decimal":
|
||||||
|
formattedValue = fmt.Sprintf("%.0f", value)
|
||||||
|
case "date":
|
||||||
|
formattedValue = fmt.Sprintf("%v", value)
|
||||||
|
default:
|
||||||
|
formattedValue = fmt.Sprintf("%v", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts, formattedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join dengan '#'
|
||||||
|
return strings.Join(parts, "#"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callCollarAPIWithRetry — Call API dengan automatic retry
|
||||||
|
func (w *CollectionBroadcastWorker) callCollarAPIWithRetry(
|
||||||
|
ctx context.Context,
|
||||||
|
req CollarAddRecipientRequest,
|
||||||
|
attempt int,
|
||||||
|
) (*CollarAPIResponse, error) {
|
||||||
|
if attempt > w.maxRetries {
|
||||||
|
return nil, fmt.Errorf("max retries (%d) exceeded", w.maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := w.callCollarAPI(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
if attempt < w.maxRetries {
|
||||||
|
waitTime := time.Duration(1<<uint(attempt)) * time.Second
|
||||||
|
w.logger.Printf("Retry attempt %d/%d in %v", attempt+1, w.maxRetries, waitTime)
|
||||||
|
time.Sleep(waitTime)
|
||||||
|
return w.callCollarAPIWithRetry(ctx, req, attempt+1)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Success {
|
||||||
|
err := fmt.Errorf("sopiga error: %s", resp.Message)
|
||||||
|
if attempt < w.maxRetries {
|
||||||
|
waitTime := time.Duration(1<<uint(attempt)) * time.Second
|
||||||
|
w.logger.Printf("API error, retry in %v", waitTime)
|
||||||
|
time.Sleep(waitTime)
|
||||||
|
return w.callCollarAPIWithRetry(ctx, req, attempt+1)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// callCollarAPI — Single HTTP call
|
||||||
|
func (w *CollectionBroadcastWorker) callCollarAPI(
|
||||||
|
ctx context.Context,
|
||||||
|
req CollarAddRecipientRequest,
|
||||||
|
) (*CollarAPIResponse, error) {
|
||||||
|
body, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("json marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
"POST",
|
||||||
|
fmt.Sprintf("%s/api/client/collar/add-recipient", w.sopigaBaseURL),
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request creation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", w.sopigaToken))
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
httpResp, err := w.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("http request: %w", err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(httpResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp CollarAPIResponse
|
||||||
|
if err := json.Unmarshal(bodyBytes, &resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("json unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResp.StatusCode != http.StatusAccepted && httpResp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("http status %d: %s", httpResp.StatusCode, resp.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateStatus — Update status ke database
|
||||||
|
func (w *CollectionBroadcastWorker) updateStatus(
|
||||||
|
ctx context.Context,
|
||||||
|
recordID int64,
|
||||||
|
status string,
|
||||||
|
errMsg string,
|
||||||
|
sopigaResp *CollarAPIResponse,
|
||||||
|
) {
|
||||||
|
var respJSON []byte
|
||||||
|
if sopigaResp != nil {
|
||||||
|
respJSON, _ = json.Marshal(sopigaResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT collection_broadcasts.update_broadcast_status($1, $2, $3, $4)
|
||||||
|
`
|
||||||
|
_, err := w.db.ExecContext(ctx, query, recordID, status, errMsg, respJSON)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Printf("ERROR updating status for record %d: %v", recordID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateDispatchedStatus — Update ke dispatched + recipient_detail_id
|
||||||
|
func (w *CollectionBroadcastWorker) updateDispatchedStatus(
|
||||||
|
ctx context.Context,
|
||||||
|
recordID int64,
|
||||||
|
recipientDetailID int64,
|
||||||
|
) {
|
||||||
|
query := `
|
||||||
|
UPDATE collection_broadcasts.broadcast_staging
|
||||||
|
SET
|
||||||
|
status = 'dispatched',
|
||||||
|
sopiga_recipient_detail_id = $1,
|
||||||
|
dispatched_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
`
|
||||||
|
_, err := w.db.ExecContext(ctx, query, recipientDetailID, recordID)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Printf("ERROR updating dispatched status for record %d: %v", recordID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
319
docs/Gadai_collection_broadcast_simplified_schema.sql
Normal file
319
docs/Gadai_collection_broadcast_simplified_schema.sql
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- GADAI MULIA - COLLECTION BROADCAST TO SOPIGA COLLAR
|
||||||
|
-- SIMPLIFIED SCHEMA: Denormalized broadcast_staging
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS collection_broadcasts;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 1. CONFIGURATION TABLES (Reference only)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Sopiga Template Config (pre-setup di Sopiga)
|
||||||
|
CREATE TABLE collection_broadcasts.sopiga_template_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
template_name VARCHAR(100) NOT NULL,
|
||||||
|
sopiga_template_id INT NOT NULL UNIQUE,
|
||||||
|
template_type VARCHAR(50), -- utility, marketing, notification
|
||||||
|
channel VARCHAR(50), -- waba
|
||||||
|
description TEXT,
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sopiga_template_active ON collection_broadcasts.sopiga_template_config(active);
|
||||||
|
|
||||||
|
-- Template Variable Mapping (untuk dynamic message building)
|
||||||
|
CREATE TABLE collection_broadcasts.template_variable_mapping (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
sopiga_template_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_template_config(sopiga_template_id) ON DELETE CASCADE,
|
||||||
|
variable_order INT NOT NULL, -- 1, 2, 3, ... (urutan interpolasi)
|
||||||
|
sopiga_variable_name VARCHAR(100) NOT NULL, -- "Nama", "TotalTagihan", "NoKontrak", etc
|
||||||
|
variable_type VARCHAR(50), -- string, integer, date, decimal
|
||||||
|
db_field_source VARCHAR(100) NOT NULL, -- Key di message_payload
|
||||||
|
is_required BOOLEAN DEFAULT TRUE,
|
||||||
|
example_value VARCHAR(500),
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_template_variable UNIQUE (sopiga_template_id, sopiga_variable_name),
|
||||||
|
CONSTRAINT unique_variable_order UNIQUE (sopiga_template_id, variable_order)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_template_variable_mapping_template_id ON collection_broadcasts.template_variable_mapping(sopiga_template_id);
|
||||||
|
CREATE INDEX idx_template_variable_mapping_order ON collection_broadcasts.template_variable_mapping(sopiga_template_id, variable_order);
|
||||||
|
|
||||||
|
-- Sopiga Collar Config (pre-created di Sopiga)
|
||||||
|
CREATE TABLE collection_broadcasts.sopiga_collar_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
broadcast_name VARCHAR(100) NOT NULL,
|
||||||
|
sopiga_collar_id INT NOT NULL UNIQUE,
|
||||||
|
sopiga_template_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_template_config(sopiga_template_id),
|
||||||
|
status VARCHAR(50), -- open, closed
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sopiga_collar_status ON collection_broadcasts.sopiga_collar_config(status);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 2. BROADCAST STAGING (CORE) - DENORMALIZED
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Single source of truth: message_payload (JSONB)
|
||||||
|
-- Semua data sudah ada di sini, tidak perlu FK ke nasabah/contract
|
||||||
|
-- Worker query hanya dari tabel ini
|
||||||
|
CREATE TABLE collection_broadcasts.broadcast_staging (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Sopiga config
|
||||||
|
sopiga_collar_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_collar_config(sopiga_collar_id),
|
||||||
|
sopiga_template_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_template_config(sopiga_template_id),
|
||||||
|
|
||||||
|
-- Message payload - SINGLE SOURCE OF TRUTH
|
||||||
|
-- Contains all fields needed: nasabah_nama, nasabah_phone, nominal_tagihan, tanggal_tempo, invoice_pdf_url, contract_no, etc
|
||||||
|
-- Struktur dinamis sesuai template variables
|
||||||
|
-- Example: {
|
||||||
|
-- "nasabah_nama": "Budi Santoso",
|
||||||
|
-- "nasabah_phone": "6281234567890",
|
||||||
|
-- "nominal_tagihan": 1500000,
|
||||||
|
-- "tanggal_tempo": "2026-06-30",
|
||||||
|
-- "invoice_pdf_url": "https://storage.gadai.com/invoices/inv-001.pdf",
|
||||||
|
-- "contract_no": "GAD-2026-001"
|
||||||
|
-- }
|
||||||
|
message_payload JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Status tracking
|
||||||
|
status VARCHAR(50) DEFAULT 'pending' NOT NULL,
|
||||||
|
-- pending: waiting to dispatch
|
||||||
|
-- dispatched: sent to Sopiga queue (HTTP 202 Accepted)
|
||||||
|
-- delivered: confirmed delivered to WhatsApp
|
||||||
|
-- failed: error occurred
|
||||||
|
-- retry_scheduled: queued for retry
|
||||||
|
error_message TEXT,
|
||||||
|
error_count INT DEFAULT 0,
|
||||||
|
|
||||||
|
-- Sopiga response
|
||||||
|
sopiga_recipient_detail_id BIGINT,
|
||||||
|
sopiga_external_id VARCHAR(100),
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
dispatched_at TIMESTAMP,
|
||||||
|
delivered_at TIMESTAMP,
|
||||||
|
failed_at TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT status_valid CHECK (status IN ('pending', 'dispatched', 'delivered', 'failed', 'retry_scheduled')),
|
||||||
|
CONSTRAINT error_count_positive CHECK (error_count >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes untuk worker & monitoring queries
|
||||||
|
CREATE INDEX idx_broadcast_staging_status ON collection_broadcasts.broadcast_staging(status);
|
||||||
|
CREATE INDEX idx_broadcast_staging_created_at ON collection_broadcasts.broadcast_staging(created_at DESC);
|
||||||
|
CREATE INDEX idx_broadcast_staging_collar_id ON collection_broadcasts.broadcast_staging(sopiga_collar_id);
|
||||||
|
CREATE INDEX idx_broadcast_staging_template_id ON collection_broadcasts.broadcast_staging(sopiga_template_id);
|
||||||
|
|
||||||
|
-- Composite index untuk worker (most frequent query)
|
||||||
|
CREATE INDEX idx_broadcast_staging_pending_query
|
||||||
|
ON collection_broadcasts.broadcast_staging(status, created_at ASC)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- Composite index untuk retry queue
|
||||||
|
CREATE INDEX idx_broadcast_staging_retry_query
|
||||||
|
ON collection_broadcasts.broadcast_staging(status, error_count, created_at ASC)
|
||||||
|
WHERE status = 'retry_scheduled' AND error_count < 3;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 3. AUDIT & LOGGING
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Status transition audit log
|
||||||
|
CREATE TABLE collection_broadcasts.broadcast_audit_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
broadcast_id BIGINT NOT NULL REFERENCES collection_broadcasts.broadcast_staging(id) ON DELETE CASCADE,
|
||||||
|
old_status VARCHAR(50),
|
||||||
|
new_status VARCHAR(50) NOT NULL,
|
||||||
|
reason VARCHAR(500),
|
||||||
|
sopiga_response JSONB,
|
||||||
|
changed_by VARCHAR(100) DEFAULT 'system',
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_broadcast_audit_log_broadcast_id ON collection_broadcasts.broadcast_audit_log(broadcast_id);
|
||||||
|
CREATE INDEX idx_broadcast_audit_log_created_at ON collection_broadcasts.broadcast_audit_log(created_at DESC);
|
||||||
|
|
||||||
|
-- Error tracking
|
||||||
|
CREATE TABLE collection_broadcasts.broadcast_error_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
broadcast_id BIGINT NOT NULL REFERENCES collection_broadcasts.broadcast_staging(id) ON DELETE CASCADE,
|
||||||
|
error_type VARCHAR(100), -- network_error, api_error, validation_error
|
||||||
|
error_code VARCHAR(50),
|
||||||
|
error_message TEXT,
|
||||||
|
error_details JSONB,
|
||||||
|
attempt_number INT,
|
||||||
|
next_retry_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_broadcast_error_log_broadcast_id ON collection_broadcasts.broadcast_error_log(broadcast_id);
|
||||||
|
CREATE INDEX idx_broadcast_error_log_error_type ON collection_broadcasts.broadcast_error_log(error_type);
|
||||||
|
|
||||||
|
-- Sopiga delivery status sync
|
||||||
|
CREATE TABLE collection_broadcasts.sopiga_sync_job (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
broadcast_id BIGINT NOT NULL REFERENCES collection_broadcasts.broadcast_staging(id) ON DELETE CASCADE,
|
||||||
|
sopiga_recipient_detail_id BIGINT,
|
||||||
|
last_synced_at TIMESTAMP,
|
||||||
|
last_status_from_sopiga VARCHAR(50),
|
||||||
|
sync_count INT DEFAULT 0,
|
||||||
|
next_sync_at TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sopiga_sync_job_broadcast_id ON collection_broadcasts.sopiga_sync_job(broadcast_id);
|
||||||
|
CREATE INDEX idx_sopiga_sync_job_next_sync_at ON collection_broadcasts.sopiga_sync_job(next_sync_at);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 4. VIEWS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Template variables dengan urutan (untuk Worker reference)
|
||||||
|
CREATE VIEW collection_broadcasts.v_template_variables_ordered AS
|
||||||
|
SELECT
|
||||||
|
tvm.sopiga_template_id,
|
||||||
|
stc.template_name,
|
||||||
|
tvm.variable_order,
|
||||||
|
tvm.sopiga_variable_name,
|
||||||
|
tvm.variable_type,
|
||||||
|
tvm.db_field_source,
|
||||||
|
tvm.is_required,
|
||||||
|
tvm.example_value
|
||||||
|
FROM collection_broadcasts.template_variable_mapping tvm
|
||||||
|
JOIN collection_broadcasts.sopiga_template_config stc ON tvm.sopiga_template_id = stc.sopiga_template_id
|
||||||
|
WHERE stc.active = TRUE
|
||||||
|
ORDER BY tvm.sopiga_template_id, tvm.variable_order;
|
||||||
|
|
||||||
|
-- Collar summary
|
||||||
|
CREATE VIEW collection_broadcasts.v_collar_summary AS
|
||||||
|
SELECT
|
||||||
|
sc.sopiga_collar_id,
|
||||||
|
sc.broadcast_name,
|
||||||
|
COUNT(*) as total_records,
|
||||||
|
COUNT(CASE WHEN bs.status = 'pending' THEN 1 END) as pending,
|
||||||
|
COUNT(CASE WHEN bs.status = 'dispatched' THEN 1 END) as dispatched,
|
||||||
|
COUNT(CASE WHEN bs.status = 'delivered' THEN 1 END) as delivered,
|
||||||
|
COUNT(CASE WHEN bs.status = 'failed' THEN 1 END) as failed,
|
||||||
|
ROUND(100.0 * COUNT(CASE WHEN bs.status = 'delivered' THEN 1 END) / NULLIF(COUNT(*), 0), 2) as delivery_rate_percent
|
||||||
|
FROM collection_broadcasts.sopiga_collar_config sc
|
||||||
|
LEFT JOIN collection_broadcasts.broadcast_staging bs ON sc.sopiga_collar_id = bs.sopiga_collar_id
|
||||||
|
GROUP BY sc.sopiga_collar_id, sc.broadcast_name;
|
||||||
|
|
||||||
|
-- Failed records (last 24h)
|
||||||
|
CREATE VIEW collection_broadcasts.v_failed_records_24h AS
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
sopiga_collar_id,
|
||||||
|
sopiga_template_id,
|
||||||
|
message_payload->>'nasabah_nama' as nasabah_nama,
|
||||||
|
message_payload->>'nasabah_phone' as nasabah_phone,
|
||||||
|
error_message,
|
||||||
|
error_count,
|
||||||
|
failed_at,
|
||||||
|
created_at
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE status = 'failed' AND created_at > NOW() - INTERVAL 1 DAY
|
||||||
|
ORDER BY failed_at DESC;
|
||||||
|
|
||||||
|
-- Delivery rate (last 24h)
|
||||||
|
CREATE VIEW collection_broadcasts.v_delivery_rate_24h AS
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(CASE WHEN status = 'delivered' THEN 1 END) as delivered,
|
||||||
|
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed,
|
||||||
|
COUNT(CASE WHEN status = 'dispatched' THEN 1 END) as in_progress,
|
||||||
|
ROUND(100.0 * COUNT(CASE WHEN status = 'delivered' THEN 1 END) / NULLIF(COUNT(*), 0), 2) as delivery_rate_percent
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE created_at > NOW() - INTERVAL 1 DAY;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 5. HELPER FUNCTIONS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Get template variables by template_id
|
||||||
|
CREATE OR REPLACE FUNCTION collection_broadcasts.get_template_variables(
|
||||||
|
p_sopiga_template_id INT
|
||||||
|
)
|
||||||
|
RETURNS TABLE(
|
||||||
|
variable_order INT,
|
||||||
|
sopiga_variable_name VARCHAR,
|
||||||
|
variable_type VARCHAR,
|
||||||
|
db_field_source VARCHAR,
|
||||||
|
is_required BOOLEAN
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
tvm.variable_order,
|
||||||
|
tvm.sopiga_variable_name,
|
||||||
|
tvm.variable_type,
|
||||||
|
tvm.db_field_source,
|
||||||
|
tvm.is_required
|
||||||
|
FROM collection_broadcasts.template_variable_mapping tvm
|
||||||
|
WHERE tvm.sopiga_template_id = p_sopiga_template_id
|
||||||
|
ORDER BY tvm.variable_order ASC;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Update status dengan audit log
|
||||||
|
CREATE OR REPLACE FUNCTION collection_broadcasts.update_broadcast_status(
|
||||||
|
p_broadcast_id BIGINT,
|
||||||
|
p_new_status VARCHAR,
|
||||||
|
p_error_message TEXT DEFAULT NULL,
|
||||||
|
p_sopiga_response JSONB DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_old_status VARCHAR;
|
||||||
|
BEGIN
|
||||||
|
SELECT status INTO v_old_status
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE id = p_broadcast_id;
|
||||||
|
|
||||||
|
UPDATE collection_broadcasts.broadcast_staging
|
||||||
|
SET
|
||||||
|
status = p_new_status,
|
||||||
|
error_message = p_error_message,
|
||||||
|
updated_at = NOW(),
|
||||||
|
dispatched_at = CASE WHEN p_new_status = 'dispatched' THEN NOW() ELSE dispatched_at END,
|
||||||
|
delivered_at = CASE WHEN p_new_status = 'delivered' THEN NOW() ELSE delivered_at END,
|
||||||
|
failed_at = CASE WHEN p_new_status = 'failed' THEN NOW() ELSE failed_at END
|
||||||
|
WHERE id = p_broadcast_id;
|
||||||
|
|
||||||
|
INSERT INTO collection_broadcasts.broadcast_audit_log (broadcast_id, old_status, new_status, sopiga_response)
|
||||||
|
VALUES (p_broadcast_id, v_old_status, p_new_status, p_sopiga_response);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 6. INITIALIZATION DATA
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Insert sample template
|
||||||
|
INSERT INTO collection_broadcasts.sopiga_template_config (template_name, sopiga_template_id, channel, template_type, description)
|
||||||
|
VALUES ('Collection Invoice May 2026', 2, 'waba', 'utility', 'Invoice bulanan untuk collection Mei 2026');
|
||||||
|
|
||||||
|
-- Insert template variables
|
||||||
|
INSERT INTO collection_broadcasts.template_variable_mapping (sopiga_template_id, variable_order, sopiga_variable_name, variable_type, db_field_source, is_required, example_value)
|
||||||
|
VALUES
|
||||||
|
(2, 1, 'Nama', 'string', 'nasabah_nama', TRUE, 'Budi Santoso'),
|
||||||
|
(2, 2, 'TotalTagihan', 'integer', 'nominal_tagihan', TRUE, '1500000'),
|
||||||
|
(2, 3, 'TanggalJatuhTempo', 'date', 'tanggal_tempo', TRUE, '2026-06-30');
|
||||||
|
|
||||||
|
-- Insert collar config
|
||||||
|
INSERT INTO collection_broadcasts.sopiga_collar_config (broadcast_name, sopiga_collar_id, sopiga_template_id, status, description)
|
||||||
|
VALUES ('Collection Invoices May 2026', 70, 2, 'open', 'Broadcast collar untuk collection invoice bulanan Mei 2026');
|
||||||
453
docs/implementasi_guide.md
Normal file
453
docs/implementasi_guide.md
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
# Gadai Mulia Collection Broadcast Integration
|
||||||
|
## Complete Implementation Guide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Overview
|
||||||
|
|
||||||
|
**Purpose:** Send collection invoices via WhatsApp (Sopiga Collar API) to Gadai Mulia customers.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
```
|
||||||
|
Gadai Collection Service
|
||||||
|
→ INSERT broadcast_staging (JSON payload)
|
||||||
|
→ Go Worker (poll every 30s)
|
||||||
|
→ Query template_variable_mapping
|
||||||
|
→ Build message dynamically
|
||||||
|
→ POST Sopiga Collar API
|
||||||
|
→ WhatsApp delivery
|
||||||
|
→ Update status (delivered/failed)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Innovation:** Flexible template system via `template_variable_mapping` table — NO code redeploy for new templates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ Database Schema (Simplified Denormalized)
|
||||||
|
|
||||||
|
### Master Configuration (Read-only)
|
||||||
|
|
||||||
|
**Table: sopiga_template_config**
|
||||||
|
- Stores Sopiga template references (pre-created in Sopiga)
|
||||||
|
- Fields: `id`, `sopiga_template_id`, `template_name`, `channel` (waba), `template_type`, `description`, `active`
|
||||||
|
- Pre-populate: One row per template in Sopiga
|
||||||
|
|
||||||
|
**Table: template_variable_mapping**
|
||||||
|
- Maps Sopiga template variables → message_payload keys
|
||||||
|
- **CRITICAL:** `variable_order` determines message interpolation sequence
|
||||||
|
- Fields: `id`, `sopiga_template_id`, `variable_order`, `sopiga_variable_name`, `variable_type` (string/integer/date/decimal), `db_field_source`, `is_required`, `example_value`
|
||||||
|
- Example:
|
||||||
|
```
|
||||||
|
sopiga_template_id=2, order=1, name='Nama', field='nasabah_nama'
|
||||||
|
sopiga_template_id=2, order=2, name='TotalTagihan', field='nominal_tagihan'
|
||||||
|
sopiga_template_id=2, order=3, name='TanggalJatuhTempo', field='tanggal_tempo'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Table: sopiga_collar_config**
|
||||||
|
- Stores Sopiga collar references (pre-created in Sopiga)
|
||||||
|
- Fields: `id`, `sopiga_collar_id`, `broadcast_name`, `sopiga_template_id` (FK), `status` (open/closed), `description`
|
||||||
|
- Pre-populate: One row per collar in Sopiga
|
||||||
|
|
||||||
|
### Processing (Core)
|
||||||
|
|
||||||
|
**Table: broadcast_staging** ← Main table
|
||||||
|
- DENORMALIZED: All data in `message_payload` (JSONB)
|
||||||
|
- NO FK to nasabah or gadai_contract
|
||||||
|
- Fields:
|
||||||
|
- `id` (PK)
|
||||||
|
- `sopiga_collar_id` (FK)
|
||||||
|
- `sopiga_template_id` (FK)
|
||||||
|
- `message_payload` (JSONB) ← **Single source of truth**
|
||||||
|
- `status` (pending → dispatched → delivered/failed)
|
||||||
|
- `error_message`, `error_count`
|
||||||
|
- `sopiga_recipient_detail_id` (response from Sopiga)
|
||||||
|
- `created_at`, `updated_at`, `dispatched_at`, `delivered_at`, `failed_at`
|
||||||
|
|
||||||
|
**Example message_payload:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nasabah_nama": "Budi Santoso",
|
||||||
|
"nasabah_phone": "6281234567890",
|
||||||
|
"nominal_tagihan": 1500000,
|
||||||
|
"tanggal_tempo": "2026-06-30",
|
||||||
|
"invoice_pdf_url": "https://storage.gadai.com/invoices/inv-001.pdf",
|
||||||
|
"contract_no": "GAD-2026-001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit & Tracking
|
||||||
|
|
||||||
|
**Table: broadcast_audit_log**
|
||||||
|
- Tracks status transitions
|
||||||
|
- Fields: `id`, `broadcast_id` (FK), `old_status`, `new_status`, `reason`, `sopiga_response` (JSONB), `changed_by`, `created_at`
|
||||||
|
|
||||||
|
**Table: broadcast_error_log**
|
||||||
|
- Detailed error tracking
|
||||||
|
- Fields: `id`, `broadcast_id` (FK), `error_type`, `error_code`, `error_message`, `error_details` (JSONB), `attempt_number`, `next_retry_at`
|
||||||
|
|
||||||
|
**Table: sopiga_sync_job**
|
||||||
|
- Tracks delivery status sync from Sopiga
|
||||||
|
- Fields: `id`, `broadcast_id` (FK), `sopiga_recipient_detail_id`, `last_synced_at`, `last_status_from_sopiga`, `sync_count`, `next_sync_at`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Setup Process
|
||||||
|
|
||||||
|
### Step 1: Setup in Sopiga (Manual)
|
||||||
|
|
||||||
|
1. **Create template** in Sopiga:
|
||||||
|
```
|
||||||
|
POST https://omnix.promas.site/api/client/template
|
||||||
|
{
|
||||||
|
"template_name": "Collection Invoice",
|
||||||
|
"channel": "waba",
|
||||||
|
"variables": [
|
||||||
|
{"name": "Nama", "type": "string"},
|
||||||
|
{"name": "TotalTagihan", "type": "integer"},
|
||||||
|
{"name": "TanggalJatuhTempo", "type": "date"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
→ Response: template_id = 2
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create collar** in Sopiga:
|
||||||
|
```
|
||||||
|
POST https://omnix.promas.site/api/client/collar
|
||||||
|
{
|
||||||
|
"judul_broadcast": "Invoice May 2026",
|
||||||
|
"template_id": 2
|
||||||
|
}
|
||||||
|
→ Response: collar_id = 70
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Register in Database (5 minutes)
|
||||||
|
|
||||||
|
**2.1 Insert template config:**
|
||||||
|
```sql
|
||||||
|
INSERT INTO collection_broadcasts.sopiga_template_config
|
||||||
|
(template_name, sopiga_template_id, channel, template_type, description)
|
||||||
|
VALUES
|
||||||
|
('Collection Invoice May 2026', 2, 'waba', 'utility', 'Invoice bulanan');
|
||||||
|
```
|
||||||
|
|
||||||
|
**2.2 Insert variable mappings (ORDER IS CRITICAL!):**
|
||||||
|
```sql
|
||||||
|
INSERT INTO collection_broadcasts.template_variable_mapping
|
||||||
|
(sopiga_template_id, variable_order, sopiga_variable_name, variable_type, db_field_source, is_required, example_value)
|
||||||
|
VALUES
|
||||||
|
(2, 1, 'Nama', 'string', 'nasabah_nama', TRUE, 'Budi Santoso'),
|
||||||
|
(2, 2, 'TotalTagihan', 'integer', 'nominal_tagihan', TRUE, '1500000'),
|
||||||
|
(2, 3, 'TanggalJatuhTempo', 'date', 'tanggal_tempo', TRUE, '2026-06-30');
|
||||||
|
```
|
||||||
|
|
||||||
|
**2.3 Insert collar config:**
|
||||||
|
```sql
|
||||||
|
INSERT INTO collection_broadcasts.sopiga_collar_config
|
||||||
|
(broadcast_name, sopiga_collar_id, sopiga_template_id, status)
|
||||||
|
VALUES
|
||||||
|
('Collection Invoices May 2026', 70, 2, 'open');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Deploy Application
|
||||||
|
|
||||||
|
Deploy Go worker with:
|
||||||
|
- Database connection string
|
||||||
|
- Sopiga base URL: `https://omnix.promas.site`
|
||||||
|
- Sopiga API token
|
||||||
|
|
||||||
|
Worker runs continuously:
|
||||||
|
- **Broadcast dispatch:** Poll every 30s, process pending records
|
||||||
|
- **Status sync:** Poll every 5 min, sync delivery status from Sopiga
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 How It Works
|
||||||
|
|
||||||
|
### Runtime Flow
|
||||||
|
|
||||||
|
**Gadai Collection Service (sends data):**
|
||||||
|
```go
|
||||||
|
// When collection reminder needed:
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"nasabah_nama": "Budi Santoso",
|
||||||
|
"nasabah_phone": "6281234567890",
|
||||||
|
"nominal_tagihan": 1500000.0,
|
||||||
|
"tanggal_tempo": "2026-06-30",
|
||||||
|
"invoice_pdf_url": "https://storage.gadai.com/invoices/inv-001.pdf",
|
||||||
|
"contract_no": "GAD-2026-001",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert to broadcast_staging
|
||||||
|
db.Exec(`
|
||||||
|
INSERT INTO collection_broadcasts.broadcast_staging
|
||||||
|
(sopiga_collar_id, sopiga_template_id, message_payload, status)
|
||||||
|
VALUES ($1, $2, $3, 'pending')
|
||||||
|
`, 70, 2, payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Go Worker (processes data - automatic):**
|
||||||
|
```
|
||||||
|
1. Poll broadcast_staging WHERE status='pending' LIMIT 100
|
||||||
|
2. For each record:
|
||||||
|
a. Get message_payload from database (already has all fields)
|
||||||
|
b. Query template_variable_mapping WHERE sopiga_template_id=2 ORDER BY variable_order
|
||||||
|
c. Build message:
|
||||||
|
- For each variable in order (1,2,3...):
|
||||||
|
- Get value from payload[db_field_source]
|
||||||
|
- Format by type (string/integer/date)
|
||||||
|
- Append to parts[]
|
||||||
|
- message = parts.join("#")
|
||||||
|
- Result: "Budi Santoso#1500000#2026-06-30"
|
||||||
|
|
||||||
|
d. Call Sopiga API:
|
||||||
|
POST /api/client/collar/add-recipient
|
||||||
|
{
|
||||||
|
"broadcast_id": 70,
|
||||||
|
"template_id": 2,
|
||||||
|
"details": {
|
||||||
|
"recipient": "6281234567890",
|
||||||
|
"message": "Budi Santoso#1500000#2026-06-30"
|
||||||
|
},
|
||||||
|
"attachment": {
|
||||||
|
"type": "document",
|
||||||
|
"file": "https://storage.gadai.com/invoices/inv-001.pdf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e. Response (HTTP 202 Accepted):
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"recipient_detail_id": 512,
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f. Update broadcast_staging:
|
||||||
|
UPDATE status='dispatched', sopiga_recipient_detail_id=512
|
||||||
|
|
||||||
|
3. Status sync worker (every 5 min):
|
||||||
|
- For dispatched records:
|
||||||
|
- GET /api/client/collar/add-recipient/{recipient_detail_id}/detail
|
||||||
|
- Update status: delivered OR failed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Adding New Template (Zero Code Change)
|
||||||
|
|
||||||
|
### Scenario: Template now has 4 variables instead of 3
|
||||||
|
|
||||||
|
**Old:**
|
||||||
|
```
|
||||||
|
1. Nama
|
||||||
|
2. TotalTagihan
|
||||||
|
3. TanggalJatuhTempo
|
||||||
|
```
|
||||||
|
|
||||||
|
**New:**
|
||||||
|
```
|
||||||
|
1. Nama
|
||||||
|
2. NoKontrak ← NEW!
|
||||||
|
3. TotalTagihan
|
||||||
|
4. TanggalJatuhTempo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup (5 minutes, Database only)
|
||||||
|
|
||||||
|
1. Create template in Sopiga → template_id = 3
|
||||||
|
2. Create collar in Sopiga → collar_id = 71
|
||||||
|
|
||||||
|
3. Register in database:
|
||||||
|
```sql
|
||||||
|
-- Template
|
||||||
|
INSERT INTO sopiga_template_config (template_name, sopiga_template_id, ...)
|
||||||
|
VALUES ('Collection Invoice Extended', 3, ...);
|
||||||
|
|
||||||
|
-- Variables (URUTAN PENTING!)
|
||||||
|
INSERT INTO template_variable_mapping
|
||||||
|
(sopiga_template_id, variable_order, sopiga_variable_name, variable_type, db_field_source, ...)
|
||||||
|
VALUES
|
||||||
|
(3, 1, 'Nama', 'string', 'nasabah_nama', ...),
|
||||||
|
(3, 2, 'NoKontrak', 'string', 'contract_no', ...), ← NEW!
|
||||||
|
(3, 3, 'TotalTagihan', 'integer', 'nominal_tagihan', ...),
|
||||||
|
(3, 4, 'TanggalJatuhTempo', 'date', 'tanggal_tempo', ...);
|
||||||
|
|
||||||
|
-- Collar
|
||||||
|
INSERT INTO sopiga_collar_config (broadcast_name, sopiga_collar_id, sopiga_template_id, ...)
|
||||||
|
VALUES ('Collection Invoices June 2026', 71, 3, ...);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Go code?** ✅ ZERO changes — `buildDynamicMessage()` queries mapping every time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Gadai Service Integration
|
||||||
|
|
||||||
|
**How Gadai Collection Service calls this:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
package gadai
|
||||||
|
|
||||||
|
type CollectionBroadcastService struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CollectionBroadcastService) SendInvoiceReminder(
|
||||||
|
ctx context.Context,
|
||||||
|
nasabahID, contractNo string,
|
||||||
|
nominalTagihan float64,
|
||||||
|
dueDate time.Time,
|
||||||
|
) error {
|
||||||
|
// Get invoice URL (from Gadai storage or API)
|
||||||
|
invoiceURL := fmt.Sprintf("https://storage.gadai.com/invoices/%s.pdf", contractNo)
|
||||||
|
|
||||||
|
// Build payload (flexible - can have extra fields)
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"nasabah_nama": "Budi Santoso",
|
||||||
|
"nasabah_phone": "6281234567890",
|
||||||
|
"nominal_tagihan": nominalTagihan,
|
||||||
|
"tanggal_tempo": dueDate.Format("2006-01-02"),
|
||||||
|
"invoice_pdf_url": invoiceURL,
|
||||||
|
"contract_no": contractNo,
|
||||||
|
"cif": "12345", // Extra field - ignored by worker if not in mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal to JSONB
|
||||||
|
payloadJSON, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
// Insert to broadcast_staging
|
||||||
|
// Worker will automatically pick it up (every 30s poll)
|
||||||
|
query := `
|
||||||
|
INSERT INTO collection_broadcasts.broadcast_staging
|
||||||
|
(sopiga_collar_id, sopiga_template_id, message_payload, status)
|
||||||
|
VALUES ($1, $2, $3, 'pending')
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := s.db.ExecContext(ctx, query,
|
||||||
|
70, // sopiga_collar_id (hardcoded or from config)
|
||||||
|
2, // sopiga_template_id (hardcoded or from config)
|
||||||
|
payloadJSON,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Monitoring & Reporting
|
||||||
|
|
||||||
|
### Views Available
|
||||||
|
|
||||||
|
**v_template_variables_ordered**
|
||||||
|
```sql
|
||||||
|
SELECT * FROM collection_broadcasts.v_template_variables_ordered;
|
||||||
|
-- Shows all template variables with proper ordering
|
||||||
|
```
|
||||||
|
|
||||||
|
**v_collar_summary**
|
||||||
|
```sql
|
||||||
|
SELECT * FROM collection_broadcasts.v_collar_summary;
|
||||||
|
-- Shows delivery rate per collar
|
||||||
|
```
|
||||||
|
|
||||||
|
**v_delivery_rate_24h**
|
||||||
|
```sql
|
||||||
|
SELECT * FROM collection_broadcasts.v_delivery_rate_24h;
|
||||||
|
-- Shows delivery rate for last 24 hours
|
||||||
|
```
|
||||||
|
|
||||||
|
**v_failed_records_24h**
|
||||||
|
```sql
|
||||||
|
SELECT * FROM collection_broadcasts.v_failed_records_24h;
|
||||||
|
-- Shows failed records with error details
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Message format wrong
|
||||||
|
|
||||||
|
**Cause:** `variable_order` in mapping doesn't match Sopiga template order
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```sql
|
||||||
|
-- Check current mapping
|
||||||
|
SELECT variable_order, sopiga_variable_name FROM template_variable_mapping
|
||||||
|
WHERE sopiga_template_id=2 ORDER BY variable_order;
|
||||||
|
|
||||||
|
-- If order wrong, delete and re-insert correctly
|
||||||
|
DELETE FROM template_variable_mapping WHERE sopiga_template_id=2;
|
||||||
|
-- Re-insert with correct order
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Missing field in message_payload
|
||||||
|
|
||||||
|
**Cause:** Gadai service didn't include field in payload, but it's marked required in mapping
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```sql
|
||||||
|
-- Either make it optional
|
||||||
|
UPDATE template_variable_mapping SET is_required=FALSE
|
||||||
|
WHERE sopiga_variable_name='NoKontrak';
|
||||||
|
|
||||||
|
-- Or ensure Gadai service includes it
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Delivery status stuck at 'dispatched'
|
||||||
|
|
||||||
|
**Cause:** Status sync worker not running or Sopiga API unreachable
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
```sql
|
||||||
|
-- Check sync jobs
|
||||||
|
SELECT * FROM sopiga_sync_job WHERE next_sync_at < NOW();
|
||||||
|
|
||||||
|
-- Manually trigger status check
|
||||||
|
SELECT collection_broadcasts.get_template_variables(2);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Security Considerations
|
||||||
|
|
||||||
|
1. **Sopiga API Token:** Store in environment variable, never hardcode
|
||||||
|
2. **Database:** Restrict access to `collection_broadcasts` schema
|
||||||
|
3. **Phone Numbers:** Already PII, treat as sensitive
|
||||||
|
4. **PDFs:** Ensure URLs are time-limited or access-controlled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Checklist: New Template
|
||||||
|
|
||||||
|
- [ ] Create template in Sopiga, note `template_id`
|
||||||
|
- [ ] Create collar in Sopiga, note `collar_id`
|
||||||
|
- [ ] Insert `sopiga_template_config` row
|
||||||
|
- [ ] Insert `template_variable_mapping` rows (check order!)
|
||||||
|
- [ ] Insert `sopiga_collar_config` row
|
||||||
|
- [ ] Verify mapping: `SELECT * FROM v_template_variables_ordered WHERE sopiga_template_id=X`
|
||||||
|
- [ ] Test with sample record in `broadcast_staging`
|
||||||
|
- [ ] Monitor first 10 deliveries in logs
|
||||||
|
- [ ] Confirm in `v_collar_summary` delivery rate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
**Schema files:**
|
||||||
|
- `gadai_collection_broadcast_simplified_schema.sql` — Complete DDL
|
||||||
|
|
||||||
|
**Application files:**
|
||||||
|
- `collection_broadcast_worker_simplified.go` — Go worker implementation
|
||||||
|
|
||||||
|
**Deployment:**
|
||||||
|
- Requires: PostgreSQL 11+, Go 1.16+
|
||||||
|
- Connects to: Sopiga API at `https://omnix.promas.site`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Last Updated:** 2026-08-03
|
||||||
|
**Status:** Ready for deployment ✅
|
||||||
319
docs/quick_start.md
Normal file
319
docs/quick_start.md
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
# Quick Start Guide
|
||||||
|
## Gadai Mulia Collection Broadcast Integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ 5-Minute Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- PostgreSQL database access
|
||||||
|
- Sopiga API credentials (token)
|
||||||
|
- Go 1.16+ (for worker deployment)
|
||||||
|
|
||||||
|
### Step 1: Create Database Schema (2 minutes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run SQL schema
|
||||||
|
psql -U postgres -h localhost -d gadai_mulia < gadai_collection_broadcast_simplified_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify schema created:
|
||||||
|
```sql
|
||||||
|
\dt collection_broadcasts.*
|
||||||
|
-- Should show: sopiga_template_config, template_variable_mapping, sopiga_collar_config, broadcast_staging, etc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Setup Sopiga Template (2 minutes)
|
||||||
|
|
||||||
|
**In Sopiga UI or API:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create template
|
||||||
|
curl -X POST https://omnix.promas.site/api/client/template \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"template_name": "Collection Invoice",
|
||||||
|
"channel": "waba",
|
||||||
|
"variables": [
|
||||||
|
{"name": "Nama", "type": "string"},
|
||||||
|
{"name": "TotalTagihan", "type": "integer"},
|
||||||
|
{"name": "TanggalJatuhTempo", "type": "date"}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
# Response: {"data": {"id": 2}}
|
||||||
|
|
||||||
|
# Create collar
|
||||||
|
curl -X POST https://omnix.promas.site/api/client/collar \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"judul_broadcast": "Invoice May 2026",
|
||||||
|
"template_id": 2
|
||||||
|
}'
|
||||||
|
# Response: {"data": {"id": 70}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note down:
|
||||||
|
- `template_id = 2`
|
||||||
|
- `collar_id = 70`
|
||||||
|
|
||||||
|
### Step 3: Register in Database (1 minute)
|
||||||
|
|
||||||
|
**Connect to PostgreSQL:**
|
||||||
|
```bash
|
||||||
|
psql -U postgres -h localhost -d gadai_mulia
|
||||||
|
```
|
||||||
|
|
||||||
|
**Insert template:**
|
||||||
|
```sql
|
||||||
|
INSERT INTO collection_broadcasts.sopiga_template_config
|
||||||
|
(template_name, sopiga_template_id, channel, template_type, description)
|
||||||
|
VALUES
|
||||||
|
('Collection Invoice May 2026', 2, 'waba', 'utility', 'Invoice bulanan untuk collection');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Insert variable mappings (URUTAN PENTING!):**
|
||||||
|
```sql
|
||||||
|
INSERT INTO collection_broadcasts.template_variable_mapping
|
||||||
|
(sopiga_template_id, variable_order, sopiga_variable_name, variable_type, db_field_source, is_required, example_value)
|
||||||
|
VALUES
|
||||||
|
(2, 1, 'Nama', 'string', 'nasabah_nama', TRUE, 'Budi Santoso'),
|
||||||
|
(2, 2, 'TotalTagihan', 'integer', 'nominal_tagihan', TRUE, '1500000'),
|
||||||
|
(2, 3, 'TanggalJatuhTempo', 'date', 'tanggal_tempo', TRUE, '2026-06-30');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Insert collar config:**
|
||||||
|
```sql
|
||||||
|
INSERT INTO collection_broadcasts.sopiga_collar_config
|
||||||
|
(broadcast_name, sopiga_collar_id, sopiga_template_id, status, description)
|
||||||
|
VALUES
|
||||||
|
('Collection Invoices May 2026', 70, 2, 'open', 'Broadcast collar untuk collection invoice bulanan Mei 2026');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify setup:**
|
||||||
|
```sql
|
||||||
|
SELECT * FROM collection_broadcasts.v_template_variables_ordered
|
||||||
|
WHERE sopiga_template_id = 2;
|
||||||
|
-- Should show 3 rows with order 1, 2, 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Deploy Worker (1 minute)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
go build -o collection_broadcast_worker collection_broadcast_worker_simplified.go
|
||||||
|
|
||||||
|
# Run with environment variables
|
||||||
|
export DB_HOST=localhost
|
||||||
|
export DB_PORT=5432
|
||||||
|
export DB_NAME=gadai_mulia
|
||||||
|
export DB_USER=postgres
|
||||||
|
export DB_PASSWORD=xxx
|
||||||
|
export SOPIGA_BASE_URL=https://omnix.promas.site
|
||||||
|
export SOPIGA_TOKEN=your_api_token
|
||||||
|
|
||||||
|
./collection_broadcast_worker
|
||||||
|
# Output: Collection Broadcast Worker started
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Test It
|
||||||
|
|
||||||
|
### Insert Test Record
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Insert to broadcast_staging
|
||||||
|
INSERT INTO collection_broadcasts.broadcast_staging
|
||||||
|
(sopiga_collar_id, sopiga_template_id, message_payload, status)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
70,
|
||||||
|
2,
|
||||||
|
'{"nasabah_nama": "Budi Santoso", "nasabah_phone": "6281234567890", "nominal_tagihan": 1500000, "tanggal_tempo": "2026-06-30", "invoice_pdf_url": "https://storage.gadai.com/invoices/inv-001.pdf"}',
|
||||||
|
'pending'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Check it's pending
|
||||||
|
SELECT id, status, created_at FROM collection_broadcasts.broadcast_staging WHERE status='pending';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wait 30 seconds
|
||||||
|
|
||||||
|
Worker polls every 30 seconds. Check status:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT id, status, sopiga_recipient_detail_id, dispatched_at, error_message
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE id = 1;
|
||||||
|
|
||||||
|
-- Should show: status='dispatched', sopiga_recipient_detail_id=512
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check WhatsApp Delivery
|
||||||
|
|
||||||
|
After 5 minutes, check sync job:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT id, status, last_status_from_sopiga, delivered_at
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE id = 1;
|
||||||
|
|
||||||
|
-- Should show: status='delivered', delivered_at=<timestamp>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Monitor
|
||||||
|
|
||||||
|
### Check Delivery Rate
|
||||||
|
```sql
|
||||||
|
SELECT * FROM collection_broadcasts.v_delivery_rate_24h;
|
||||||
|
-- delivery_rate_percent should be > 95%
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Failed Records
|
||||||
|
```sql
|
||||||
|
SELECT * FROM collection_broadcasts.v_failed_records_24h;
|
||||||
|
-- Should be empty or < 5%
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Collar Summary
|
||||||
|
```sql
|
||||||
|
SELECT * FROM collection_broadcasts.v_collar_summary;
|
||||||
|
-- See totals per collar, delivery rates
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ➕ Add New Template (5 minutes)
|
||||||
|
|
||||||
|
When Sopiga template changes (e.g., add `NoKontrak` field):
|
||||||
|
|
||||||
|
### 1. Create in Sopiga
|
||||||
|
```bash
|
||||||
|
# template_id = 3, collar_id = 71
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Register in Database
|
||||||
|
```sql
|
||||||
|
-- Template
|
||||||
|
INSERT INTO sopiga_template_config (template_name, sopiga_template_id, ...)
|
||||||
|
VALUES ('Collection Invoice Extended', 3, ...);
|
||||||
|
|
||||||
|
-- Variables (URUTAN PENTING!)
|
||||||
|
INSERT INTO template_variable_mapping
|
||||||
|
(sopiga_template_id, variable_order, sopiga_variable_name, variable_type, db_field_source, is_required)
|
||||||
|
VALUES
|
||||||
|
(3, 1, 'Nama', 'string', 'nasabah_nama', TRUE),
|
||||||
|
(3, 2, 'NoKontrak', 'string', 'contract_no', TRUE), ← NEW
|
||||||
|
(3, 3, 'TotalTagihan', 'integer', 'nominal_tagihan', TRUE),
|
||||||
|
(3, 4, 'TanggalJatuhTempo', 'date', 'tanggal_tempo', TRUE);
|
||||||
|
|
||||||
|
-- Collar
|
||||||
|
INSERT INTO sopiga_collar_config (broadcast_name, sopiga_collar_id, sopiga_template_id, status)
|
||||||
|
VALUES ('Collection Invoices June 2026', 71, 3, 'open');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. NO code redeploy needed ✅
|
||||||
|
|
||||||
|
Worker automatically picks up new template on next poll.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Gadai Service Integration
|
||||||
|
|
||||||
|
**How Gadai calls this:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "database/sql"
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
db, _ := sql.Open("postgres", "postgres://user:pass@localhost/gadai_mulia")
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"nasabah_nama": "Budi Santoso",
|
||||||
|
"nasabah_phone": "6281234567890",
|
||||||
|
"nominal_tagihan": 1500000.0,
|
||||||
|
"tanggal_tempo": "2026-06-30",
|
||||||
|
"invoice_pdf_url": "https://storage.gadai.com/invoices/inv-001.pdf",
|
||||||
|
"contract_no": "GAD-2026-001",
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadJSON, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
db.Exec(`
|
||||||
|
INSERT INTO collection_broadcasts.broadcast_staging
|
||||||
|
(sopiga_collar_id, sopiga_template_id, message_payload, status)
|
||||||
|
VALUES ($1, $2, $3, 'pending')
|
||||||
|
`, 70, 2, payloadJSON)
|
||||||
|
|
||||||
|
// Done! Worker picks it up automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### "Message format wrong"
|
||||||
|
→ Check template variable order matches Sopiga:
|
||||||
|
```sql
|
||||||
|
SELECT variable_order, sopiga_variable_name FROM template_variable_mapping
|
||||||
|
WHERE sopiga_template_id=2 ORDER BY variable_order;
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Record stuck in pending"
|
||||||
|
→ Check worker logs:
|
||||||
|
```bash
|
||||||
|
# Look for errors in stdout/stderr
|
||||||
|
# Check DB connection
|
||||||
|
psql -U postgres -h localhost -d gadai_mulia -c "SELECT 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Sopiga API errors"
|
||||||
|
→ Check token and URL:
|
||||||
|
```bash
|
||||||
|
curl -X GET https://omnix.promas.site/api/client/collar/list \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Checklist
|
||||||
|
|
||||||
|
- [ ] Schema created
|
||||||
|
- [ ] Sopiga template created (note template_id)
|
||||||
|
- [ ] Sopiga collar created (note collar_id)
|
||||||
|
- [ ] Database populated with template_id, collar_id
|
||||||
|
- [ ] Variable mappings inserted (check order!)
|
||||||
|
- [ ] Worker deployed and running
|
||||||
|
- [ ] Test record inserted
|
||||||
|
- [ ] Record moved to 'dispatched' after 30s
|
||||||
|
- [ ] Record moved to 'delivered' after 5 min
|
||||||
|
- [ ] WhatsApp message received on test phone
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Quick Reference
|
||||||
|
|
||||||
|
**Database:**
|
||||||
|
- Schema: `collection_broadcasts`
|
||||||
|
- Main table: `broadcast_staging`
|
||||||
|
- Config tables: `sopiga_template_config`, `template_variable_mapping`, `sopiga_collar_config`
|
||||||
|
|
||||||
|
**Sopiga URLs:**
|
||||||
|
- Base: `https://omnix.promas.site`
|
||||||
|
- Create template: POST `/api/client/template`
|
||||||
|
- Create collar: POST `/api/client/collar`
|
||||||
|
- Add recipient: POST `/api/client/collar/add-recipient`
|
||||||
|
- Get recipient detail: GET `/api/client/collar/add-recipient/{id}/detail`
|
||||||
|
|
||||||
|
**Worker:**
|
||||||
|
- Poll interval: 30 seconds (pending records)
|
||||||
|
- Status sync: 5 minutes (delivery status)
|
||||||
|
- Max retries: 3 (exponential backoff: 1s, 2s, 4s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Ready to go! 🚀**
|
||||||
89
docs/webhook_integration_request.md
Normal file
89
docs/webhook_integration_request.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Permintaan Integrasi Webhook — Collection Broadcast
|
||||||
|
|
||||||
|
**Ke:** Tim Omnix
|
||||||
|
**Dari:** Tim Collection Broadcast (Gadai Mulia)
|
||||||
|
**Tujuan:** Kami butuh notifikasi real-time saat status pengiriman WhatsApp
|
||||||
|
(sent/delivered/read/failed) berubah, supaya tidak perlu polling
|
||||||
|
`GET /api/client/collar/add-recipient/{id}/detail` berulang-ulang ke sistem
|
||||||
|
Omnix untuk setiap recipient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Yang kami minta dari tim Omnix
|
||||||
|
|
||||||
|
1. **Konfirmasi ketersediaan fitur** — apakah Omnix/Sopiga sudah punya (atau bisa
|
||||||
|
dibuatkan) mekanisme outgoing webhook saat status recipient di collar berubah?
|
||||||
|
(Kami cek dokumentasi publik di `/docs?api-docs.yaml` dan tidak menemukan
|
||||||
|
endpoint registrasi webhook untuk collar/recipient — hanya ada `webhook_url`
|
||||||
|
di level `WhatsAppSession`, yang tampaknya untuk keperluan lain.)
|
||||||
|
2. **Endpoint/cara registrasi** URL callback kami ke sistem Omnix (dashboard,
|
||||||
|
API, atau config manual).
|
||||||
|
3. **Shared secret** untuk signing payload (lihat §3) — dikirim lewat kanal aman
|
||||||
|
(bukan email biasa), atau kalau Omnix punya skema signature sendiri (mis. HMAC
|
||||||
|
dengan public key, atau format berbeda), kasih tahu kami spesifikasinya —
|
||||||
|
kami sesuaikan.
|
||||||
|
4. **Kapan callback dikirim** — idealnya setiap kali status berubah
|
||||||
|
(`sent` → `delivered` → `read`, atau → `failed`), bukan cuma sekali di akhir.
|
||||||
|
5. **Retry policy di sisi Omnix** — kalau endpoint kami down/timeout, apakah
|
||||||
|
Omnix retry otomatis? Berapa kali, dengan interval berapa?
|
||||||
|
|
||||||
|
## 2. Endpoint & secret yang kami berikan ke Omnix
|
||||||
|
|
||||||
|
```
|
||||||
|
URL : https://<DOMAIN_PUBLIK_KAMI>/webhooks/sopiga/delivery-status
|
||||||
|
Method: POST
|
||||||
|
Secret: (kirim terpisah lewat kanal aman — JANGAN taruh di email/chat biasa,
|
||||||
|
JANGAN commit ke dokumen/repo ini)
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ URL di atas masih placeholder — isi dengan domain publik/staging service
|
||||||
|
> `omnix-broadcast` kami sebelum dikirim ke tim Omnix. Saat ini service jalan
|
||||||
|
> lokal di `localhost:8081`, belum bisa diakses dari luar.
|
||||||
|
|
||||||
|
> 🔒 **Secret key** sudah kami generate (64 karakter hex, random 256-bit) dan
|
||||||
|
> tersimpan di `.env` service kami (`WEBHOOK_SECRET`). Kirim nilainya ke PIC
|
||||||
|
> Omnix lewat kanal aman (password manager, secret vault, atau chat terenkripsi
|
||||||
|
> — bukan email/Slack polos). Mereka pakai secret yang SAMA persis untuk
|
||||||
|
> menandatangani tiap request ke kami.
|
||||||
|
|
||||||
|
## 3. Format payload — **dikonfirmasi tim Omnix** ✅
|
||||||
|
|
||||||
|
**Header:**
|
||||||
|
```
|
||||||
|
X-Sopiga-Signature: <hex HMAC-SHA256 dari raw body, pakai secret di atas>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recipient_detail_id": 1234,
|
||||||
|
"status": "sent",
|
||||||
|
"gateway": "628124878787"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Tipe | Keterangan |
|
||||||
|
|---|---|---|
|
||||||
|
| `recipient_detail_id` | integer | Sama dengan `recipient_detail_id` yang dikembalikan saat `add-recipient` |
|
||||||
|
| `status` | string | Salah satu: `pending`, `sent`, `delivered`, `read`, `failed` (atau `undelivered`) |
|
||||||
|
| `gateway` | string | Nomor WhatsApp pengirim (sender) |
|
||||||
|
|
||||||
|
Sudah diuji end-to-end di sisi kami dengan payload persis seperti di atas —
|
||||||
|
`status: "sent"` diabaikan (belum final), `status: "delivered"`/`"read"` update
|
||||||
|
record jadi `delivered`, `status: "failed"`/`"undelivered"` update jadi `failed`.
|
||||||
|
|
||||||
|
> Masih perlu dikonfirmasi: apakah header `X-Sopiga-Signature` (HMAC-SHA256)
|
||||||
|
> di atas juga dipakai Omnix, atau ada skema signature/auth lain di sisi mereka?
|
||||||
|
|
||||||
|
## 4. Response yang kami kirim balik
|
||||||
|
|
||||||
|
- `200 OK` — payload diterima & diproses.
|
||||||
|
- `400 Bad Request` — payload tidak valid/tidak bisa diparse.
|
||||||
|
- `401 Unauthorized` — signature tidak cocok.
|
||||||
|
- `500 Internal Server Error` — gagal proses di sisi kami (mohon di-retry).
|
||||||
|
|
||||||
|
## 5. Fallback
|
||||||
|
|
||||||
|
Selama callback belum aktif/terverifikasi, kami tetap jalankan polling manual
|
||||||
|
ke `GET /api/client/collar/add-recipient/{id}/detail` sebagai cadangan, jadi
|
||||||
|
tidak ada risiko data hilang selama masa transisi.
|
||||||
63
domain/broadcast.go
Normal file
63
domain/broadcast.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type BroadcastStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending BroadcastStatus = "pending"
|
||||||
|
StatusDispatched BroadcastStatus = "dispatched"
|
||||||
|
StatusDelivered BroadcastStatus = "delivered"
|
||||||
|
StatusFailed BroadcastStatus = "failed"
|
||||||
|
StatusRetryScheduled BroadcastStatus = "retry_scheduled"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Broadcast struct {
|
||||||
|
ID int64
|
||||||
|
SopigaCollarID int
|
||||||
|
SopigaTemplateID int
|
||||||
|
MessagePayload map[string]any
|
||||||
|
Status BroadcastStatus
|
||||||
|
ErrorMessage *string
|
||||||
|
ErrorCount int
|
||||||
|
SopigaRecipientDetailID *int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broadcast) RecipientPhone() (string, bool) {
|
||||||
|
v, ok := b.MessagePayload["nasabah_phone"].(string)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broadcast) InvoiceURL() (string, bool) {
|
||||||
|
v, ok := b.MessagePayload["invoice_pdf_url"].(string)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broadcast) CustomerName() string {
|
||||||
|
v, _ := b.MessagePayload["nasabah_nama"].(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapSopigaDeliveryStatus translates a Sopiga recipient status into our
|
||||||
|
// internal terminal status. ok is false while Sopiga still reports an
|
||||||
|
// in-flight state (e.g. "sent"), meaning there is nothing to update yet.
|
||||||
|
func MapSopigaDeliveryStatus(sopigaStatus string) (status BroadcastStatus, ok bool) {
|
||||||
|
switch sopigaStatus {
|
||||||
|
case "delivered", "read":
|
||||||
|
return StatusDelivered, true
|
||||||
|
case "failed", "undelivered":
|
||||||
|
return StatusFailed, true
|
||||||
|
default: // pending, sent, ...
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateVariable struct {
|
||||||
|
VariableOrder int
|
||||||
|
VariableName string
|
||||||
|
VariableType string
|
||||||
|
FieldSource string
|
||||||
|
IsRequired bool
|
||||||
|
}
|
||||||
54
dto/sopiga.go
Normal file
54
dto/sopiga.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type CollarAddRecipientRequest struct {
|
||||||
|
BroadcastID int `json:"broadcast_id"`
|
||||||
|
TemplateID int `json:"template_id"`
|
||||||
|
Details CollarDetails `json:"details"`
|
||||||
|
Attachment *CollarAttachment `json:"attachment,omitempty"`
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollarDetails struct {
|
||||||
|
Recipient string `json:"recipient"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollarAttachment struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
File string `json:"file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollarAPIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data CollarResponseData `json:"data"`
|
||||||
|
Errors map[string][]string `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollarResponseData struct {
|
||||||
|
BroadcastID int `json:"broadcast_id"`
|
||||||
|
RecipientDetailID int64 `json:"recipient_detail_id"`
|
||||||
|
Recipient string `json:"recipient"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollarRecipientDetailResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data CollarRecipientDetailData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollarRecipientDetailData struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
BroadcastID int `json:"broadcast_id"`
|
||||||
|
Recipient string `json:"recipient"`
|
||||||
|
TemplateID int `json:"template_id"`
|
||||||
|
Status string `json:"status"` // pending, sent, delivered, read, failed
|
||||||
|
ErrorMessage *string `json:"error_message"`
|
||||||
|
SentAt *time.Time `json:"sent_at"`
|
||||||
|
DeliveredAt *time.Time `json:"delivered_at"`
|
||||||
|
ReadAt *time.Time `json:"read_at"`
|
||||||
|
}
|
||||||
9
dto/webhook.go
Normal file
9
dto/webhook.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// DeliveryStatusWebhook is the confirmed contract for an inbound Omnix
|
||||||
|
// callback when a recipient's delivery status changes.
|
||||||
|
type DeliveryStatusWebhook struct {
|
||||||
|
RecipientDetailID int64 `json:"recipient_detail_id"`
|
||||||
|
Status string `json:"status"` // pending, sent, delivered, read, failed
|
||||||
|
Gateway string `json:"gateway"` // sender WhatsApp number
|
||||||
|
}
|
||||||
24
entity/broadcast.go
Normal file
24
entity/broadcast.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type BroadcastStaging struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
SopigaCollarID int `db:"sopiga_collar_id"`
|
||||||
|
SopigaTemplateID int `db:"sopiga_template_id"`
|
||||||
|
MessagePayload []byte `db:"message_payload"`
|
||||||
|
Status string `db:"status"`
|
||||||
|
ErrorMessage *string `db:"error_message"`
|
||||||
|
ErrorCount int `db:"error_count"`
|
||||||
|
SopigaRecipientDetailID *int64 `db:"sopiga_recipient_detail_id"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateVariableMapping struct {
|
||||||
|
VariableOrder int `db:"variable_order"`
|
||||||
|
SopigaVariableName string `db:"sopiga_variable_name"`
|
||||||
|
VariableType string `db:"variable_type"`
|
||||||
|
DBFieldSource string `db:"db_field_source"`
|
||||||
|
IsRequired bool `db:"is_required"`
|
||||||
|
}
|
||||||
20
go.mod
Normal file
20
go.mod
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
module repository.promas.id/prana/omnix-sopiga
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgx/v5 v5.5.0
|
||||||
|
repository.promas.id/prana/go-dw-framework v0.0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
github.com/stretchr/testify v1.8.3 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace repository.promas.id/prana/go-dw-framework => ../go-dw-framework
|
||||||
28
go.sum
Normal file
28
go.sum
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||||
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
82
handler/webhook.go
Normal file
82
handler/webhook.go
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
fwlogger "repository.promas.id/prana/go-dw-framework/logger"
|
||||||
|
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/dto"
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const signatureHeader = "X-Sopiga-Signature"
|
||||||
|
|
||||||
|
// WebhookHandler receives delivery-status callbacks pushed by Omnix,
|
||||||
|
// as an alternative to polling. Payload shape confirmed by the Omnix team
|
||||||
|
// (see dto.DeliveryStatusWebhook); the signature scheme is still our own
|
||||||
|
// proposal pending their confirmation.
|
||||||
|
type WebhookHandler struct {
|
||||||
|
service *service.Service
|
||||||
|
logger *fwlogger.Logger
|
||||||
|
secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebhookHandler(svc *service.Service, log *fwlogger.Logger, secret string) *WebhookHandler {
|
||||||
|
return &WebhookHandler{service: svc, logger: log, secret: secret}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) HandleDeliveryStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("webhook: read body failed", "error", err)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
if h.secret != "" && !h.verifySignature(body, r.Header.Get(signatureHeader)) {
|
||||||
|
h.logger.Error("webhook: signature verification failed")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload dto.DeliveryStatusWebhook
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
h.logger.Error("webhook: invalid payload", "error", err)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.SyncDeliveryFromWebhook(r.Context(), payload); err != nil {
|
||||||
|
h.logger.Error("webhook: sync delivery failed",
|
||||||
|
"recipient_detail_id", payload.RecipientDetailID,
|
||||||
|
"error", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("webhook: delivery status applied",
|
||||||
|
"recipient_detail_id", payload.RecipientDetailID,
|
||||||
|
"status", payload.Status,
|
||||||
|
"gateway", payload.Gateway)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WebhookHandler) verifySignature(body []byte, signature string) bool {
|
||||||
|
mac := hmac.New(sha256.New, []byte(h.secret))
|
||||||
|
mac.Write(body)
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
return subtle.ConstantTimeCompare([]byte(expected), []byte(signature)) == 1
|
||||||
|
}
|
||||||
83
main.go
Normal file
83
main.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fwconfig "repository.promas.id/prana/go-dw-framework/config"
|
||||||
|
fwdb "repository.promas.id/prana/go-dw-framework/db"
|
||||||
|
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/handler"
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/repository"
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/service"
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/transformer"
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
_ = fwconfig.LoadDotEnv(".env")
|
||||||
|
cfg := LoadConfig()
|
||||||
|
log := fwlogger.New("omnix-broadcast")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pool, err := fwdb.NewPool(ctx, fwdb.Config{
|
||||||
|
DSN: cfg.DB.DSN,
|
||||||
|
MaxConns: cfg.DB.MaxConns,
|
||||||
|
MinConns: cfg.DB.MinConns,
|
||||||
|
MaxConnLifetime: cfg.DB.MaxConnLifetime,
|
||||||
|
MaxConnIdleTime: cfg.DB.MaxConnIdleTime,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to connect to database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
repo := repository.New(pool)
|
||||||
|
tf := transformer.New()
|
||||||
|
sopigaClient := client.NewSopigaClient(cfg.Sopiga.BaseURL, cfg.Sopiga.Token)
|
||||||
|
retrier := fwingestion.NewRetrier(cfg.Worker.MaxRetries, cfg.Worker.RetryDelay)
|
||||||
|
|
||||||
|
svc := service.New(repo, tf, sopigaClient, retrier, log)
|
||||||
|
bcWorker := worker.New(svc, log, cfg.Worker.CheckInterval, cfg.Worker.BatchSize)
|
||||||
|
syncWorker := worker.NewDeliverySync(svc, log, cfg.Worker.SyncCheckInterval, cfg.Worker.SyncBatchSize)
|
||||||
|
|
||||||
|
go bcWorker.Start(ctx)
|
||||||
|
go syncWorker.Start(ctx)
|
||||||
|
|
||||||
|
webhookHandler := handler.NewWebhookHandler(svc, log, cfg.Webhook.Secret)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/webhooks/sopiga/delivery-status", webhookHandler.HandleDeliveryStatus)
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: ":" + cfg.Webhook.Port,
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Info("webhook server listening", "port", cfg.Webhook.Port)
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Error("webhook server stopped", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigChan
|
||||||
|
|
||||||
|
log.Info("shutting down gracefully")
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
_ = httpServer.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
17
migrations/001_create_collection_broadcasts.down.sql
Normal file
17
migrations/001_create_collection_broadcasts.down.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
DROP FUNCTION IF EXISTS collection_broadcasts.update_broadcast_status(BIGINT, VARCHAR, TEXT, JSONB);
|
||||||
|
DROP FUNCTION IF EXISTS collection_broadcasts.get_template_variables(INT);
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS collection_broadcasts.v_delivery_rate_24h;
|
||||||
|
DROP VIEW IF EXISTS collection_broadcasts.v_failed_records_24h;
|
||||||
|
DROP VIEW IF EXISTS collection_broadcasts.v_collar_summary;
|
||||||
|
DROP VIEW IF EXISTS collection_broadcasts.v_template_variables_ordered;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS collection_broadcasts.sopiga_sync_job;
|
||||||
|
DROP TABLE IF EXISTS collection_broadcasts.broadcast_error_log;
|
||||||
|
DROP TABLE IF EXISTS collection_broadcasts.broadcast_audit_log;
|
||||||
|
DROP TABLE IF EXISTS collection_broadcasts.broadcast_staging;
|
||||||
|
DROP TABLE IF EXISTS collection_broadcasts.sopiga_collar_config;
|
||||||
|
DROP TABLE IF EXISTS collection_broadcasts.template_variable_mapping;
|
||||||
|
DROP TABLE IF EXISTS collection_broadcasts.sopiga_template_config;
|
||||||
|
|
||||||
|
DROP SCHEMA IF EXISTS collection_broadcasts;
|
||||||
258
migrations/001_create_collection_broadcasts.up.sql
Normal file
258
migrations/001_create_collection_broadcasts.up.sql
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
CREATE SCHEMA IF NOT EXISTS collection_broadcasts;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 1. CONFIGURATION TABLES (Reference only)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE collection_broadcasts.sopiga_template_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
template_name VARCHAR(100) NOT NULL,
|
||||||
|
sopiga_template_id INT NOT NULL UNIQUE,
|
||||||
|
template_type VARCHAR(50),
|
||||||
|
channel VARCHAR(50),
|
||||||
|
description TEXT,
|
||||||
|
active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sopiga_template_active ON collection_broadcasts.sopiga_template_config(active);
|
||||||
|
|
||||||
|
CREATE TABLE collection_broadcasts.template_variable_mapping (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
sopiga_template_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_template_config(sopiga_template_id) ON DELETE CASCADE,
|
||||||
|
variable_order INT NOT NULL,
|
||||||
|
sopiga_variable_name VARCHAR(100) NOT NULL,
|
||||||
|
variable_type VARCHAR(50),
|
||||||
|
db_field_source VARCHAR(100) NOT NULL,
|
||||||
|
is_required BOOLEAN DEFAULT TRUE,
|
||||||
|
example_value VARCHAR(500),
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_template_variable UNIQUE (sopiga_template_id, sopiga_variable_name),
|
||||||
|
CONSTRAINT unique_variable_order UNIQUE (sopiga_template_id, variable_order)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_template_variable_mapping_template_id ON collection_broadcasts.template_variable_mapping(sopiga_template_id);
|
||||||
|
CREATE INDEX idx_template_variable_mapping_order ON collection_broadcasts.template_variable_mapping(sopiga_template_id, variable_order);
|
||||||
|
|
||||||
|
CREATE TABLE collection_broadcasts.sopiga_collar_config (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
broadcast_name VARCHAR(100) NOT NULL,
|
||||||
|
sopiga_collar_id INT NOT NULL UNIQUE,
|
||||||
|
sopiga_template_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_template_config(sopiga_template_id),
|
||||||
|
status VARCHAR(50),
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sopiga_collar_status ON collection_broadcasts.sopiga_collar_config(status);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 2. BROADCAST STAGING (CORE) - DENORMALIZED
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE collection_broadcasts.broadcast_staging (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
sopiga_collar_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_collar_config(sopiga_collar_id),
|
||||||
|
sopiga_template_id INT NOT NULL REFERENCES collection_broadcasts.sopiga_template_config(sopiga_template_id),
|
||||||
|
|
||||||
|
-- Single source of truth: all fields needed to build the message live here
|
||||||
|
message_payload JSONB NOT NULL,
|
||||||
|
|
||||||
|
status VARCHAR(50) DEFAULT 'pending' NOT NULL,
|
||||||
|
error_message TEXT,
|
||||||
|
error_count INT DEFAULT 0,
|
||||||
|
|
||||||
|
sopiga_recipient_detail_id BIGINT,
|
||||||
|
sopiga_external_id VARCHAR(100),
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
dispatched_at TIMESTAMP,
|
||||||
|
delivered_at TIMESTAMP,
|
||||||
|
failed_at TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT status_valid CHECK (status IN ('pending', 'dispatched', 'delivered', 'failed', 'retry_scheduled')),
|
||||||
|
CONSTRAINT error_count_positive CHECK (error_count >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_broadcast_staging_status ON collection_broadcasts.broadcast_staging(status);
|
||||||
|
CREATE INDEX idx_broadcast_staging_created_at ON collection_broadcasts.broadcast_staging(created_at DESC);
|
||||||
|
CREATE INDEX idx_broadcast_staging_collar_id ON collection_broadcasts.broadcast_staging(sopiga_collar_id);
|
||||||
|
CREATE INDEX idx_broadcast_staging_template_id ON collection_broadcasts.broadcast_staging(sopiga_template_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_broadcast_staging_pending_query
|
||||||
|
ON collection_broadcasts.broadcast_staging(status, created_at ASC)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
CREATE INDEX idx_broadcast_staging_retry_query
|
||||||
|
ON collection_broadcasts.broadcast_staging(status, error_count, created_at ASC)
|
||||||
|
WHERE status = 'retry_scheduled' AND error_count < 3;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 3. AUDIT & LOGGING
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE collection_broadcasts.broadcast_audit_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
broadcast_id BIGINT NOT NULL REFERENCES collection_broadcasts.broadcast_staging(id) ON DELETE CASCADE,
|
||||||
|
old_status VARCHAR(50),
|
||||||
|
new_status VARCHAR(50) NOT NULL,
|
||||||
|
reason VARCHAR(500),
|
||||||
|
sopiga_response JSONB,
|
||||||
|
changed_by VARCHAR(100) DEFAULT 'system',
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_broadcast_audit_log_broadcast_id ON collection_broadcasts.broadcast_audit_log(broadcast_id);
|
||||||
|
CREATE INDEX idx_broadcast_audit_log_created_at ON collection_broadcasts.broadcast_audit_log(created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE collection_broadcasts.broadcast_error_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
broadcast_id BIGINT NOT NULL REFERENCES collection_broadcasts.broadcast_staging(id) ON DELETE CASCADE,
|
||||||
|
error_type VARCHAR(100),
|
||||||
|
error_code VARCHAR(50),
|
||||||
|
error_message TEXT,
|
||||||
|
error_details JSONB,
|
||||||
|
attempt_number INT,
|
||||||
|
next_retry_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_broadcast_error_log_broadcast_id ON collection_broadcasts.broadcast_error_log(broadcast_id);
|
||||||
|
CREATE INDEX idx_broadcast_error_log_error_type ON collection_broadcasts.broadcast_error_log(error_type);
|
||||||
|
|
||||||
|
CREATE TABLE collection_broadcasts.sopiga_sync_job (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
broadcast_id BIGINT NOT NULL REFERENCES collection_broadcasts.broadcast_staging(id) ON DELETE CASCADE,
|
||||||
|
sopiga_recipient_detail_id BIGINT,
|
||||||
|
last_synced_at TIMESTAMP,
|
||||||
|
last_status_from_sopiga VARCHAR(50),
|
||||||
|
sync_count INT DEFAULT 0,
|
||||||
|
next_sync_at TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sopiga_sync_job_broadcast_id ON collection_broadcasts.sopiga_sync_job(broadcast_id);
|
||||||
|
CREATE INDEX idx_sopiga_sync_job_next_sync_at ON collection_broadcasts.sopiga_sync_job(next_sync_at);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 4. VIEWS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE VIEW collection_broadcasts.v_template_variables_ordered AS
|
||||||
|
SELECT
|
||||||
|
tvm.sopiga_template_id,
|
||||||
|
stc.template_name,
|
||||||
|
tvm.variable_order,
|
||||||
|
tvm.sopiga_variable_name,
|
||||||
|
tvm.variable_type,
|
||||||
|
tvm.db_field_source,
|
||||||
|
tvm.is_required,
|
||||||
|
tvm.example_value
|
||||||
|
FROM collection_broadcasts.template_variable_mapping tvm
|
||||||
|
JOIN collection_broadcasts.sopiga_template_config stc ON tvm.sopiga_template_id = stc.sopiga_template_id
|
||||||
|
WHERE stc.active = TRUE
|
||||||
|
ORDER BY tvm.sopiga_template_id, tvm.variable_order;
|
||||||
|
|
||||||
|
CREATE VIEW collection_broadcasts.v_collar_summary AS
|
||||||
|
SELECT
|
||||||
|
sc.sopiga_collar_id,
|
||||||
|
sc.broadcast_name,
|
||||||
|
COUNT(*) as total_records,
|
||||||
|
COUNT(CASE WHEN bs.status = 'pending' THEN 1 END) as pending,
|
||||||
|
COUNT(CASE WHEN bs.status = 'dispatched' THEN 1 END) as dispatched,
|
||||||
|
COUNT(CASE WHEN bs.status = 'delivered' THEN 1 END) as delivered,
|
||||||
|
COUNT(CASE WHEN bs.status = 'failed' THEN 1 END) as failed,
|
||||||
|
ROUND(100.0 * COUNT(CASE WHEN bs.status = 'delivered' THEN 1 END) / NULLIF(COUNT(*), 0), 2) as delivery_rate_percent
|
||||||
|
FROM collection_broadcasts.sopiga_collar_config sc
|
||||||
|
LEFT JOIN collection_broadcasts.broadcast_staging bs ON sc.sopiga_collar_id = bs.sopiga_collar_id
|
||||||
|
GROUP BY sc.sopiga_collar_id, sc.broadcast_name;
|
||||||
|
|
||||||
|
CREATE VIEW collection_broadcasts.v_failed_records_24h AS
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
sopiga_collar_id,
|
||||||
|
sopiga_template_id,
|
||||||
|
message_payload->>'nasabah_nama' as nasabah_nama,
|
||||||
|
message_payload->>'nasabah_phone' as nasabah_phone,
|
||||||
|
error_message,
|
||||||
|
error_count,
|
||||||
|
failed_at,
|
||||||
|
created_at
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE status = 'failed' AND created_at > NOW() - INTERVAL '1 DAY'
|
||||||
|
ORDER BY failed_at DESC;
|
||||||
|
|
||||||
|
CREATE VIEW collection_broadcasts.v_delivery_rate_24h AS
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(CASE WHEN status = 'delivered' THEN 1 END) as delivered,
|
||||||
|
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed,
|
||||||
|
COUNT(CASE WHEN status = 'dispatched' THEN 1 END) as in_progress,
|
||||||
|
ROUND(100.0 * COUNT(CASE WHEN status = 'delivered' THEN 1 END) / NULLIF(COUNT(*), 0), 2) as delivery_rate_percent
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE created_at > NOW() - INTERVAL '1 DAY';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 5. HELPER FUNCTIONS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION collection_broadcasts.get_template_variables(
|
||||||
|
p_sopiga_template_id INT
|
||||||
|
)
|
||||||
|
RETURNS TABLE(
|
||||||
|
variable_order INT,
|
||||||
|
sopiga_variable_name VARCHAR,
|
||||||
|
variable_type VARCHAR,
|
||||||
|
db_field_source VARCHAR,
|
||||||
|
is_required BOOLEAN
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
tvm.variable_order,
|
||||||
|
tvm.sopiga_variable_name,
|
||||||
|
tvm.variable_type,
|
||||||
|
tvm.db_field_source,
|
||||||
|
tvm.is_required
|
||||||
|
FROM collection_broadcasts.template_variable_mapping tvm
|
||||||
|
WHERE tvm.sopiga_template_id = p_sopiga_template_id
|
||||||
|
ORDER BY tvm.variable_order ASC;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION collection_broadcasts.update_broadcast_status(
|
||||||
|
p_broadcast_id BIGINT,
|
||||||
|
p_new_status VARCHAR,
|
||||||
|
p_error_message TEXT DEFAULT NULL,
|
||||||
|
p_sopiga_response JSONB DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_old_status VARCHAR;
|
||||||
|
BEGIN
|
||||||
|
SELECT status INTO v_old_status
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE id = p_broadcast_id;
|
||||||
|
|
||||||
|
UPDATE collection_broadcasts.broadcast_staging
|
||||||
|
SET
|
||||||
|
status = p_new_status,
|
||||||
|
error_message = p_error_message,
|
||||||
|
updated_at = NOW(),
|
||||||
|
dispatched_at = CASE WHEN p_new_status = 'dispatched' THEN NOW() ELSE dispatched_at END,
|
||||||
|
delivered_at = CASE WHEN p_new_status = 'delivered' THEN NOW() ELSE delivered_at END,
|
||||||
|
failed_at = CASE WHEN p_new_status = 'failed' THEN NOW() ELSE failed_at END
|
||||||
|
WHERE id = p_broadcast_id;
|
||||||
|
|
||||||
|
INSERT INTO collection_broadcasts.broadcast_audit_log (broadcast_id, old_status, new_status, sopiga_response)
|
||||||
|
VALUES (p_broadcast_id, v_old_status, p_new_status, p_sopiga_response);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
171
repository/broadcast.go
Normal file
171
repository/broadcast.go
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool) *Repository {
|
||||||
|
return &Repository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) FindPending(ctx context.Context, limit int) ([]*entity.BroadcastStaging, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, sopiga_collar_id, sopiga_template_id, message_payload,
|
||||||
|
status, error_message, error_count, sopiga_recipient_detail_id,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE status = 'pending'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find pending: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var records []*entity.BroadcastStaging
|
||||||
|
for rows.Next() {
|
||||||
|
var e entity.BroadcastStaging
|
||||||
|
if err := rows.Scan(
|
||||||
|
&e.ID, &e.SopigaCollarID, &e.SopigaTemplateID, &e.MessagePayload,
|
||||||
|
&e.Status, &e.ErrorMessage, &e.ErrorCount, &e.SopigaRecipientDetailID,
|
||||||
|
&e.CreatedAt, &e.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan: %w", err)
|
||||||
|
}
|
||||||
|
records = append(records, &e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) FindTemplateVariables(ctx context.Context, templateID int) ([]*entity.TemplateVariableMapping, error) {
|
||||||
|
query := `
|
||||||
|
SELECT variable_order, sopiga_variable_name, variable_type, db_field_source, is_required
|
||||||
|
FROM collection_broadcasts.template_variable_mapping
|
||||||
|
WHERE sopiga_template_id = $1
|
||||||
|
ORDER BY variable_order ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, templateID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find template variables: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var variables []*entity.TemplateVariableMapping
|
||||||
|
for rows.Next() {
|
||||||
|
var v entity.TemplateVariableMapping
|
||||||
|
if err := rows.Scan(&v.VariableOrder, &v.SopigaVariableName, &v.VariableType, &v.DBFieldSource, &v.IsRequired); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan: %w", err)
|
||||||
|
}
|
||||||
|
variables = append(variables, &v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return variables, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) FindByRecipientDetailID(ctx context.Context, recipientDetailID int64) (*entity.BroadcastStaging, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, sopiga_collar_id, sopiga_template_id, message_payload,
|
||||||
|
status, error_message, error_count, sopiga_recipient_detail_id,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE sopiga_recipient_detail_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
var e entity.BroadcastStaging
|
||||||
|
err := r.pool.QueryRow(ctx, query, recipientDetailID).Scan(
|
||||||
|
&e.ID, &e.SopigaCollarID, &e.SopigaTemplateID, &e.MessagePayload,
|
||||||
|
&e.Status, &e.ErrorMessage, &e.ErrorCount, &e.SopigaRecipientDetailID,
|
||||||
|
&e.CreatedAt, &e.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find by recipient_detail_id %d: %w", recipientDetailID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpdateStatus(ctx context.Context, id int64, status, errMsg string, sopigaResponse any) error {
|
||||||
|
var respJSON []byte
|
||||||
|
if sopigaResponse != nil {
|
||||||
|
var err error
|
||||||
|
respJSON, err = json.Marshal(sopigaResponse)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal sopiga response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `SELECT collection_broadcasts.update_broadcast_status($1, $2, $3, $4)`
|
||||||
|
_, err := r.pool.Exec(ctx, query, id, status, errMsg, respJSON)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update status: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) FindDispatched(ctx context.Context, limit int) ([]*entity.BroadcastStaging, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id, sopiga_collar_id, sopiga_template_id, message_payload,
|
||||||
|
status, error_message, error_count, sopiga_recipient_detail_id,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM collection_broadcasts.broadcast_staging
|
||||||
|
WHERE status = 'dispatched' AND sopiga_recipient_detail_id IS NOT NULL
|
||||||
|
ORDER BY dispatched_at ASC
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("find dispatched: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var records []*entity.BroadcastStaging
|
||||||
|
for rows.Next() {
|
||||||
|
var e entity.BroadcastStaging
|
||||||
|
if err := rows.Scan(
|
||||||
|
&e.ID, &e.SopigaCollarID, &e.SopigaTemplateID, &e.MessagePayload,
|
||||||
|
&e.Status, &e.ErrorMessage, &e.ErrorCount, &e.SopigaRecipientDetailID,
|
||||||
|
&e.CreatedAt, &e.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan: %w", err)
|
||||||
|
}
|
||||||
|
records = append(records, &e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpdateDispatched(ctx context.Context, id int64, recipientDetailID int64) error {
|
||||||
|
query := `
|
||||||
|
UPDATE collection_broadcasts.broadcast_staging
|
||||||
|
SET
|
||||||
|
status = 'dispatched',
|
||||||
|
sopiga_recipient_detail_id = $1,
|
||||||
|
dispatched_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
`
|
||||||
|
_, err := r.pool.Exec(ctx, query, recipientDetailID, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update dispatched: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
205
service/broadcast.go
Normal file
205
service/broadcast.go
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
tests/transformer_test.go
Normal file
47
tests/transformer_test.go
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/domain"
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/transformer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildDynamicMessage_Ordered(t *testing.T) {
|
||||||
|
tf := transformer.New()
|
||||||
|
|
||||||
|
variables := []*domain.TemplateVariable{
|
||||||
|
{VariableOrder: 1, VariableName: "Nama", VariableType: "string", FieldSource: "nasabah_nama", IsRequired: true},
|
||||||
|
{VariableOrder: 2, VariableName: "TotalTagihan", VariableType: "integer", FieldSource: "nominal_tagihan", IsRequired: true},
|
||||||
|
{VariableOrder: 3, VariableName: "TanggalJatuhTempo", VariableType: "date", FieldSource: "tanggal_tempo", IsRequired: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"nasabah_nama": "Budi Santoso",
|
||||||
|
"nominal_tagihan": 1500000.0,
|
||||||
|
"tanggal_tempo": "2026-06-30",
|
||||||
|
}
|
||||||
|
|
||||||
|
message, err := tf.BuildDynamicMessage(variables, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "Budi Santoso#1500000#2026-06-30"
|
||||||
|
if message != want {
|
||||||
|
t.Fatalf("expected %q, got %q", want, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDynamicMessage_MissingRequiredField(t *testing.T) {
|
||||||
|
tf := transformer.New()
|
||||||
|
|
||||||
|
variables := []*domain.TemplateVariable{
|
||||||
|
{VariableOrder: 1, VariableName: "Nama", VariableType: "string", FieldSource: "nasabah_nama", IsRequired: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tf.BuildDynamicMessage(variables, map[string]any{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing required field, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
33
tests/validator_test.go
Normal file
33
tests/validator_test.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/domain"
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateBroadcastPayload_MissingPhone(t *testing.T) {
|
||||||
|
b := &domain.Broadcast{
|
||||||
|
MessagePayload: map[string]any{
|
||||||
|
"invoice_pdf_url": "https://storage.gadai.com/invoices/inv-001.pdf",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validator.ValidateBroadcastPayload(b); err != validator.ErrMissingRecipientPhone {
|
||||||
|
t.Fatalf("expected ErrMissingRecipientPhone, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBroadcastPayload_Valid(t *testing.T) {
|
||||||
|
b := &domain.Broadcast{
|
||||||
|
MessagePayload: map[string]any{
|
||||||
|
"nasabah_phone": "6281234567890",
|
||||||
|
"invoice_pdf_url": "https://storage.gadai.com/invoices/inv-001.pdf",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validator.ValidateBroadcastPayload(b); err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
transformer/broadcast.go
Normal file
76
transformer/broadcast.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package transformer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/domain"
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Transformer struct{}
|
||||||
|
|
||||||
|
func New() *Transformer {
|
||||||
|
return &Transformer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transformer) EntityToDomain(e *entity.BroadcastStaging) (*domain.Broadcast, error) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(e.MessagePayload, &payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal message_payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.Broadcast{
|
||||||
|
ID: e.ID,
|
||||||
|
SopigaCollarID: e.SopigaCollarID,
|
||||||
|
SopigaTemplateID: e.SopigaTemplateID,
|
||||||
|
MessagePayload: payload,
|
||||||
|
Status: domain.BroadcastStatus(e.Status),
|
||||||
|
ErrorMessage: e.ErrorMessage,
|
||||||
|
ErrorCount: e.ErrorCount,
|
||||||
|
SopigaRecipientDetailID: e.SopigaRecipientDetailID,
|
||||||
|
CreatedAt: e.CreatedAt,
|
||||||
|
UpdatedAt: e.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transformer) EntityToDomainVariable(e *entity.TemplateVariableMapping) *domain.TemplateVariable {
|
||||||
|
return &domain.TemplateVariable{
|
||||||
|
VariableOrder: e.VariableOrder,
|
||||||
|
VariableName: e.SopigaVariableName,
|
||||||
|
VariableType: e.VariableType,
|
||||||
|
FieldSource: e.DBFieldSource,
|
||||||
|
IsRequired: e.IsRequired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildDynamicMessage interpolates the message payload against the ordered
|
||||||
|
// template variables, joining formatted values with "#" — the delimiter
|
||||||
|
// Sopiga expects between positional template placeholders.
|
||||||
|
func (t *Transformer) BuildDynamicMessage(variables []*domain.TemplateVariable, payload map[string]any) (string, error) {
|
||||||
|
parts := make([]string, 0, len(variables))
|
||||||
|
|
||||||
|
for _, v := range variables {
|
||||||
|
value, exists := payload[v.FieldSource]
|
||||||
|
if !exists {
|
||||||
|
if v.IsRequired {
|
||||||
|
return "", fmt.Errorf("missing required field: %s", v.FieldSource)
|
||||||
|
}
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts, formatValue(v.VariableType, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, "#"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatValue(variableType string, value any) string {
|
||||||
|
switch variableType {
|
||||||
|
case "integer", "decimal":
|
||||||
|
return fmt.Sprintf("%.0f", value)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
validator/broadcast.go
Normal file
22
validator/broadcast.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMissingRecipientPhone = errors.New("nasabah_phone not in payload")
|
||||||
|
ErrMissingInvoiceURL = errors.New("invoice_pdf_url not in payload")
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateBroadcastPayload(b *domain.Broadcast) error {
|
||||||
|
if _, ok := b.RecipientPhone(); !ok {
|
||||||
|
return ErrMissingRecipientPhone
|
||||||
|
}
|
||||||
|
if _, ok := b.InvoiceURL(); !ok {
|
||||||
|
return ErrMissingInvoiceURL
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
85
worker/broadcast_worker.go
Normal file
85
worker/broadcast_worker.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fwlogger "repository.promas.id/prana/go-dw-framework/logger"
|
||||||
|
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/domain"
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxConcurrency = 5
|
||||||
|
|
||||||
|
type BroadcastWorker struct {
|
||||||
|
service *service.Service
|
||||||
|
logger *fwlogger.Logger
|
||||||
|
checkInterval time.Duration
|
||||||
|
batchSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(svc *service.Service, log *fwlogger.Logger, checkInterval time.Duration, batchSize int) *BroadcastWorker {
|
||||||
|
return &BroadcastWorker{
|
||||||
|
service: svc,
|
||||||
|
logger: log,
|
||||||
|
checkInterval: checkInterval,
|
||||||
|
batchSize: batchSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *BroadcastWorker) Start(ctx context.Context) {
|
||||||
|
w.logger.Info("broadcast worker started")
|
||||||
|
|
||||||
|
ticker := time.NewTicker(w.checkInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
w.logger.Info("broadcast worker stopped")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w.processPendingRecords(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *BroadcastWorker) processPendingRecords(ctx context.Context) {
|
||||||
|
broadcasts, err := w.service.FetchPending(ctx, w.batchSize)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Error("fetch pending failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(broadcasts) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.logger.Info("processing pending broadcasts", "count", len(broadcasts))
|
||||||
|
|
||||||
|
sem := make(chan struct{}, maxConcurrency)
|
||||||
|
done := make(chan struct{}, len(broadcasts))
|
||||||
|
|
||||||
|
for _, b := range broadcasts {
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func(b *domain.Broadcast) {
|
||||||
|
defer func() {
|
||||||
|
<-sem
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
w.dispatchOne(ctx, b)
|
||||||
|
}(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(broadcasts); i++ {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *BroadcastWorker) dispatchOne(ctx context.Context, b *domain.Broadcast) {
|
||||||
|
if err := w.service.Dispatch(ctx, b); err != nil {
|
||||||
|
w.logger.Error("dispatch failed", "broadcast_id", b.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.logger.Info("broadcast dispatched", "broadcast_id", b.ID)
|
||||||
|
}
|
||||||
83
worker/delivery_sync_worker.go
Normal file
83
worker/delivery_sync_worker.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fwlogger "repository.promas.id/prana/go-dw-framework/logger"
|
||||||
|
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/domain"
|
||||||
|
"repository.promas.id/prana/omnix-sopiga/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeliverySyncWorker struct {
|
||||||
|
service *service.Service
|
||||||
|
logger *fwlogger.Logger
|
||||||
|
checkInterval time.Duration
|
||||||
|
batchSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeliverySync(svc *service.Service, log *fwlogger.Logger, checkInterval time.Duration, batchSize int) *DeliverySyncWorker {
|
||||||
|
return &DeliverySyncWorker{
|
||||||
|
service: svc,
|
||||||
|
logger: log,
|
||||||
|
checkInterval: checkInterval,
|
||||||
|
batchSize: batchSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DeliverySyncWorker) Start(ctx context.Context) {
|
||||||
|
w.logger.Info("delivery sync worker started")
|
||||||
|
|
||||||
|
ticker := time.NewTicker(w.checkInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
w.logger.Info("delivery sync worker stopped")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w.syncDispatchedRecords(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DeliverySyncWorker) syncDispatchedRecords(ctx context.Context) {
|
||||||
|
broadcasts, err := w.service.FetchDispatched(ctx, w.batchSize)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Error("fetch dispatched failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(broadcasts) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.logger.Info("syncing delivery status", "count", len(broadcasts))
|
||||||
|
|
||||||
|
sem := make(chan struct{}, maxConcurrency)
|
||||||
|
done := make(chan struct{}, len(broadcasts))
|
||||||
|
|
||||||
|
for _, b := range broadcasts {
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func(b *domain.Broadcast) {
|
||||||
|
defer func() {
|
||||||
|
<-sem
|
||||||
|
done <- struct{}{}
|
||||||
|
}()
|
||||||
|
w.syncOne(ctx, b)
|
||||||
|
}(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(broadcasts); i++ {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DeliverySyncWorker) syncOne(ctx context.Context, b *domain.Broadcast) {
|
||||||
|
if err := w.service.SyncDelivery(ctx, b); err != nil {
|
||||||
|
w.logger.Error("sync delivery failed", "broadcast_id", b.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.logger.Info("delivery status synced", "broadcast_id", b.ID)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user