This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
Binary file not shown.
+375
View File
@@ -0,0 +1,375 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"fotbal-club/internal/config"
eshop_controllers "fotbal-club/internal/controllers/eshop"
"fotbal-club/internal/middleware"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"fotbal-club/pkg/database"
"fotbal-club/pkg/email"
"fotbal-club/pkg/logger"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func main() {
// Load shared configuration
config.LoadConfig()
// Logger level based on DEBUG flag
if config.AppConfig != nil && config.AppConfig.Debug {
logger.SetLevel(logger.LevelDebug)
log.Println("[eshop] Logger level set to DEBUG")
} else {
logger.SetLevel(logger.LevelInfo)
}
// Gin mode
if config.AppConfig != nil && config.AppConfig.AppEnv == "production" {
gin.SetMode(gin.ReleaseMode)
}
// Initialize shared database connection
dbInstance, err := database.InitDB()
if err != nil {
log.Fatalf("[eshop] Failed to connect to database: %v", err)
}
// Optional migrations for eshop-specific tables only (will be added later)
runMigrations, _ := strconv.ParseBool(os.Getenv("RUN_MIGRATIONS"))
if runMigrations {
log.Println("[eshop] RUN_MIGRATIONS is true, but no eshop-specific migrations are defined yet")
}
// Initialize Gin router with a similar hardened stack as the main backend
reporter := services.NewErrorReporter(config.AppConfig)
r := gin.New()
r.Use(middleware.CustomRecoveryWithReporter(reporter))
if err := r.SetTrustedProxies(nil); err != nil {
log.Printf("[eshop] Trusted proxies setup error: %v", err)
}
r.Use(middleware.RequestID())
r.Use(middleware.RequestLogger())
r.Use(middleware.ErrorStatusReporter(reporter))
r.Use(middleware.SanitizeHeaders())
r.Use(middleware.DBContext())
r.Use(middleware.RequestSizeLimit(2 * 1024 * 1024))
r.Use(middleware.ValidateContentType())
r.Use(gzip.Gzip(gzip.DefaultCompression))
r.Use(middleware.SecurityHeaders())
// E-shop CORS reuse AllowedOrigins from shared config
r.Use(func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
allowed := false
// Explicit exact-origin allow list
for _, ao := range config.AppConfig.AllowedOrigins {
if ao == origin {
allowed = true
break
}
}
// Wildcard support
if !allowed {
for _, ao := range config.AppConfig.AllowedOrigins {
if ao == "*" && origin != "" {
allowed = true
break
}
}
}
// Relaxed rule for non-production local dev
if !allowed && origin != "" && config.AppConfig.AppEnv != "production" {
if strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") ||
strings.HasPrefix(origin, "https://localhost:") || strings.HasPrefix(origin, "https://127.0.0.1:") {
allowed = true
}
}
if allowed && origin != "" {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Vary", "Origin")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Expose-Headers", "X-Request-ID")
}
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Cache-Control, X-Requested-With, X-Session-Token, X-Admin-Token, X-Dev-Admin, X-CSRF-Token")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// Attach DB-backed email service for future order emails (not used yet)
_ = email.NewEmailService(config.AppConfig, dbInstance)
// API group for E-shop
api := r.Group("/api/v1/eshop")
// Healthcheck
api.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": "myclub-eshop-backend",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
api.HEAD("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": "myclub-eshop-backend",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
// Admin Setup & Management Endpoints
admin := api.Group("/admin")
admin.Use(middleware.JWTAuth(dbInstance))
admin.Use(middleware.RoleAuth("admin"))
{
// GET /api/v1/eshop/admin/settings
admin.GET("/settings", func(c *gin.Context) {
var settings models.EshopSettings
if err := dbInstance.First(&settings).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Return defaults if not found
c.JSON(http.StatusOK, models.EshopSettings{
DefaultCurrency: "CZK",
DefaultCountry: "CZ",
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load settings"})
return
}
c.JSON(http.StatusOK, settings)
})
// PUT /api/v1/eshop/admin/settings
admin.PUT("/settings", func(c *gin.Context) {
var body models.EshopSettings
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
var settings models.EshopSettings
if err := dbInstance.First(&settings).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create new
settings = body
// Ensure ID is 0 or handled by GORM for creation
settings.ID = 0
if err := dbInstance.Create(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create settings"})
return
}
c.JSON(http.StatusOK, settings)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing settings"})
return
}
// Update existing
settings.DefaultCurrency = body.DefaultCurrency
settings.SupportedCurrencies = body.SupportedCurrencies
settings.DefaultCountry = body.DefaultCountry
settings.ShippingOptionsJSON = body.ShippingOptionsJSON
settings.TermsURL = body.TermsURL
settings.ReturnsPolicyURL = body.ReturnsPolicyURL
settings.SupportEmail = body.SupportEmail
settings.SupportPhone = body.SupportPhone
if err := dbInstance.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
return
}
c.JSON(http.StatusOK, settings)
})
// GET /api/v1/eshop/admin/club-info
// Returns main Club Settings to pre-fill E-shop Setup
admin.GET("/club-info", func(c *gin.Context) {
var clubSettings models.Settings
if err := dbInstance.First(&clubSettings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load club settings"})
return
}
c.JSON(http.StatusOK, gin.H{
"club_name": clubSettings.ClubName,
"club_logo_url": clubSettings.ClubLogoURL,
"contact_email": clubSettings.ContactEmail,
"contact_phone": clubSettings.ContactPhone,
"contact_country": clubSettings.ContactCountry,
"primary_color": clubSettings.PrimaryColor,
})
})
}
// Shipping controller & endpoints
shippingCtrl := eshop_controllers.NewShippingController(config.AppConfig)
// Admin Shipping Actions
admin.POST("/orders/:id/create-packet", shippingCtrl.CreatePacket)
// Public Shipping endpoints
shipping := api.Group("/shipping")
{
shipping.GET("/packeta-widget-config", shippingCtrl.GetPacketaWidgetConfig)
shipping.GET("/labels/:packet_id", shippingCtrl.DownloadLabel)
}
// Support chat routes
support := api.Group("/support")
support.Use(middleware.JWTOptional(dbInstance), middleware.RateLimit(60, time.Minute))
RegisterSupportRoutes(support, dbInstance)
checkoutCtrl := eshop_controllers.NewCheckoutController(dbInstance, config.AppConfig)
api.POST("/checkout", middleware.JWTOptional(dbInstance), middleware.RateLimit(10, time.Minute), checkoutCtrl.Checkout)
// Payment webhooks (Revolut, Stripe)
payments := api.Group("/payments")
{
payments.POST("/revolut/webhook", middleware.RateLimit(60, time.Minute), checkoutCtrl.RevolutWebhook)
payments.POST("/stripe/webhook", middleware.RateLimit(60, time.Minute), checkoutCtrl.StripeWebhook)
}
// Background jobs
go func() {
// Update packet statuses every hour
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
if config.AppConfig.PacketaAPIPassword != "" {
shippingCtrl.UpdatePacketStatuses(dbInstance)
}
}
}()
// Public catalog endpoints (auth optional)
productsPub := api.Group("/products")
productsPub.Use(middleware.JWTOptional(dbInstance))
{
// GET /api/v1/eshop/products
productsPub.GET("", func(c *gin.Context) {
var items []models.EshopProduct
q := dbInstance.
Preload("Category").
Preload("Variants").
Where("active = ?", true).
Order("created_at DESC, id DESC")
if err := q.Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load products"})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
})
// GET /api/v1/eshop/products/:slug
productsPub.GET(":slug", func(c *gin.Context) {
slug := c.Param("slug")
if strings.TrimSpace(slug) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing product slug"})
return
}
var item models.EshopProduct
if err := dbInstance.
Preload("Category").
Preload("Variants").
Where("slug = ? AND active = ?", slug, true).
First(&item).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load product"})
return
}
c.JSON(http.StatusOK, item)
})
}
// Cart controller
cartCtrl := eshop_controllers.NewCartController(dbInstance, config.AppConfig)
// Public cart endpoints (auth optional, tied to user or session)
cart := api.Group("/cart")
cart.Use(middleware.JWTOptional(dbInstance))
{
cart.GET("", cartCtrl.GetCart)
cart.POST("/items", cartCtrl.AddItem)
cart.PATCH("/items/:id", cartCtrl.UpdateItem)
cart.DELETE("/items/:id", cartCtrl.RemoveItem)
}
// GET /api/v1/eshop/orders/:id
api.GET("/orders/:id", middleware.JWTOptional(dbInstance), checkoutCtrl.GetOrder)
port := os.Getenv("PORT")
if strings.TrimSpace(port) == "" {
port = "8080"
}
srv := &http.Server{
Addr: ":" + port,
Handler: r,
ReadTimeout: config.AppConfig.ReadTimeout,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: config.AppConfig.WriteTimeout,
IdleTimeout: config.AppConfig.IdleTimeout,
}
// Graceful shutdown and DB close
sqlDB, err := dbInstance.DB()
if err != nil {
log.Printf("[eshop] Error getting database connection: %v", err)
}
go func() {
log.Printf("[eshop] Server starting on port %s\n", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[eshop] Failed to start server: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("[eshop] Shutdown signal received, shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("[eshop] Server forced to shutdown: %v", err)
}
if sqlDB != nil {
if err := sqlDB.Close(); err != nil {
log.Printf("[eshop] Error closing database connection: %v", err)
}
}
}
+463
View File
@@ -0,0 +1,463 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// systemPrompt is hardcoded here to ensure it is available in the binary without complex build steps
const systemPrompt = `# DeepSeek systémový prompt pro MyClub Eshop podporu
Tento prompt je určen jako **system message** pro model ` + "`deepseek-chat`" + ` používaný v zákaznické podpoře MyClub eshopu.
---
## 1. Role a identita asistenta
Jsi **virtuální asistent zákaznické podpory** pro fotbalový klubový eshop **MyClub**.
- Prodáváme zejména **klubový merchandise** (dresy, trička, mikiny, šály, čepice, suvenýry), případně **permanentky, vstupenky a dárkové poukazy**.
- Eshop je úzce napojený na hlavní web klubu (MyClub CMS) a používá stejný uživatelský účet.
- Zákazníci jsou převážně **čeští a slovenští fanoušci**, často méně technicky zdatní.
Mluv **především česky**, pokud není z kontextu zřejmé, že uživatel preferuje jiný jazyk (např. angličtinu). Pokud dotaz přijde slovensky, odpovídej slovensky, ale jednoduše a srozumitelně.
---
## 2. Cíle a tón komunikace
- Buď **přátelský, věcný a stručný**.
- Vysvětluj kroky **jednoduchým jazykem** (žádný technický žargon pro běžné uživatele).
- Neslibuj nic, co z interního kontextu nevyplývá (termíny doručení, dostupnost, slevy apod.).
- Pokud si nejsi jistý, napiš to otevřeně a navrhni kontakt na podporu (email/telefon), který dostaneš v kontextu.
Příklady vhodného tónu:
- „Jasně, poradím. Teď prosím klikněte na…“
- „Děkuju za trpělivost, podívám se na to.“
- „Tomuhle bohužel přesně nerozumím, ale napíšu, jak to ověřit u podpory.“
---
## 3. Kontext, který dostaneš od backendu
Backend ti bude posílat strukturovaný kontext v JSONu v systémové nebo "tool" části zprávy. Nepředpokládej nic, co v kontextu chybí.
Příklady kontextu (může se lišit):
` + "```json" + `
{
"store": {
"store_name": "{{STORE_NAME}}",
"club_name": "{{CLUB_NAME}}",
"primary_language": "cs",
"supported_languages": ["cs", "sk", "en"],
"support_email": "{{SUPPORT_EMAIL}}",
"support_phone": "{{SUPPORT_PHONE}}",
"returns_policy_url": "{{RETURNS_URL}}",
"terms_url": "{{TERMS_URL}}"
},
"user": {
"is_logged_in": true,
"name": "{{USER_NAME}}",
"email": "{{USER_EMAIL}}"
},
"orders": [
{
"order_id": "{{ORDER_ID}}",
"created_at": "{{ISO_DATETIME}}",
"status": "paid | awaiting_payment | shipped | delivered | cancelled | refunded",
"total_amount": 1290,
"currency": "CZK",
"shipping_method": "packeta | courier | pickup",
"shipping_status": "label_created | handed_to_carrier | in_transit | ready_for_pickup | delivered",
"tracking_url": "{{TRACKING_URL}}"
}
],
"shipping": {
"packeta_enabled": true,
"estimated_delivery_days": "2-4",
"regions": ["CZ", "SK"]
}
}
` + "```" + `
V odpovědích **vždy respektuj tento kontext**. Pokud nějaká informace v kontextu není, otevřeně přiznej, že ji neznáš.
---
## 4. Práce s objednávkami a dopravou
### 4.1 Stav objednávky
- Stav objednávky vysvětluj lidsky (česky), např.:
- ` + "`awaiting_payment`" + ` → „Čekáme na dokončení platby.“
- ` + "`paid`" + ` → „Objednávka je zaplacená a připravuje se k odeslání.“
- ` + "`shipped` / `in_transit`" + ` → „Balík je na cestě k vám.“
- ` + "`ready_for_pickup`" + ` → „Balík je připravený k vyzvednutí na výdejním místě.“
- ` + "`delivered`" + ` → „Objednávka byla doručena.“
- ` + "`cancelled` / `refunded`" + ` → vysvětlit, že objednávka byla zrušená / peníze vrácené.
Pokud máš v kontextu ` + "`tracking_url`" + `, nabídni uživateli, ať si stav ověří přímo tam.
### 4.2 Packeta (Zásilkovna)
- Vysvětluj princip jednoduše:
- Zákazník si při objednávce vybral **výdejní místo** nebo **ZBOX**.
- Po odeslání obdrží SMS/email od Packety s kódem pro vyzvednutí.
- Nepopisuj vnitřní technické detaily (API, XML, Packeta.Widget…), pouze uživatelské kroky.
- Pokud chybí informace o zásilce, řekni, že nemáš detailní data a ať zákazník použije tracking link nebo kontaktuje podporu.
---
## 5. Platby (Stripe)
- Platební bránu popisuj jako **"bezpečnou online platbu kartou"**.
- Nikdy netvrď, že máš přístup k číslům karet nemáš a mít nebudeš.
- Pokud je stav platby ` + "`awaiting_payment`" + ` nebo ` + "`payment_failed`" + `:
- Navrhni zkontrolovat údaje karty, případně zkusit jinou kartu nebo prohlížeč.
- Pokud problém přetrvá, doporuč kontaktovat podporu (email/telefon z kontextu).
- Pokud je platba ` + "`paid`" + ` a objednávka ještě není odeslaná, uklidni uživatele, že je vše v pořádku a objednávka se připravuje.
---
## 6. Typické scénáře a jak odpovídat
### 6.1 „Kdy mi přijde balík?“
1. Podívej se, zda máš v kontextu poslední objednávku a její ` + "`status`" + ` + případně ` + "`shipping_status`" + ` a ` + "`estimated_delivery_days`" + `.
2. Odpověz konkrétně:
- „Vaše poslední objednávka **#1234** je aktuálně ve stavu **předáno dopravci**. Obvykle dorazí za **24 pracovní dny**.“
3. Pokud nic z toho nemáš, napiš:
- „Bohužel tady nevidím detailní informace k vaší objednávce. Prosím ověřte stav v eshopu po přihlášení, nebo napište na {{SUPPORT_EMAIL}}.“
### 6.2 „Chci změnit výdejní místo / adresu“
- Vysvětli, že změna po odeslání je omezená a může ji řešit jen lidská podpora.
- Navrhni konkrétní postup:
- „Přepište prosím číslo objednávky a novou adresu do emailu na {{SUPPORT_EMAIL}}. Kolegové zkusí změnu provést, pokud to dopravce ještě umožňuje.“
### 6.3 „Nepřišel mi potvrzovací email“
- Doporuč kroky:
- Zkontrolovat spam/promo složky.
- Ověřit, že adresa v účtu je správná.
- Případně kontaktovat podporu s číslem objednávky.
---
## 7. Limity asistenta a eskalace
- Nikdy **nevymýšlej konkrétní čísla objednávek, částky nebo přesné termíny**, pokud je nemáš v kontextu.
- Při nejistotě nebo chybějících datech **vždy nabídni kontakt na podporu**:
- „Toto bohužel z chatu nevidím. Prosím napište na {{SUPPORT_EMAIL}} a přidejte číslo objednávky. Kolegové do toho mohou nahlédnout v systému.“
- Pokud se uživatel ptá na:
- právní záležitosti (reklamace, odstoupení od smlouvy),
- reklamaci zboží,
- vrácení peněz,
- nebo cokoliv, co vyžaduje manuální schválení,
vysvětli obecný princip (např. „máte 14 dní na vrácení při online nákupu“, pokud je to v kontextu nebo běžné v EU), ale nakonec přesměruj na oficiální podmínky (` + "`terms_url`" + `, ` + "`returns_policy_url`" + `) a na lidskou podporu.
---
## 8. Styl odpovědí
- Krátké odstavce, maximálně 23 věty, pokud uživatel nechce detail.
- Používej **tučné zvýraznění** pro důležité informace (číslo objednávky, stav, důležitá akce).
- Pokud vysvětluješ postup, napiš ho v očíslovaných krocích.
Příklad:
> "Abychom to vyřešili, prosím postupujte takto:\n\n1. Přihlaste se do eshopu.\n2. V menu zvolte **Moje objednávky**.\n3. Ověřte stav objednávky **#1234**.\n4. Pokud je tam stále uvedeno ` + "`čeká na platbu`" + `, zkuste prosím platbu znovu nebo nás kontaktujte na {{SUPPORT_EMAIL}}."
---
## 9. Co dělat při chybějícím nebo nekonzistentním kontextu
- Pokud backend pošle prázdný nebo neúplný kontext:
- Nesnaž se hádat, jaký je stav objednávky nebo zásilky.
- Vysvětli, že z chatu nemáš přímý přístup k internímu systému a uživatel musí použít svůj účet nebo kontaktovat podporu.
Příklad:
> "Bohužel tady v chatu nevidím detailní data o vaší objednávce. Prosím přihlaste se do eshopu a podívejte se do sekce **Moje objednávky**, nebo napište na {{SUPPORT_EMAIL}} s číslem objednávky."
---
## 10. Shrnutí
- Vždy se opírej o data z kontextu.
- Odpovídej česky/slovensky, jednoduše a přátelsky.
- Neimprovizuj v číslech, částkách ani termínech.
- Pokud si nejsi jistý, uveď to a přesměruj uživatele na email/telefon podpory nebo sekci **Moje objednávky** v eshopu.
`
type ChatRequest struct {
Message string `json:"message"`
History []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"history"`
}
type DeepSeekRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// DeepSeekStreamResponse represents the SSE chunk structure from DeepSeek API
type DeepSeekStreamResponse struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
// RegisterSupportRoutes adds support endpoints to the router
func RegisterSupportRoutes(g *gin.RouterGroup, db *gorm.DB) {
g.POST("/chat/stream", func(c *gin.Context) {
SupportHandler(c, db)
})
}
func SupportHandler(c *gin.Context, db *gorm.DB) {
// 1. Check if DeepSeek API key is configured
if config.AppConfig.DeepSeekAPIKey == "" {
log.Println("[eshop] DeepSeek API key not configured")
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Chat support unavailable"})
return
}
var req ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
// 2. Build Context
ctxData := buildContext(c, db)
ctxJSON, _ := json.Marshal(ctxData)
// 3. Prepare messages
messages := []Message{
{
Role: "system",
Content: systemPrompt + "\n\nCONTEXT:\n" + string(ctxJSON),
},
}
// Add history (limit to last 10 messages to save tokens)
limit := 10
if len(req.History) > limit {
req.History = req.History[len(req.History)-limit:]
}
for _, h := range req.History {
messages = append(messages, Message{
Role: h.Role,
Content: h.Content,
})
}
// Add current user message
messages = append(messages, Message{
Role: "user",
Content: req.Message,
})
// 4. Call DeepSeek API
deepSeekReq := DeepSeekRequest{
Model: "deepseek-chat",
Messages: messages,
Stream: true,
}
reqBody, _ := json.Marshal(deepSeekReq)
client := &http.Client{
Timeout: 60 * time.Second, // Long timeout for streaming
}
apiURL := strings.TrimRight(config.AppConfig.DeepSeekBaseURL, "/") + "/chat/completions"
proxyReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(reqBody))
if err != nil {
log.Printf("[eshop] Failed to create DeepSeek request: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal error"})
return
}
proxyReq.Header.Set("Content-Type", "application/json")
proxyReq.Header.Set("Authorization", "Bearer "+config.AppConfig.DeepSeekAPIKey)
resp, err := client.Do(proxyReq)
if err != nil {
log.Printf("[eshop] DeepSeek API call failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "Support service unavailable"})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Printf("[eshop] DeepSeek API error %d: %s", resp.StatusCode, string(body))
c.JSON(http.StatusBadGateway, gin.H{"error": "Support service error"})
return
}
// 5. Stream response
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Transfer-Encoding", "chunked")
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err != io.EOF {
log.Printf("[eshop] Error reading stream: %v", err)
}
break
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Forward the SSE line directly
c.Writer.Write([]byte(line + "\n\n"))
c.Writer.Flush()
if line == "data: [DONE]" {
break
}
}
}
func buildContext(c *gin.Context, db *gorm.DB) map[string]interface{} {
// Default store info
storeInfo := map[string]interface{}{
"store_name": config.AppConfig.PacketaEshopName,
"club_name": "MyClub", // Could be dynamic if multi-tenant
"primary_language": "cs",
"supported_languages": []string{"cs", "sk", "en"},
"support_email": config.AppConfig.ContactEmail,
"support_phone": "", // Add if available
"returns_policy_url": config.AppConfig.FrontendBaseURL + "/obchodni-podminky", // Fallback
"terms_url": config.AppConfig.FrontendBaseURL + "/obchodni-podminky",
}
// Try to load settings from DB
var settings models.EshopSettings
if err := db.First(&settings).Error; err == nil {
if settings.SupportEmail != "" {
storeInfo["support_email"] = settings.SupportEmail
}
if settings.SupportPhone != "" {
storeInfo["support_phone"] = settings.SupportPhone
}
if settings.TermsURL != "" {
storeInfo["terms_url"] = settings.TermsURL
}
if settings.ReturnsPolicyURL != "" {
storeInfo["returns_policy_url"] = settings.ReturnsPolicyURL
}
}
userInfo := map[string]interface{}{
"is_logged_in": false,
}
var ordersInfo []map[string]interface{}
// Check authentication (similar to getCartContext logic)
// We check standard context keys populated by auth middleware or manual token check
var userID uint
loggedIn := false
if v, ok := c.Get("userID"); ok {
switch id := v.(type) {
case uint:
userID = id
loggedIn = true
case int:
userID = uint(id)
loggedIn = true
case float64:
userID = uint(id)
loggedIn = true
}
}
if loggedIn {
var user models.User
if err := db.First(&user, userID).Error; err == nil {
userInfo["is_logged_in"] = true
userInfo["name"] = strings.TrimSpace(user.FirstName + " " + user.LastName)
userInfo["email"] = user.Email
userInfo["id"] = user.ID
}
// Load last 3 orders
var orders []models.EshopOrder
if err := db.Where("user_id = ?", userID).
Order("created_at DESC").
Limit(3).
Preload("Labels"). // For tracking info
Find(&orders).Error; err == nil {
for _, o := range orders {
trackingURL := ""
shippingStatus := "processing"
if len(o.Labels) > 0 {
// Use the latest label
lbl := o.Labels[len(o.Labels)-1]
trackingURL = lbl.LabelURL // Or specific tracking URL if constructed
shippingStatus = lbl.Status
}
ordersInfo = append(ordersInfo, map[string]interface{}{
"order_id": o.OrderNumber, // Use user-friendly number
"created_at": o.CreatedAt.Format(time.RFC3339),
"status": o.Status,
"total_amount": float64(o.TotalAmountCents) / 100.0,
"currency": o.Currency,
"shipping_method": o.ShippingMethod,
"shipping_status": shippingStatus,
"tracking_url": trackingURL,
})
}
}
}
return map[string]interface{}{
"store": storeInfo,
"user": userInfo,
"orders": ordersInfo,
"shipping": map[string]interface{}{
"packeta_enabled": true, // Assume enabled for now
},
}
}