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:
2026-07-07 14:44:12 +08:00
parent d4ef996f49
commit aeddedf2a3
1348 changed files with 176 additions and 57 deletions
+131
View File
@@ -0,0 +1,131 @@
package csat
import (
"context"
"fmt"
"github.com/gochat/gochat/internal/channel"
applogger "github.com/gochat/gochat/pkg/logger"
"gorm.io/gorm"
)
// CsatSurveyListener listens for conversation resolution and message update events
// to trigger CSAT surveys and record CSAT responses.
// Reference: Chatwoot CsatSurveyListener:
// - on conversation resolved → sends CSAT survey message
// - on message_updated with input_csat content type → builds and saves CSAT response
type CsatSurveyListener struct {
channel.BaseListener
db *gorm.DB
svc *CsatSurveyService
builder *ResponseBuilder
}
func NewCsatSurveyListener(db *gorm.DB, svc *CsatSurveyService, builder *ResponseBuilder) *CsatSurveyListener {
return &CsatSurveyListener{db: db, svc: svc, builder: builder}
}
func (l *CsatSurveyListener) Name() string {
return "csat_survey_listener"
}
func (l *CsatSurveyListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error {
switch event.Type {
case channel.EventConversationResolved:
return l.onConversationResolved(ctx, event)
case channel.EventMessageUpdated:
return l.onMessageUpdated(ctx, event)
default:
return nil
}
}
// onConversationResolved sends a CSAT survey message when a conversation is resolved.
// Reference: Chatwoot CsatSurveyListener — on conversation.resolved -> sends CSAT survey
// (creates an outgoing message with content_type=input_csat).
func (l *CsatSurveyListener) onConversationResolved(ctx context.Context, event *channel.ChannelEvent) error {
conv, err := l.ExtractConversation(event)
if err != nil {
return fmt.Errorf("csat: extract conversation: %w", err)
}
// Create a CSAT survey message (content_type = input_csat)
// This message will be delivered to the contact for them to rate the conversation.
surveyMessage := map[string]interface{}{
"conversation_id": conv.ID,
"account_id": conv.AccountID,
"inbox_id": conv.InboxID,
"content_type": "input_csat",
"message_type": "outgoing",
"private": false,
}
applogger.L().Info("csat: sending CSAT survey for conversation",
"conversation_id", conv.ID,
"account_id", conv.AccountID)
// Publish the survey message event for the message service to create
_ = surveyMessage // TODO: wire through message creation service when available
return nil
}
// onMessageUpdated processes CSAT survey responses when a message with input_csat
// content type is updated (contact submits their rating).
// Reference: Chatwoot CsatSurveyListener — on message_updated with input_csat → builds response.
func (l *CsatSurveyListener) onMessageUpdated(ctx context.Context, event *channel.ChannelEvent) error {
msg, err := l.ExtractMessage(event)
if err != nil {
return nil // not all update events carry a message
}
// Only process input_csat messages
if msg.ContentType != "input_csat" {
return nil
}
// Extract rating and feedback from event data
rating := 0
feedback := ""
if data, ok := event.Data["csat_rating"]; ok {
if r, ok := data.(int); ok {
rating = r
} else if r, ok := data.(float64); ok {
rating = int(r)
}
}
if data, ok := event.Data["csat_feedback"]; ok {
if f, ok := data.(string); ok {
feedback = f
}
}
if rating < 1 || rating > 5 {
applogger.L().Warn("csat: invalid rating received", "rating", rating)
return nil
}
var assignedAgentID *uint
if msg.SenderID != nil {
assignedAgentID = msg.SenderID
}
response := l.builder.BuildFromSurveyInput(
msg.AccountID,
msg.ConversationID,
event.ContactID,
msg.ID,
assignedAgentID,
rating,
feedback,
)
if err := l.svc.Create(ctx, response); err != nil {
applogger.L().Error("csat: failed to save survey response", "error", err)
return err
}
applogger.L().Info("csat: saved survey response",
"conversation_id", msg.ConversationID,
"rating", rating)
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package csat
import (
"time"
"github.com/gochat/gochat/internal/model"
)
// CsatSurveyResponse stores a customer satisfaction survey response.
// Reference: Chatwoot CSAT survey response model — captures rating (1-5), feedback_message,
// review_notes, and links to conversation/message/agent.
type CsatSurveyResponse struct {
model.Base
AccountID uint `gorm:"index;not null" json:"account_id"`
ConversationID uint `gorm:"index;not null" json:"conversation_id"`
ContactID uint `gorm:"index;not null" json:"contact_id"`
MessageID uint `gorm:"index;not null" json:"message_id"`
AssignedAgentID *uint `gorm:"index" json:"assigned_agent_id,omitempty"`
Rating int `gorm:"not null;check:rating >= 1 AND rating <= 5" json:"rating"` // 1-5
FeedbackMessage string `gorm:"type:text" json:"feedback_message"`
CsatReviewNotes string `gorm:"type:text" json:"csat_review_notes"`
ReviewNotesUpdatedAt *time.Time `json:"review_notes_updated_at,omitempty"`
ReviewNotesUpdatedByID *uint `json:"review_notes_updated_by_id,omitempty"`
}
func (CsatSurveyResponse) TableName() string { return "csat_survey_responses" }
+146
View File
@@ -0,0 +1,146 @@
package csat
import (
"context"
"time"
"gorm.io/gorm"
)
// CsatSurveyService provides business logic for CSAT survey operations.
// Reference: Chatwoot CsatSurveyService — CRUD for survey responses.
type CsatSurveyService struct {
db *gorm.DB
}
func NewCsatSurveyService(db *gorm.DB) *CsatSurveyService {
return &CsatSurveyService{db: db}
}
// Create records a new CSAT survey response.
func (s *CsatSurveyService) Create(ctx context.Context, response *CsatSurveyResponse) error {
return s.db.WithContext(ctx).Create(response).Error
}
// GetByID retrieves a CSAT survey response by ID.
func (s *CsatSurveyService) GetByID(ctx context.Context, id uint) (*CsatSurveyResponse, error) {
var resp CsatSurveyResponse
if err := s.db.WithContext(ctx).First(&resp, id).Error; err != nil {
return nil, err
}
return &resp, nil
}
// ListByAccount returns CSAT responses for an account with Chatwoot-compatible filters.
// 1:1 Chatwoot: CsatSurveyResponsesController#index
// Filters: created_at range, assigned_agent_id (user_ids), inbox_id, team_id, rating
// Pagination: 25 per page
func (s *CsatSurveyService) ListByAccount(ctx context.Context, accountID uint, params CsatFilterParams, offset, limit int) ([]CsatSurveyResponse, int64, error) {
var responses []CsatSurveyResponse
var count int64
db := s.db.WithContext(ctx).Model(&CsatSurveyResponse{}).Where("account_id = ?", accountID)
// 1:1 Chatwoot: filter_by_created_at(range)
if params.Since != nil {
db = db.Where("created_at >= ?", params.Since)
}
if params.Until != nil {
db = db.Where("created_at <= ?", params.Until)
}
// 1:1 Chatwoot: filter_by_assigned_agent_id(user_ids)
if len(params.AssignedAgentIDs) > 0 {
db = db.Where("assigned_agent_id IN ?", params.AssignedAgentIDs)
}
// 1:1 Chatwoot: filter_by_inbox_id
// CSAT responses don't have inbox_id directly; join through conversations
if params.InboxID > 0 {
db = db.Joins("JOIN conversations ON conversations.id = csat_survey_responses.conversation_id").
Where("conversations.inbox_id = ?", params.InboxID)
}
// 1:1 Chatwoot: filter_by_team_id
// Team filtering through conversation team_id
if params.TeamID > 0 {
db = db.Joins("JOIN conversations ON conversations.id = csat_survey_responses.conversation_id").
Where("conversations.team_id = ?", params.TeamID)
}
// 1:1 Chatwoot: filter_by_rating
if params.Rating > 0 {
db = db.Where("rating = ?", params.Rating)
}
db.Count(&count)
if err := db.Offset(offset).Limit(limit).Order("created_at DESC").Find(&responses).Error; err != nil {
return nil, 0, err
}
return responses, count, nil
}
// CsatFilterParams holds filter parameters for CSAT survey listing.
// 1:1 Chatwoot: CsatSurveyResponsesController filter params
type CsatFilterParams struct {
Since *time.Time // created_at >= since
Until *time.Time // created_at <= until
AssignedAgentIDs []uint // user_ids param
InboxID uint // inbox_id param
TeamID uint // team_id param
Rating int // rating param (1-5)
}
// ListByConversation returns CSAT responses for a specific conversation.
func (s *CsatSurveyService) ListByConversation(ctx context.Context, accountID, conversationID uint) ([]CsatSurveyResponse, error) {
var responses []CsatSurveyResponse
err := s.db.WithContext(ctx).
Where("account_id = ? AND conversation_id = ?", accountID, conversationID).
Order("created_at DESC").Find(&responses).Error
return responses, err
}
// UpdateReviewNotes updates the review notes on a CSAT response.
func (s *CsatSurveyService) UpdateReviewNotes(ctx context.Context, id uint, notes string, updatedByUserID uint) error {
return s.db.WithContext(ctx).Model(&CsatSurveyResponse{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"csat_review_notes": notes,
"review_notes_updated_by_id": updatedByUserID,
}).Error
}
// GetAverageRating computes the average CSAT rating for an account.
func (s *CsatSurveyService) GetAverageRating(ctx context.Context, accountID uint) (float64, int64, error) {
var result struct {
Avg float64
Count int64
}
err := s.db.WithContext(ctx).Model(&CsatSurveyResponse{}).
Where("account_id = ?", accountID).
Select("AVG(rating) as avg, COUNT(*) as count").
Scan(&result).Error
return result.Avg, result.Count, err
}
// ResponseBuilder constructs a CsatSurveyResponse from event data.
// Reference: Chatwoot CsatSurveyListener — on message_updated with input_csat content type,
// builds response from the message survey data.
type ResponseBuilder struct{}
func NewResponseBuilder() *ResponseBuilder {
return &ResponseBuilder{}
}
// BuildFromSurveyInput creates a CsatSurveyResponse from CSAT survey input data.
// The input_csat content type message carries rating and feedback in its content/metadata.
func (b *ResponseBuilder) BuildFromSurveyInput(accountID, conversationID, contactID, messageID uint, assignedAgentID *uint, rating int, feedback string) *CsatSurveyResponse {
return &CsatSurveyResponse{
AccountID: accountID,
ConversationID: conversationID,
ContactID: contactID,
MessageID: messageID,
AssignedAgentID: assignedAgentID,
Rating: rating,
FeedbackMessage: feedback,
}
}