220 lines
7.9 KiB
Go
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, ¬ifyTelegram, ¬ifyMax, ¬ifyVk, ¬ifySMS)
|
|
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})
|
|
}
|