// Package settings is the app's single mutable configuration record — // business requisites (for PDF documents, internal/document), the Gemini // API key/model (internal/aiintake, internal/analytics, internal/diagnosis), // the Telegram bot token/chat/base URL (internal/notify), and the IMEI // lookup API key (internal/imei). One singleton row in Postgres (see // migrations/007_settings.sql) rather than env vars, so an owner can edit // these from the Settings page without a redeploy. // // Deliberately excludes true infrastructure secrets — JWT_SECRET, // DATABASE_URL, MINIO_*, CORE_URL/MODULE_TOKEN, // ONLINE_STORE_WEBHOOK_TOKEN. Those are how this process authenticates to // its own infrastructure (a DB credential can't live inside the database it // unlocks) or would invalidate every staff session / break inter-service // auth if changed live through a web form — they stay .env, by user // decision. // // Fetch falls back to the matching env var whenever the DB field is empty, // so an existing deployment keeps working unchanged the moment this // migration runs, before any owner has touched the Settings page. package settings import ( "context" "os" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" ) const defaultGeminiModel = "gemini-2.0-flash" const defaultTGAPIBaseURL = "http://telegram-bot-api:8081" type Settings struct { BusinessName string `json:"business_name"` BusinessINN string `json:"business_inn"` BusinessKPP string `json:"business_kpp"` BusinessAddress string `json:"business_address"` BusinessPhone string `json:"business_phone"` BusinessBankName string `json:"business_bank_name"` BusinessBankAccount string `json:"business_bank_account"` BusinessBankBIK string `json:"business_bank_bik"` BusinessBankCorrAccount string `json:"business_bank_corr_account"` GeminiAPIKey string `json:"gemini_api_key"` GeminiModel string `json:"gemini_model"` TGBotToken string `json:"tg_bot_token"` TGChatID string `json:"tg_chat_id"` TGAPIBaseURL string `json:"tg_api_base_url"` IMEIAPIKey string `json:"imei_api_key"` SMSProvider string `json:"sms_provider"` SMSAPIID string `json:"sms_api_id"` SMSFrom string `json:"sms_from"` SMSEnabled bool `json:"sms_enabled"` ClientTGBotUsername string `json:"client_tg_bot_username"` TGWebhookSecret string `json:"tg_webhook_secret"` ClientNotifyEnabled bool `json:"client_notify_enabled"` PublicTrackingURL string `json:"public_tracking_url"` LoyaltyEnabled bool `json:"loyalty_enabled"` LoyaltyAccrualPercent string `json:"loyalty_accrual_percent"` DefaultWarrantyDays int `json:"default_warranty_days"` KkmEnabled bool `json:"kkm_enabled"` KkmServerURL string `json:"kkm_server_url"` KkmLogin string `json:"kkm_login"` KkmPassword string `json:"kkm_password"` KkmNumDevice string `json:"kkm_num_device"` KkmTax string `json:"kkm_tax"` DiscountApprovalEnabled bool `json:"discount_approval_enabled"` DiscountApprovalThresholdPercent string `json:"discount_approval_threshold_percent"` MaxBotToken string `json:"max_bot_token"` MaxWebhookSecret string `json:"max_webhook_secret"` ClientMaxBotUsername string `json:"client_max_bot_username"` VkGroupToken string `json:"vk_group_token"` VkGroupID string `json:"vk_group_id"` VkSecretKey string `json:"vk_secret_key"` VkConfirmationCode string `json:"vk_confirmation_code"` ClientVkCommunityID string `json:"client_vk_community_id"` DiaxProEmail string `json:"diaxpro_email"` DiaxProPassword string `json:"diaxpro_password"` // TrialStartedAt is stamped once by migration 067 the moment this // database is first created — see that migration's doc comment for why // that's a reliable first-boot timestamp with no separate detection // code. LicenseKey is empty until an owner pastes one into Settings; // see internal/license for verification. TrialStartedAt time.Time `json:"trial_started_at"` LicenseKey string `json:"license_key"` UpdatedAt *time.Time `json:"updated_at"` UpdatedByStaffName string `json:"updated_by_staff_name"` } // Fetch reads the singleton settings row, one indexed SELECT on a 1-row // table — cheap enough to call at the top of every request handler that // needs one of these values, rather than caching them at process start, // so a change made in the Settings page takes effect immediately for the // next request without a restart. func Fetch(ctx context.Context, db *pgxpool.Pool) (Settings, error) { var s Settings // Every TEXT column is nullable (COALESCE'd to '' here) — a fresh row // starts fully empty until an owner fills in the Settings page, and // pgx can't scan a SQL NULL into a plain (non-pointer) Go string. err := db.QueryRow(ctx, ` SELECT COALESCE(business_name, ''), COALESCE(business_inn, ''), COALESCE(business_kpp, ''), COALESCE(business_address, ''), COALESCE(business_phone, ''), COALESCE(business_bank_name, ''), COALESCE(business_bank_account, ''), COALESCE(business_bank_bik, ''), COALESCE(business_bank_corr_account, ''), COALESCE(gemini_api_key, ''), COALESCE(gemini_model, ''), COALESCE(tg_bot_token, ''), COALESCE(tg_chat_id, ''), COALESCE(tg_api_base_url, ''), COALESCE(imei_api_key, ''), COALESCE(sms_provider, ''), COALESCE(sms_api_id, ''), COALESCE(sms_from, ''), sms_enabled, COALESCE(client_tg_bot_username, ''), COALESCE(tg_webhook_secret, ''), client_notify_enabled, COALESCE(public_tracking_url, ''), loyalty_enabled, loyalty_accrual_percent::text, default_warranty_days, kkm_enabled, COALESCE(kkm_server_url, ''), COALESCE(kkm_login, ''), COALESCE(kkm_password, ''), COALESCE(kkm_num_device, ''), COALESCE(kkm_tax, ''), discount_approval_enabled, discount_approval_threshold_percent::text, COALESCE(max_bot_token, ''), COALESCE(max_webhook_secret, ''), COALESCE(client_max_bot_username, ''), COALESCE(vk_group_token, ''), COALESCE(vk_group_id, ''), COALESCE(vk_secret_key, ''), COALESCE(vk_confirmation_code, ''), COALESCE(client_vk_community_id, ''), COALESCE(diaxpro_email, ''), COALESCE(diaxpro_password, ''), trial_started_at, COALESCE(license_key, ''), updated_at, COALESCE(updated_by_staff_name, '') FROM settings WHERE id = 1`, ).Scan( &s.BusinessName, &s.BusinessINN, &s.BusinessKPP, &s.BusinessAddress, &s.BusinessPhone, &s.BusinessBankName, &s.BusinessBankAccount, &s.BusinessBankBIK, &s.BusinessBankCorrAccount, &s.GeminiAPIKey, &s.GeminiModel, &s.TGBotToken, &s.TGChatID, &s.TGAPIBaseURL, &s.IMEIAPIKey, &s.SMSProvider, &s.SMSAPIID, &s.SMSFrom, &s.SMSEnabled, &s.ClientTGBotUsername, &s.TGWebhookSecret, &s.ClientNotifyEnabled, &s.PublicTrackingURL, &s.LoyaltyEnabled, &s.LoyaltyAccrualPercent, &s.DefaultWarrantyDays, &s.KkmEnabled, &s.KkmServerURL, &s.KkmLogin, &s.KkmPassword, &s.KkmNumDevice, &s.KkmTax, &s.DiscountApprovalEnabled, &s.DiscountApprovalThresholdPercent, &s.MaxBotToken, &s.MaxWebhookSecret, &s.ClientMaxBotUsername, &s.VkGroupToken, &s.VkGroupID, &s.VkSecretKey, &s.VkConfirmationCode, &s.ClientVkCommunityID, &s.DiaxProEmail, &s.DiaxProPassword, &s.TrialStartedAt, &s.LicenseKey, &s.UpdatedAt, &s.UpdatedByStaffName, ) if err != nil { return Settings{}, err } applyEnvFallback(&s) return s, nil } func applyEnvFallback(s *Settings) { if s.BusinessName == "" { s.BusinessName = os.Getenv("BUSINESS_NAME") } if s.BusinessINN == "" { s.BusinessINN = os.Getenv("BUSINESS_INN") } if s.BusinessKPP == "" { s.BusinessKPP = os.Getenv("BUSINESS_KPP") } if s.BusinessAddress == "" { s.BusinessAddress = os.Getenv("BUSINESS_ADDRESS") } if s.BusinessPhone == "" { s.BusinessPhone = os.Getenv("BUSINESS_PHONE") } if s.BusinessBankName == "" { s.BusinessBankName = os.Getenv("BUSINESS_BANK_NAME") } if s.BusinessBankAccount == "" { s.BusinessBankAccount = os.Getenv("BUSINESS_BANK_ACCOUNT") } if s.BusinessBankBIK == "" { s.BusinessBankBIK = os.Getenv("BUSINESS_BANK_BIK") } if s.BusinessBankCorrAccount == "" { s.BusinessBankCorrAccount = os.Getenv("BUSINESS_BANK_CORR_ACCOUNT") } if s.GeminiAPIKey == "" { s.GeminiAPIKey = os.Getenv("GEMINI_API_KEY") } if s.GeminiModel == "" { s.GeminiModel = os.Getenv("GEMINI_MODEL") } if s.GeminiModel == "" { s.GeminiModel = defaultGeminiModel } if s.TGBotToken == "" { s.TGBotToken = os.Getenv("TG_BOT_TOKEN") } if s.TGChatID == "" { s.TGChatID = os.Getenv("TG_CHAT_ID") } if s.TGAPIBaseURL == "" { s.TGAPIBaseURL = os.Getenv("TG_API_BASE_URL") } if s.TGAPIBaseURL == "" { s.TGAPIBaseURL = defaultTGAPIBaseURL } if s.IMEIAPIKey == "" { s.IMEIAPIKey = os.Getenv("IMEI_API_KEY") } if s.SMSProvider == "" { s.SMSProvider = os.Getenv("SMS_PROVIDER") } if s.SMSProvider == "" { s.SMSProvider = "smsru" } if s.SMSAPIID == "" { s.SMSAPIID = os.Getenv("SMS_API_ID") } if s.SMSFrom == "" { s.SMSFrom = os.Getenv("SMS_FROM") } if s.ClientTGBotUsername == "" { s.ClientTGBotUsername = os.Getenv("CLIENT_TG_BOT_USERNAME") } if s.TGWebhookSecret == "" { s.TGWebhookSecret = os.Getenv("TG_WEBHOOK_SECRET") } if s.PublicTrackingURL == "" { s.PublicTrackingURL = os.Getenv("PUBLIC_TRACKING_URL") } } // TrackingURL builds the public /track/:token link for a client // notification, or "" if PublicTrackingURL isn't configured — every caller // already treats an empty string as "omit the link" (see clientnotify's // OrderCreated/StatusChanged/Ready templates), so a fetch failure here // degrades the same way rather than blocking the notification entirely. // Was three near-identical copies (order, cartridge, and now booking's own // handlers) before being pulled out here. func TrackingURL(ctx context.Context, db *pgxpool.Pool, token string) string { s, err := Fetch(ctx, db) if err != nil || s.PublicTrackingURL == "" { return "" } return strings.TrimRight(s.PublicTrackingURL, "/") + "/" + token }