Files
aura-crm/production/backend/internal/clientnotify/clientnotify.go
T

225 lines
7.2 KiB
Go

// Package clientnotify sends client-facing Telegram/MAX/SMS notifications —
// order created, status changed, ready for pickup, warranty expiring,
// loyalty points accrued. Distinct from internal/notify (a single fixed
// staff chat, fire-and-forget): every client is a different recipient with
// their own channel preference, so delivery goes through an outbox table
// (client_notifications) with per-event deduplication instead.
//
// Telegram's bot token and API base URL are shared with internal/notify
// (one bot, read from the same internal/settings row) — only the recipient
// chat_id differs. MAX is a separate bot/token (see internal/maxbot for the
// inbound linking side). SMS goes through internal/smsgw, configured
// separately since a deployment might enable Telegram/MAX notifications
// without ever setting up an SMS aggregator.
package clientnotify
import (
"context"
"io"
"log"
"net/http"
"time"
"production/internal/dbutil"
"production/internal/settings"
"production/internal/smsgw"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const sendTimeout = 10 * time.Second
type Handler struct {
db *pgxpool.Pool
client *http.Client
}
func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{db: db, client: &http.Client{Timeout: sendTimeout}}
}
// Event describes one notification-worthy moment. TGBody/SMSBody are
// pre-rendered by the caller via templates.go (only the caller — the order
// or cartridge handler — knows the domain specifics like device label or
// status text); Enqueue only decides whether/how to deliver them. MAX gets
// no body field of its own — every templates.go function's "rich text"
// branch (anything that isn't the SMS branch) reads fine as a MAX message
// too, so the max channel just reuses TGBody rather than the caller
// rendering a third, identical variant.
type Event struct {
ClientID string
OrderID string
CartridgeBatchID string
Trigger string
// DedupeSeed identifies the specific occurrence that triggered this
// notification — an order_events.id for status changes, a composite
// like "warranty:<order_id>:<date>" for scheduled reminders. See
// dedupeKey's doc comment.
DedupeSeed string
TGBody string
SMSBody string
}
// Enqueue looks up the client's channel preference, picks Telegram or SMS,
// writes an idempotent outbox row, and dispatches it in the background —
// fire-and-forget from the caller's perspective, same convention as
// internal/notify.Send. A client with no linked Telegram chat and no SMS
// opt-in is a silent no-op, not an error.
func (h *Handler) Enqueue(ev Event) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), sendTimeout)
defer cancel()
s, err := settings.Fetch(ctx, h.db)
if err != nil {
log.Printf("clientnotify: settings fetch failed: %v", err)
return
}
if !s.ClientNotifyEnabled {
return
}
var tgChatID, maxChatID, vkChatID, phone string
var notifyTelegram, notifyMax, notifyVk, notifySMS bool
err = h.db.QueryRow(ctx,
`SELECT COALESCE(tg_chat_id, ''), COALESCE(max_chat_id, ''), COALESCE(vk_chat_id, ''), COALESCE(phone_normalized, ''),
notify_telegram, notify_max, notify_vk, notify_sms
FROM clients WHERE id = $1::uuid`,
ev.ClientID,
).Scan(&tgChatID, &maxChatID, &vkChatID, &phone, &notifyTelegram, &notifyMax, &notifyVk, &notifySMS)
if err != nil {
log.Printf("clientnotify: client fetch failed for %s: %v", ev.ClientID, err)
return
}
channel, ok := chooseChannel(tgChatID, maxChatID, vkChatID, notifyTelegram, notifyMax, notifyVk, notifySMS, s.ClientNotifyEnabled, s.SMSEnabled)
if !ok {
return
}
body := ev.TGBody
if channel == "sms" {
body = ev.SMSBody
}
if body == "" {
return
}
key := dedupeKey(ev.Trigger, ev.DedupeSeed, channel)
var id string
err = h.db.QueryRow(ctx,
`INSERT INTO client_notifications (client_id, order_id, cartridge_batch_id, trigger, channel, body, dedupe_key)
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6, $7)
ON CONFLICT (dedupe_key) DO NOTHING
RETURNING id`,
ev.ClientID, dbutil.NullIfEmpty(ev.OrderID), dbutil.NullIfEmpty(ev.CartridgeBatchID), ev.Trigger, channel, body, key,
).Scan(&id)
if err != nil {
if err == pgx.ErrNoRows {
// Already sent for this exact event — not an error, the
// unique index on dedupe_key did its job.
return
}
log.Printf("clientnotify: enqueue insert failed: %v", err)
return
}
h.dispatch(ctx, id, channel, tgChatID, maxChatID, vkChatID, phone, body, s)
}()
}
func (h *Handler) dispatch(ctx context.Context, id, channel, tgChatID, maxChatID, vkChatID, phone, body string, s settings.Settings) {
var (
msgID string
err error
)
switch channel {
case "telegram":
msgID, err = h.sendTelegram(ctx, tgChatID, body, s)
case "max":
msgID, err = h.sendMax(ctx, maxChatID, body, s)
case "vk":
msgID, err = h.sendVk(ctx, vkChatID, body, s)
case "sms":
msgID, err = h.sendSMS(ctx, phone, body, s)
}
if err != nil {
h.markFailed(ctx, id, err)
return
}
h.markSent(ctx, id, msgID)
}
func (h *Handler) sendTelegram(ctx context.Context, chatID, text string, s settings.Settings) (string, error) {
req, err := buildTGSendRequest(ctx, text, s.TGBotToken, chatID, s.TGAPIBaseURL)
if err != nil {
return "", err
}
resp, err := h.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
return parseTGSendResponse(resp.Body)
}
func (h *Handler) sendMax(ctx context.Context, chatID, text string, s settings.Settings) (string, error) {
req, err := buildMaxSendRequest(ctx, text, s.MaxBotToken, chatID)
if err != nil {
return "", err
}
resp, err := h.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
return parseMaxSendResponse(resp.StatusCode, resp.Body)
}
func (h *Handler) sendVk(ctx context.Context, chatID, text string, s settings.Settings) (string, error) {
req, err := buildVkSendRequest(ctx, text, s.VkGroupToken, chatID)
if err != nil {
return "", err
}
resp, err := h.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return parseVkSendResponse(respBody)
}
func (h *Handler) sendSMS(ctx context.Context, phone, text string, s settings.Settings) (string, error) {
provider, err := smsgw.New(s.SMSProvider, s.SMSAPIID, s.SMSFrom, false)
if err != nil {
return "", err
}
return provider.Send(ctx, phone, text)
}
func (h *Handler) markSent(ctx context.Context, id, msgID string) {
if _, err := h.db.Exec(ctx,
`UPDATE client_notifications SET status = 'sent', sent_at = NOW(),
provider_message_id = $1, attempts = attempts + 1 WHERE id = $2::uuid`,
dbutil.NullIfEmpty(msgID), id,
); err != nil {
log.Printf("clientnotify: mark sent failed for %s: %v", id, err)
}
}
func (h *Handler) markFailed(ctx context.Context, id string, sendErr error) {
if _, err := h.db.Exec(ctx,
`UPDATE client_notifications SET status = 'failed', error = $1, attempts = attempts + 1 WHERE id = $2::uuid`,
sendErr.Error(), id,
); err != nil {
log.Printf("clientnotify: mark failed failed for %s: %v", id, err)
}
log.Printf("clientnotify: delivery failed for %s: %v", id, sendErr)
}