Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user