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,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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user