Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
141 lines
4.2 KiB
Go
141 lines
4.2 KiB
Go
// Package v1 provides the SSE (Server-Sent Events) handler for real-time event push.
|
|
// This is the SSE fallback for clients that don't need bidirectional WebSocket
|
|
// communication but still want real-time updates (one-way server push).
|
|
//
|
|
// Endpoint: GET /api/v1/accounts/:account_id/events
|
|
// Content-Type: text/event-stream
|
|
//
|
|
// SSE event format (matching Chatwoot ActionCable event types):
|
|
// event: message.created
|
|
// data: {"id":1,"content":"hello"}
|
|
//
|
|
// Reference: Chatwoot ActionCable — this endpoint provides the same real-time
|
|
// events as WebSocket but via SSE protocol for simpler client integration.
|
|
package v1
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
wspkg "github.com/gochat/gochat/internal/ws"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// SSEEventHandler handles Server-Sent Events for real-time event push.
|
|
// It provides an SSE fallback endpoint matching Chatwoot's ActionCable
|
|
// events, for clients that prefer unidirectional HTTP streaming over WebSocket.
|
|
type SSEEventHandler struct {
|
|
registry *wspkg.SSERegistry
|
|
}
|
|
|
|
// NewSSEEventHandler creates a new SSE event handler with the given registry.
|
|
func NewSSEEventHandler(registry *wspkg.SSERegistry) *SSEEventHandler {
|
|
return &SSEEventHandler{
|
|
registry: registry,
|
|
}
|
|
}
|
|
|
|
// StreamEvents handles the SSE streaming endpoint.
|
|
// GET /api/v1/accounts/:account_id/events
|
|
//
|
|
// The handler:
|
|
// 1. Validates the authenticated user has access to the account
|
|
// 2. Subscribes the SSE client to the account's event stream
|
|
// 3. Flushes events as they arrive in the text/event-stream format
|
|
// 4. Handles client disconnect by unsubscribing
|
|
//
|
|
// SSE protocol:
|
|
// - Content-Type: text/event-stream
|
|
// - Each event: "event: <type>\ndata: <json>\n\n"
|
|
// - Keep-alive comments: ": keepalive\n\n" (every 30s)
|
|
func (h *SSEEventHandler) StreamEvents(c *gin.Context) {
|
|
// Extract account ID from URL
|
|
accountIDStr := c.Param("account_id")
|
|
accountID, err := strconv.ParseUint(accountIDStr, 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account_id"})
|
|
return
|
|
}
|
|
|
|
// Get authenticated user ID from context (set by AuthMiddleware)
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
userIDUint, ok := userID.(uint)
|
|
if !ok {
|
|
// user_id may be set as float64 from JSON claims
|
|
if f, ok := userID.(float64); ok {
|
|
userIDUint = uint(f)
|
|
} else {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user_id"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Set SSE headers
|
|
c.Header("Content-Type", "text/event-stream")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
c.Header("X-Accel-Buffering", "no") // Disable nginx buffering
|
|
c.Status(http.StatusOK)
|
|
|
|
// Generate unique channel ID
|
|
channelID := generateSSEChannelID()
|
|
|
|
// Subscribe to the SSE registry
|
|
channel := h.registry.Subscribe(channelID, uint(accountID), userIDUint)
|
|
|
|
applogger.L().Infof("sse: client connected (channel=%s, account=%d, user=%d)",
|
|
channelID, accountID, userIDUint)
|
|
|
|
// Flush headers to client
|
|
c.Writer.Flush()
|
|
|
|
// Stream events until client disconnects
|
|
defer h.registry.Unsubscribe(channelID)
|
|
|
|
// Send initial connection confirmation
|
|
c.Writer.WriteString(fmt.Sprintf("event: connected\ndata: {\"channel_id\":\"%s\"}\n\n", channelID))
|
|
c.Writer.Flush()
|
|
|
|
for {
|
|
select {
|
|
case event, ok := <-channel.Events:
|
|
if !ok {
|
|
// Channel closed (unsubscribed)
|
|
return
|
|
}
|
|
sseFormatted, err := wspkg.FormatSSE(event)
|
|
if err != nil {
|
|
applogger.L().Warnf("sse: failed to format event: %v", err)
|
|
continue
|
|
}
|
|
_, err = c.Writer.WriteString(sseFormatted)
|
|
if err != nil {
|
|
// Client disconnected
|
|
applogger.L().Infof("sse: client disconnected (channel=%s)", channelID)
|
|
return
|
|
}
|
|
c.Writer.Flush()
|
|
|
|
case <-c.Request.Context().Done():
|
|
// HTTP connection closed by client
|
|
applogger.L().Infof("sse: client context done (channel=%s)", channelID)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// generateSSEChannelID creates a unique SSE channel ID using crypto/rand.
|
|
func generateSSEChannelID() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
return "sse_" + hex.EncodeToString(b)
|
|
} |