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

220 lines
7.9 KiB
Go

package clientnotify
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"time"
"production/internal/dbutil"
"production/internal/settings"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)
// linkTTL is how long a staff-generated deep-link token stays valid — short
// enough that a link pasted into an old chat thread or leaked screenshot
// can't be used to hijack a client's notification channel much later.
const linkTTL = 24 * time.Hour
func generateToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
type notificationRow struct {
ID string `json:"id"`
Trigger string `json:"trigger"`
Channel string `json:"channel"`
Status string `json:"status"`
Body string `json:"body"`
Error *string `json:"error"`
CreatedAt time.Time `json:"created_at"`
SentAt *time.Time `json:"sent_at"`
}
// History returns the notification outbox for one client, most recent
// first — the audit trail an owner checks when a client says "I never got
// the SMS".
func (h *Handler) History(c *fiber.Ctx) error {
clientID := c.Params("id")
rows, err := h.db.Query(context.Background(),
`SELECT id, trigger, channel, status, body, error, created_at, sent_at
FROM client_notifications WHERE client_id = $1::uuid ORDER BY created_at DESC LIMIT 100`,
clientID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
out := []notificationRow{}
for rows.Next() {
var r notificationRow
if err := rows.Scan(&r.ID, &r.Trigger, &r.Channel, &r.Status, &r.Body, &r.Error, &r.CreatedAt, &r.SentAt); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
out = append(out, r)
}
return c.JSON(out)
}
// CreateLink mints a single one-time deep-link token good for either
// channel — the token itself doesn't encode which bot it's for, only which
// client, so the same client_notification_links row backs both a Telegram
// link (t.me/<bot>?start=<token>, consumed by internal/tgbot's webhook) and
// a MAX link (max.ru/<bot>?start=<token>, consumed by internal/maxbot's —
// same table, same used_at/expires_at semantics, first webhook to redeem it
// wins). Returned once — same "shown once" convention as core's module
// tokens — the caller (staff UI) is expected to display/copy immediately.
func (h *Handler) CreateLink(c *fiber.Ctx) error {
clientID := c.Params("id")
ctx := context.Background()
token, err := generateToken()
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
_, err = h.db.Exec(ctx,
`INSERT INTO client_notification_links (token, client_id, expires_at) VALUES ($1, $2::uuid, NOW() + $3::interval)`,
token, clientID, fmt.Sprintf("%d seconds", int(linkTTL.Seconds())))
if err != nil {
if dbutil.IsFKViolation(err) {
return c.Status(404).JSON(fiber.Map{"error": "client not found"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
s, err := settings.Fetch(ctx, h.db)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
var url, maxURL, vkURL string
if s.ClientTGBotUsername != "" {
url = fmt.Sprintf("https://t.me/%s?start=%s", s.ClientTGBotUsername, token)
}
if s.ClientMaxBotUsername != "" {
maxURL = fmt.Sprintf("https://max.ru/%s?start=%s", s.ClientMaxBotUsername, token)
}
if s.ClientVkCommunityID != "" {
// VK's equivalent of Telegram's ?start=/MAX's ?start= — a ref param
// echoed back on the message_new event that follows (see
// internal/vkbot's doc comment), not a "start" query param.
vkURL = fmt.Sprintf("https://vk.me/%s?ref=%s", s.ClientVkCommunityID, token)
}
return c.Status(201).JSON(fiber.Map{"token": token, "url": url, "max_url": maxURL, "vk_url": vkURL, "expires_in_hours": int(linkTTL.Hours())})
}
type prefsInput struct {
NotifyTelegram *bool `json:"notify_telegram"`
NotifyMax *bool `json:"notify_max"`
NotifySMS *bool `json:"notify_sms"`
Consent *bool `json:"consent"`
}
// UpdatePrefs is a partial update, same COALESCE convention as
// order.Update/settings.Update — an omitted field keeps its current value.
// Consent is one-directional here: sending consent=true stamps consent_at
// (152-ФЗ record of when the client opted in); there's no un-consent flow
// from this endpoint, only turning notify_telegram/notify_max/notify_sms off.
func (h *Handler) UpdatePrefs(c *fiber.Ctx) error {
id := c.Params("id")
var body prefsInput
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
var consentAt any
if body.Consent != nil && *body.Consent {
consentAt = time.Now()
}
tag, err := h.db.Exec(context.Background(),
`UPDATE clients SET
notify_telegram = COALESCE($1, notify_telegram),
notify_max = COALESCE($2, notify_max),
notify_sms = COALESCE($3, notify_sms),
consent_at = COALESCE($4::timestamptz, consent_at)
WHERE id = $5::uuid`,
body.NotifyTelegram, body.NotifyMax, body.NotifySMS, consentAt, id)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if tag.RowsAffected() == 0 {
return c.Status(404).JSON(fiber.Map{"error": "client not found"})
}
return c.JSON(fiber.Map{"ok": true})
}
// TestNotify sends an immediate one-off message on whichever channel the
// client currently has available — lets an owner verify the setup (bot
// token, SMS.ru credentials, a specific client's link) without waiting for
// a real order event.
func (h *Handler) TestNotify(c *fiber.Ctx) error {
id := c.Params("id")
ctx := context.Background()
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`, id,
).Scan(&tgChatID, &maxChatID, &vkChatID, &phone, &notifyTelegram, &notifyMax, &notifyVk, &notifySMS)
if err != nil {
if err == pgx.ErrNoRows {
return c.Status(404).JSON(fiber.Map{"error": "client not found"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
s, err := settings.Fetch(ctx, h.db)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
channel, ok := chooseChannel(tgChatID, maxChatID, vkChatID, notifyTelegram, notifyMax, notifyVk, notifySMS, s.ClientNotifyEnabled, s.SMSEnabled)
if !ok {
return c.Status(400).JSON(fiber.Map{"error": "у клиента нет доступного канала уведомлений (не подключён Telegram/MAX/VK и не включён SMS)"})
}
text := "Тестовое уведомление от сервисного центра."
var msgID string
var sendErr error
switch channel {
case "telegram":
msgID, sendErr = h.sendTelegram(ctx, tgChatID, text, s)
case "max":
msgID, sendErr = h.sendMax(ctx, maxChatID, text, s)
case "vk":
msgID, sendErr = h.sendVk(ctx, vkChatID, text, s)
case "sms":
msgID, sendErr = h.sendSMS(ctx, phone, text, s)
}
status, errText := "sent", ""
if sendErr != nil {
status, errText = "failed", sendErr.Error()
}
key := dedupeKey("manual", time.Now().Format(time.RFC3339Nano), channel)
if _, err := h.db.Exec(ctx,
`INSERT INTO client_notifications (client_id, trigger, channel, status, body, dedupe_key, provider_message_id, error, sent_at, attempts)
VALUES ($1::uuid, 'manual', $2, $3, $4, $5, $6, $7, CASE WHEN $3 = 'sent' THEN NOW() ELSE NULL END, 1)`,
id, channel, status, text, key, dbutil.NullIfEmpty(msgID), dbutil.NullIfEmpty(errText),
); err != nil {
log.Printf("clientnotify: test-notify outbox insert failed: %v", err)
}
if sendErr != nil {
return c.Status(502).JSON(fiber.Map{"error": sendErr.Error()})
}
return c.JSON(fiber.Map{"ok": true, "channel": channel})
}