清理: - 删除 34 份过时文档(gap reports/QA临时报告/验收报告/阶段性文档) - 删除 docs/.hermes/skills 第三方 skills 副本(16 文件) - 删除 skills-lock.json 目录归集: - 根目录仅保留 README.md 索引 - product/ — 产品与架构设计(PRD + ARCHITECTURE + P2设计文档 + AI/企业路线图) - tracking/ — Chatwoot parity 开发跟踪 - requirements/ — M01-M12 模块需求 - plans/ — 历史实现计划 - parity/ — 路由 parity 与前端契约 - qa/ — QA 报告与测试计划 - ops/ — 运维部署 命名规范: - 全小写 kebab-case,禁止全大写文件名 - product/tracking/ops 用 NN- 序号前缀 - requirements 用 MNN- 两位零填充模块号 - plans/qa 用 YYYY-MM-DD- 日期前缀 - requirements M1-M9 零填充为 M01-M09(修复字典序) 同步更新: - backend/cmd/route_parity/main.go 路径默认值 - backend/scripts/parity_frontend_smoke.sh 报告路径 - 所有 docs 内部交叉引用 - .gitignore 排除编译产物 (backend/gochat, backend/route_parity) - 新增迁移 000052/000053 - 前端 WS 相关修改
57 KiB
Companies + Campaign + HelpCenter + Notes + Labels + Attachments Implementation Plan
For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.
Goal: Add 6 missing feature modules (Companies, Campaigns, Notes, Labels, Attachments, HelpCenter expansion) to GoChat, matching Chatwoot's API surface.
Architecture: Follow the existing GoChat layered pattern: model → repository → service → handler → router. Each module gets its own handler struct wired in bootstrap.go, its own route group under /api/v1/accounts/:id, and its model registered in AutoMigrate. HelpCenter already has Portal/Category/Article/Folder — we add search endpoint. Campaign already has model+service in internal/campaign/ — we add handler+routes. Company/Note/Label/Attachment models exist as .txt stubs — we activate them.
Tech Stack: Go 1.24, Gin, GORM, PostgreSQL (jsonb), existing pkg/response + pkg/pagination packages.
Task 1: Activate Company model from stub
Objective: Move company.go.txt → company.go so GORM can see the Company model.
Files:
- Create:
internal/model/company.go(rename from .txt) - Modify:
internal/repository/testdb_helper.go(add Company to defaultTestModels)
Step 1: Copy stub to real model file
cp internal/model/company.go.txt internal/model/company.go
Step 2: Verify the model compiles
cd /home/yanghao05/Workspace/gochat && go build ./internal/model/...
Expected: no errors
Step 3: Add Company to defaultTestModels in testdb_helper.go
In internal/repository/testdb_helper.go, find defaultTestModels() and add &model.Company{}, after &model.NotificationPreference{},:
func defaultTestModels() []interface{} {
return []interface{}{
&model.Account{},
&model.User{},
&model.AccountUser{},
&model.Inbox{},
&model.Contact{},
&model.ContactInbox{},
&model.Conversation{},
&model.Message{},
&model.Notification{},
&model.NotificationPreference{},
&model.Company{},
}
}
Step 4: Run existing model tests to verify
cd /home/yanghao05/Workspace/gochat && go test ./internal/model/... -count=1 -timeout 30s
Expected: PASS
Step 5: Commit
git add internal/model/company.go internal/repository/testdb_helper.go
git commit -m "feat: activate Company model from stub"
Task 2: Create CompanyRepo
Objective: Create the GORM repository for Company with CRUD + search + contact association.
Files:
- Create:
internal/repository/company_repo.go
Step 1: Write company_repo.go
package repository
import (
"context"
"github.com/gochat/gochat/internal/model"
"gorm.io/gorm"
)
// CompanyRepo implements GORM repository for Company.
// Reference: Chatwoot app/models/company.rb
type CompanyRepo struct {
db *gorm.DB
}
func NewCompanyRepo(db *gorm.DB) *CompanyRepo {
return &CompanyRepo{db: db}
}
func (r *CompanyRepo) Create(ctx context.Context, company *model.Company) error {
return r.db.WithContext(ctx).Create(company).Error
}
func (r *CompanyRepo) GetByID(ctx context.Context, id uint) (*model.Company, error) {
var company model.Company
err := r.db.WithContext(ctx).First(&company, id).Error
if err != nil {
return nil, err
}
return &company, nil
}
func (r *CompanyRepo) FindByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.Company, int64, error) {
var companies []model.Company
var total int64
countDB := r.db.WithContext(ctx).Model(&model.Company{}).Where("account_id = ?", accountID)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).Where("account_id = ?", accountID).
Offset(offset).Limit(limit).Order("name ASC").
Find(&companies).Error
return companies, total, err
}
func (r *CompanyRepo) Search(ctx context.Context, accountID uint, query string, offset, limit int) ([]model.Company, int64, error) {
var companies []model.Company
var total int64
likeQuery := "%" + query + "%"
condition := r.db.WithContext(ctx).Model(&model.Company{}).
Where("account_id = ? AND (name ILIKE ? OR domain ILIKE ?)", accountID, likeQuery, likeQuery)
if err := condition.Count(&total).Error; err != nil {
return nil, 0, err
}
err := condition.Offset(offset).Limit(limit).Order("name ASC").
Find(&companies).Error
return companies, total, err
}
func (r *CompanyRepo) Update(ctx context.Context, company *model.Company) error {
return r.db.WithContext(ctx).Save(company).Error
}
func (r *CompanyRepo) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.Company{}, id).Error
}
// AddContact associates a contact with a company (many2many: company_contacts).
func (r *CompanyRepo) AddContact(ctx context.Context, companyID, contactID uint) error {
return r.db.WithContext(ctx).
Exec("INSERT INTO company_contacts (company_id, contact_id) VALUES (?, ?) ON CONFLICT DO NOTHING", companyID, contactID).Error
}
// RemoveContact dissociates a contact from a company.
func (r *CompanyRepo) RemoveContact(ctx context.Context, companyID, contactID uint) error {
return r.db.WithContext(ctx).
Exec("DELETE FROM company_contacts WHERE company_id = ? AND contact_id = ?", companyID, contactID).Error
}
// ListContacts returns all contacts associated with a company.
func (r *CompanyRepo) ListContacts(ctx context.Context, companyID uint, offset, limit int) ([]model.Contact, int64, error) {
var contacts []model.Contact
var total int64
countDB := r.db.WithContext(ctx).Model(&model.Contact{}).
Joins("JOIN company_contacts ON company_contacts.contact_id = contacts.id").
Where("company_contacts.company_id = ?", companyID)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).
Joins("JOIN company_contacts ON company_contacts.contact_id = contacts.id").
Where("company_contacts.company_id = ?", companyID).
Offset(offset).Limit(limit).Order("name ASC").
Find(&contacts).Error
return contacts, total, err
}
Step 2: Verify compilation
cd /home/yanghao05/Workspace/gochat && go build ./internal/repository/...
Expected: no errors
Step 3: Commit
git add internal/repository/company_repo.go
git commit -m "feat: add CompanyRepo with CRUD, search, contact association"
Task 3: Create CompanyService
Objective: Create the business logic layer for Company operations.
Files:
- Create:
internal/service/company_service.go
Step 1: Write company_service.go
package service
import (
"context"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
)
// CompanyService implements business logic for Company operations.
// Reference: Chatwoot app/controllers/api/v1/companies_controller.rb
type CompanyService struct {
repo *repository.CompanyRepo
}
func NewCompanyService(repo *repository.CompanyRepo) *CompanyService {
return &CompanyService{repo: repo}
}
type CreateCompanyRequest struct {
Name string `json:"name" validate:"required,min=1"`
Description string `json:"description,omitempty"`
WebsiteURL string `json:"website_url,omitempty"`
FaviconURL string `json:"favicon_url,omitempty"`
Domain string `json:"domain,omitempty"`
CustomAttributes string `json:"custom_attributes,omitempty"`
}
type UpdateCompanyRequest struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
WebsiteURL string `json:"website_url,omitempty"`
FaviconURL string `json:"favicon_url,omitempty"`
Domain string `json:"domain,omitempty"`
CustomAttributes string `json:"custom_attributes,omitempty"`
}
func (s *CompanyService) Create(ctx context.Context, accountID uint, req *CreateCompanyRequest) (*model.Company, error) {
company := &model.Company{
AccountID: accountID,
Name: req.Name,
Description: req.Description,
WebsiteURL: req.WebsiteURL,
FaviconURL: req.FaviconURL,
Domain: req.Domain,
}
if req.CustomAttributes != "" {
company.CustomAttributes = []byte(req.CustomAttributes)
}
if err := s.repo.Create(ctx, company); err != nil {
applogger.L().Errorf("CompanyService.Create: %v", err)
return nil, err
}
return company, nil
}
func (s *CompanyService) GetByID(ctx context.Context, id uint) (*model.Company, error) {
return s.repo.GetByID(ctx, id)
}
func (s *CompanyService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.Company, int64, error) {
return s.repo.FindByAccount(ctx, accountID, offset, limit)
}
func (s *CompanyService) Search(ctx context.Context, accountID uint, query string, offset, limit int) ([]model.Company, int64, error) {
if query == "" {
return s.repo.FindByAccount(ctx, accountID, offset, limit)
}
return s.repo.Search(ctx, accountID, query, offset, limit)
}
func (s *CompanyService) Update(ctx context.Context, id uint, req *UpdateCompanyRequest) (*model.Company, error) {
company, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if req.Name != "" {
company.Name = req.Name
}
if req.Description != "" {
company.Description = req.Description
}
if req.WebsiteURL != "" {
company.WebsiteURL = req.WebsiteURL
}
if req.FaviconURL != "" {
company.FaviconURL = req.FaviconURL
}
if req.Domain != "" {
company.Domain = req.Domain
}
if req.CustomAttributes != "" {
company.CustomAttributes = []byte(req.CustomAttributes)
}
if err := s.repo.Update(ctx, company); err != nil {
applogger.L().Errorf("CompanyService.Update: %v", err)
return nil, err
}
return company, nil
}
func (s *CompanyService) Delete(ctx context.Context, id uint) error {
return s.repo.Delete(ctx, id)
}
func (s *CompanyService) AddContact(ctx context.Context, companyID, contactID uint) error {
return s.repo.AddContact(ctx, companyID, contactID)
}
func (s *CompanyService) RemoveContact(ctx context.Context, companyID, contactID uint) error {
return s.repo.RemoveContact(ctx, companyID, contactID)
}
func (s *CompanyService) ListContacts(ctx context.Context, companyID uint, offset, limit int) ([]model.Contact, int64, error) {
return s.repo.ListContacts(ctx, companyID, offset, limit)
}
Step 2: Verify compilation
cd /home/yanghao05/Workspace/gochat && go build ./internal/service/...
Expected: no errors
Step 3: Commit
git add internal/service/company_service.go
git commit -m "feat: add CompanyService with CRUD, search, contact association"
Task 4: Create CompanyHandler
Objective: Create the HTTP handler for Company API endpoints.
Files:
- Create:
internal/handler/api/v1/company_handler.go
Step 1: Write company_handler.go
package v1
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/pagination"
"github.com/gochat/gochat/pkg/response"
)
// CompanyHandler handles company-related API endpoints.
// Reference: Chatwoot app/controllers/api/v1/companies_controller.rb
type CompanyHandler struct {
svc *service.CompanyService
}
func NewCompanyHandler(svc *service.CompanyService) *CompanyHandler {
return &CompanyHandler{svc: svc}
}
// List lists all companies for an account.
// GET /api/v1/accounts/:id/companies
func (h *CompanyHandler) List(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
pg := pagination.Parse(c)
companies, total, err := h.svc.ListByAccount(c.Request.Context(), uint(accountID), pg.Offset, pg.PerPage)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list companies")
return
}
response.OKWithMeta(c, companies, pg.Page, pg.PerPage, total)
}
// Search searches companies by name or domain.
// GET /api/v1/accounts/:id/companies/search?q=...
func (h *CompanyHandler) Search(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
q := c.Query("q")
pg := pagination.Parse(c)
companies, total, err := h.svc.Search(c.Request.Context(), uint(accountID), q, pg.Offset, pg.PerPage)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to search companies")
return
}
response.OKWithMeta(c, companies, pg.Page, pg.PerPage, total)
}
// Get retrieves a single company.
// GET /api/v1/accounts/:id/companies/:company_id
func (h *CompanyHandler) Get(c *gin.Context) {
companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company_id")
return
}
company, err := h.svc.GetByID(c.Request.Context(), uint(companyID))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "company not found")
return
}
response.OK(c, company)
}
// Create creates a new company.
// POST /api/v1/accounts/:id/companies
func (h *CompanyHandler) Create(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
var req service.CreateCompanyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
company, err := h.svc.Create(c.Request.Context(), uint(accountID), &req)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create company")
return
}
response.Created(c, company)
}
// Update updates a company.
// PUT /api/v1/accounts/:id/companies/:company_id
func (h *CompanyHandler) Update(c *gin.Context) {
companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company_id")
return
}
var req service.UpdateCompanyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
company, err := h.svc.Update(c.Request.Context(), uint(companyID), &req)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update company")
return
}
response.OK(c, company)
}
// Delete deletes a company.
// DELETE /api/v1/accounts/:id/companies/:company_id
func (h *CompanyHandler) Delete(c *gin.Context) {
companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company_id")
return
}
if err := h.svc.Delete(c.Request.Context(), uint(companyID)); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete company")
return
}
response.OK(c, gin.H{"id": companyID})
}
// AddContact associates a contact with a company.
// POST /api/v1/accounts/:id/companies/:company_id/contacts
func (h *CompanyHandler) AddContact(c *gin.Context) {
companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company_id")
return
}
contactID, err := strconv.ParseUint(c.Param("contact_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact_id")
return
}
if err := h.svc.AddContact(c.Request.Context(), uint(companyID), uint(contactID)); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to add contact")
return
}
response.OK(c, gin.H{"company_id": companyID, "contact_id": contactID})
}
// RemoveContact dissociates a contact from a company.
// DELETE /api/v1/accounts/:id/companies/:company_id/contacts/:contact_id
func (h *CompanyHandler) RemoveContact(c *gin.Context) {
companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company_id")
return
}
contactID, err := strconv.ParseUint(c.Param("contact_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact_id")
return
}
if err := h.svc.RemoveContact(c.Request.Context(), uint(companyID), uint(contactID)); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to remove contact")
return
}
response.OK(c, gin.H{"company_id": companyID, "contact_id": contactID})
}
// ListContacts lists contacts associated with a company.
// GET /api/v1/accounts/:id/companies/:company_id/contacts
func (h *CompanyHandler) ListContacts(c *gin.Context) {
companyID, err := strconv.ParseUint(c.Param("company_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid company_id")
return
}
pg := pagination.Parse(c)
contacts, total, err := h.svc.ListContacts(c.Request.Context(), uint(companyID), pg.Offset, pg.PerPage)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list contacts")
return
}
response.OKWithMeta(c, contacts, pg.Page, pg.PerPage, total)
}
// AddConversation associates a conversation with a company.
// POST /api/v1/accounts/:id/companies/:company_id/conversations
func (h *CompanyHandler) AddConversation(c *gin.Context) {
// Placeholder — conversation-company association is a future iteration
c.JSON(http.StatusNotImplemented, gin.H{"error": "not yet implemented"})
}
// RemoveConversation dissociates a conversation from a company.
// DELETE /api/v1/accounts/:id/companies/:company_id/conversations/:conversation_id
func (h *CompanyHandler) RemoveConversation(c *gin.Context) {
// Placeholder — conversation-company association is a future iteration
c.JSON(http.StatusNotImplemented, gin.H{"error": "not yet implemented"})
}
Step 2: Verify compilation
cd /home/yanghao05/Workspace/gochat && go build ./internal/handler/api/v1/...
Expected: no errors
Step 3: Commit
git add internal/handler/api/v1/company_handler.go
git commit -m "feat: add CompanyHandler with CRUD, search, contact/conversation association"
Task 5: Activate ContactNote model from stub
Objective: Move contact_note.go.txt → contact_note.go so GORM can see the ContactNote model.
Files:
- Create:
internal/model/contact_note.go(rename from .txt) - Modify:
internal/repository/testdb_helper.go(add ContactNote to defaultTestModels)
Step 1: Copy stub to real model file
cp internal/model/contact_note.go.txt internal/model/contact_note.go
Step 2: Verify compilation
cd /home/yanghao05/Workspace/gochat && go build ./internal/model/...
Step 3: Add ContactNote to defaultTestModels
In internal/repository/testdb_helper.go, add &model.ContactNote{}, after &model.Company{},:
func defaultTestModels() []interface{} {
return []interface{}{
&model.Account{},
&model.User{},
&model.AccountUser{},
&model.Inbox{},
&model.Contact{},
&model.ContactInbox{},
&model.Conversation{},
&model.Message{},
&model.Notification{},
&model.NotificationPreference{},
&model.Company{},
&model.ContactNote{},
}
}
Step 4: Commit
git add internal/model/contact_note.go internal/repository/testdb_helper.go
git commit -m "feat: activate ContactNote model from stub"
Task 6: Create NoteRepo + NoteService + NoteHandler
Objective: Create the full stack for ContactNote CRUD (notes attached to contacts).
Files:
- Create:
internal/repository/note_repo.go - Create:
internal/service/note_service.go - Create:
internal/handler/api/v1/note_handler.go
Step 1: Write note_repo.go
package repository
import (
"context"
"github.com/gochat/gochat/internal/model"
"gorm.io/gorm"
)
type NoteRepo struct {
db *gorm.DB
}
func NewNoteRepo(db *gorm.DB) *NoteRepo {
return &NoteRepo{db: db}
}
func (r *NoteRepo) Create(ctx context.Context, note *model.ContactNote) error {
return r.db.WithContext(ctx).Create(note).Error
}
func (r *NoteRepo) GetByID(ctx context.Context, id uint) (*model.ContactNote, error) {
var note model.ContactNote
err := r.db.WithContext(ctx).First(¬e, id).Error
if err != nil {
return nil, err
}
return ¬e, nil
}
func (r *NoteRepo) FindByContact(ctx context.Context, contactID uint, offset, limit int) ([]model.ContactNote, int64, error) {
var notes []model.ContactNote
var total int64
countDB := r.db.WithContext(ctx).Model(&model.ContactNote{}).Where("contact_id = ?", contactID)
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.WithContext(ctx).Where("contact_id = ?", contactID).
Offset(offset).Limit(limit).Order("created_at DESC").
Find(¬es).Error
return notes, total, err
}
func (r *NoteRepo) Update(ctx context.Context, note *model.ContactNote) error {
return r.db.WithContext(ctx).Save(note).Error
}
func (r *NoteRepo) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.ContactNote{}, id).Error
}
Step 2: Write note_service.go
package service
import (
"context"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
)
type NoteService struct {
repo *repository.NoteRepo
}
func NewNoteService(repo *repository.NoteRepo) *NoteService {
return &NoteService{repo: repo}
}
type CreateNoteRequest struct {
Content string `json:"content" validate:"required,min=1"`
}
type UpdateNoteRequest struct {
Content string `json:"content" validate:"required,min=1"`
}
func (s *NoteService) Create(ctx context.Context, contactID, userID uint, req *CreateNoteRequest) (*model.ContactNote, error) {
note := &model.ContactNote{
ContactID: contactID,
UserID: userID,
Content: req.Content,
}
if err := s.repo.Create(ctx, note); err != nil {
applogger.L().Errorf("NoteService.Create: %v", err)
return nil, err
}
return note, nil
}
func (s *NoteService) GetByID(ctx context.Context, id uint) (*model.ContactNote, error) {
return s.repo.GetByID(ctx, id)
}
func (s *NoteService) ListByContact(ctx context.Context, contactID uint, offset, limit int) ([]model.ContactNote, int64, error) {
return s.repo.FindByContact(ctx, contactID, offset, limit)
}
func (s *NoteService) Update(ctx context.Context, id uint, req *UpdateNoteRequest) (*model.ContactNote, error) {
note, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
note.Content = req.Content
if err := s.repo.Update(ctx, note); err != nil {
applogger.L().Errorf("NoteService.Update: %v", err)
return nil, err
}
return note, nil
}
func (s *NoteService) Delete(ctx context.Context, id uint) error {
return s.repo.Delete(ctx, id)
}
Step 3: Write note_handler.go
package v1
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/pagination"
"github.com/gochat/gochat/pkg/response"
)
type NoteHandler struct {
svc *service.NoteService
}
func NewNoteHandler(svc *service.NoteService) *NoteHandler {
return &NoteHandler{svc: svc}
}
// List lists notes for a contact.
// GET /api/v1/accounts/:id/contacts/:contact_id/notes
func (h *NoteHandler) List(c *gin.Context) {
contactID, err := strconv.ParseUint(c.Param("contact_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact_id")
return
}
pg := pagination.Parse(c)
notes, total, err := h.svc.ListByContact(c.Request.Context(), uint(contactID), pg.Offset, pg.PerPage)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list notes")
return
}
response.OKWithMeta(c, notes, pg.Page, pg.PerPage, total)
}
// Get retrieves a single note.
// GET /api/v1/accounts/:id/contacts/:contact_id/notes/:note_id
func (h *NoteHandler) Get(c *gin.Context) {
noteID, err := strconv.ParseUint(c.Param("note_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid note_id")
return
}
note, err := h.svc.GetByID(c.Request.Context(), uint(noteID))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "note not found")
return
}
response.OK(c, note)
}
// Create creates a note on a contact.
// POST /api/v1/accounts/:id/contacts/:contact_id/notes
func (h *NoteHandler) Create(c *gin.Context) {
contactID, err := strconv.ParseUint(c.Param("contact_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact_id")
return
}
// Extract user_id from JWT claims (set by AuthMiddleware)
userIDStr, exists := c.Get("user_id")
if !exists {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "missing user_id")
return
}
userID, err := strconv.ParseUint(userIDStr.(string), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid user_id")
return
}
var req service.CreateNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
note, err := h.svc.Create(c.Request.Context(), uint(contactID), uint(userID), &req)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create note")
return
}
response.Created(c, note)
}
// Update updates a note.
// PUT /api/v1/accounts/:id/contacts/:contact_id/notes/:note_id
func (h *NoteHandler) Update(c *gin.Context) {
noteID, err := strconv.ParseUint(c.Param("note_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid note_id")
return
}
var req service.UpdateNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
note, err := h.svc.Update(c.Request.Context(), uint(noteID), &req)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update note")
return
}
response.OK(c, note)
}
// Delete deletes a note.
// DELETE /api/v1/accounts/:id/contacts/:contact_id/notes/:note_id
func (h *NoteHandler) Delete(c *gin.Context) {
noteID, err := strconv.ParseUint(c.Param("note_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid note_id")
return
}
if err := h.svc.Delete(c.Request.Context(), uint(noteID)); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete note")
return
}
response.OK(c, gin.H{"id": noteID})
}
Step 4: Verify compilation
cd /home/yanghao05/Workspace/gochat && go build ./internal/repository/... ./internal/service/... ./internal/handler/api/v1/...
Step 5: Commit
git add internal/repository/note_repo.go internal/service/note_service.go internal/handler/api/v1/note_handler.go
git commit -m "feat: add ContactNote full stack (repo+service+handler)"
Task 7: Activate ConversationLabel model from stub
Objective: Move conversation_label.go.txt → conversation_label.go so GORM can see it.
Files:
- Create:
internal/model/conversation_label.go(rename from .txt) - Modify:
internal/repository/testdb_helper.go(add ConversationLabel to defaultTestModels)
Step 1: Copy stub
cp internal/model/conversation_label.go.txt internal/model/conversation_label.go
Step 2: Verify compilation
cd /home/yanghao05/Workspace/gochat && go build ./internal/model/...
Step 3: Add ConversationLabel to defaultTestModels
In internal/repository/testdb_helper.go, add &model.ConversationLabel{}, after &model.ContactNote{},:
// (continuing the list from previous tasks)
&model.ConversationLabel{},
Step 4: Commit
git add internal/model/conversation_label.go internal/repository/testdb_helper.go
git commit -m "feat: activate ConversationLabel model from stub"
Task 8: Create LabelRepo + LabelService + LabelHandler
Objective: Create the full stack for ConversationLabel CRUD + label statistics.
Files:
- Create:
internal/repository/label_repo.go - Create:
internal/service/label_service.go - Create:
internal/handler/api/v1/label_handler.go
Step 1: Write label_repo.go
package repository
import (
"context"
"github.com/gochat/gochat/internal/model"
"gorm.io/gorm"
)
type LabelRepo struct {
db *gorm.DB
}
func NewLabelRepo(db *gorm.DB) *LabelRepo {
return &LabelRepo{db: db}
}
func (r *LabelRepo) AddLabel(ctx context.Context, conversationID uint, label string) error {
cl := &model.ConversationLabel{
ConversationID: conversationID,
Label: label,
}
return r.db.WithContext(ctx).Create(cl).Error
}
func (r *LabelRepo) RemoveLabel(ctx context.Context, conversationID uint, label string) error {
return r.db.WithContext(ctx).
Where("conversation_id = ? AND label = ?", conversationID, label).
Delete(&model.ConversationLabel{}).Error
}
func (r *LabelRepo) ListByConversation(ctx context.Context, conversationID uint) ([]model.ConversationLabel, error) {
var labels []model.ConversationLabel
err := r.db.WithContext(ctx).Where("conversation_id = ?", conversationID).Find(&labels).Error
return labels, err
}
// LabelCount returns the count of conversations per label for an account.
func (r *LabelRepo) LabelCount(ctx context.Context, accountID uint) (map[string]int64, error) {
var results []struct {
Label string
Count int64
}
err := r.db.WithContext(ctx).
Model(&model.ConversationLabel{}).
Select("label, COUNT(*) as count").
Joins("JOIN conversations ON conversations.id = conversation_labels.conversation_id").
Where("conversations.account_id = ?", accountID).
Group("label").
Find(&results).Error
if err != nil {
return nil, err
}
m := make(map[string]int64, len(results))
for _, r := range results {
m[r.Label] = r.Count
}
return m, nil
}
Step 2: Write label_service.go
package service
import (
"context"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
)
type LabelService struct {
repo *repository.LabelRepo
}
func NewLabelService(repo *repository.LabelRepo) *LabelService {
return &LabelService{repo: repo}
}
func (s *LabelService) AddLabel(ctx context.Context, conversationID uint, label string) error {
return s.repo.AddLabel(ctx, conversationID, label)
}
func (s *LabelService) RemoveLabel(ctx context.Context, conversationID uint, label string) error {
return s.repo.RemoveLabel(ctx, conversationID, label)
}
func (s *LabelService) ListByConversation(ctx context.Context, conversationID uint) ([]model.ConversationLabel, error) {
return s.repo.ListByConversation(ctx, conversationID)
}
func (s *LabelService) LabelCount(ctx context.Context, accountID uint) (map[string]int64, error) {
return s.repo.LabelCount(ctx, accountID)
}
Step 3: Write label_handler.go
package v1
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
)
type LabelHandler struct {
svc *service.LabelService
}
func NewLabelHandler(svc *service.LabelService) *LabelHandler {
return &LabelHandler{svc: svc}
}
// List lists labels on a conversation.
// GET /api/v1/accounts/:id/conversations/:conversation_id/labels
func (h *LabelHandler) List(c *gin.Context) {
conversationID, err := strconv.ParseUint(c.Param("conversation_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
return
}
labels, err := h.svc.ListByConversation(c.Request.Context(), uint(conversationID))
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list labels")
return
}
response.OK(c, labels)
}
// Add adds a label to a conversation.
// POST /api/v1/accounts/:id/conversations/:conversation_id/labels
// Body: { "label": "urgent" }
func (h *LabelHandler) Add(c *gin.Context) {
conversationID, err := strconv.ParseUint(c.Param("conversation_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
return
}
var req struct {
Label string `json:"label" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
if err := h.svc.AddLabel(c.Request.Context(), uint(conversationID), req.Label); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to add label")
return
}
response.OK(c, gin.H{"conversation_id": conversationID, "label": req.Label})
}
// Remove removes a label from a conversation.
// DELETE /api/v1/accounts/:id/conversations/:conversation_id/labels/:label
func (h *LabelHandler) Remove(c *gin.Context) {
conversationID, err := strconv.ParseUint(c.Param("conversation_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
return
}
label := c.Param("label")
if label == "" {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "missing label")
return
}
if err := h.svc.RemoveLabel(c.Request.Context(), uint(conversationID), label); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to remove label")
return
}
response.OK(c, gin.H{"conversation_id": conversationID, "label": label})
}
// Stats returns label usage counts for an account.
// GET /api/v1/accounts/:id/labels/stats
func (h *LabelHandler) Stats(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
counts, err := h.svc.LabelCount(c.Request.Context(), uint(accountID))
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get label stats")
return
}
response.OK(c, counts)
}
Step 4: Verify compilation
cd /home/yanghao05/Workspace/gochat && go build ./internal/repository/... ./internal/service/... ./internal/handler/api/v1/...
Step 5: Commit
git add internal/repository/label_repo.go internal/service/label_service.go internal/handler/api/v1/label_handler.go
git commit -m "feat: add ConversationLabel full stack (repo+service+handler+stats)"
Task 9: Create CampaignHandler
Objective: Create the HTTP handler for Campaign API endpoints. Campaign model + service already exist in internal/campaign/.
Files:
- Create:
internal/handler/api/v1/campaign_handler.go
Step 1: Write campaign_handler.go
package v1
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
campaignsvc "github.com/gochat/gochat/internal/campaign"
"github.com/gochat/gochat/pkg/pagination"
"github.com/gochat/gochat/pkg/response"
)
// CampaignHandler handles campaign-related API endpoints.
// Reference: Chatwoot app/controllers/api/v1/campaigns_controller.rb
type CampaignHandler struct {
svc *campaignsvc.CampaignService
}
func NewCampaignHandler(svc *campaignsvc.CampaignService) *CampaignHandler {
return &CampaignHandler{svc: svc}
}
// List lists campaigns for an account.
// GET /api/v1/accounts/:id/campaigns
func (h *CampaignHandler) List(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
pg := pagination.Parse(c)
campaigns, total, err := h.svc.ListByAccount(c.Request.Context(), uint(accountID), pg.Offset, pg.PerPage)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list campaigns")
return
}
response.OKWithMeta(c, campaigns, pg.Page, pg.PerPage, total)
}
// Get retrieves a single campaign.
// GET /api/v1/accounts/:id/campaigns/:campaign_id
func (h *CampaignHandler) Get(c *gin.Context) {
campaignID, err := strconv.ParseUint(c.Param("campaign_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid campaign_id")
return
}
campaign, err := h.svc.GetByID(c.Request.Context(), uint(campaignID))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "campaign not found")
return
}
response.OK(c, campaign)
}
// Create creates a new campaign.
// POST /api/v1/accounts/:id/campaigns
func (h *CampaignHandler) Create(c *gin.Context) {
var req campaignsvc.CreateCampaignRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
campaign := &campaignsvc.Campaign{
AccountID: req.AccountID,
InboxID: req.InboxID,
Title: req.Title,
Message: req.Message,
Description: req.Description,
CampaignStatus: campaignsvc.CampaignStatusActive,
CampaignType: req.CampaignType,
}
if req.SenderID != nil {
campaign.SenderID = req.SenderID
}
if err := h.svc.Create(c.Request.Context(), campaign); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create campaign")
return
}
response.Created(c, campaign)
}
// Update updates a campaign.
// PUT /api/v1/accounts/:id/campaigns/:campaign_id
func (h *CampaignHandler) Update(c *gin.Context) {
campaignID, err := strconv.ParseUint(c.Param("campaign_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid campaign_id")
return
}
var req campaignsvc.UpdateCampaignRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
campaign, err := h.svc.Update(c.Request.Context(), uint(campaignID), &req)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update campaign")
return
}
response.OK(c, campaign)
}
// Delete deletes a campaign.
// DELETE /api/v1/accounts/:id/campaigns/:campaign_id
func (h *CampaignHandler) Delete(c *gin.Context) {
campaignID, err := strconv.ParseUint(c.Param("campaign_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid campaign_id")
return
}
if err := h.svc.Delete(c.Request.Context(), uint(campaignID)); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete campaign")
return
}
response.OK(c, gin.H{"id": campaignID})
}
// SendMessage triggers campaign message sending.
// POST /api/v1/accounts/:id/campaigns/:campaign_id/send
func (h *CampaignHandler) SendMessage(c *gin.Context) {
// Placeholder — actual message sending deferred to P7 (channels) integration
c.JSON(http.StatusNotImplemented, gin.H{"error": "campaign send not yet implemented"})
}
Step 2: Add CreateCampaignRequest and UpdateCampaignRequest to campaign/service.go
We need to add DTO structs to campaign/service.go. Find the existing CampaignService struct and add these request types:
// CreateCampaignRequest is the DTO for creating a campaign.
type CreateCampaignRequest struct {
AccountID uint `json:"account_id" validate:"required"`
InboxID uint `json:"inbox_id" validate:"required"`
SenderID *uint `json:"sender_id,omitempty"`
Title string `json:"title" validate:"required,min=1"`
Message string `json:"message" validate:"required,min=1"`
Description string `json:"description,omitempty"`
CampaignType CampaignType `json:"campaign_type" validate:"required"`
Audience string `json:"audience,omitempty"`
TriggerRules string `json:"trigger_rules,omitempty"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
}
// UpdateCampaignRequest is the DTO for updating a campaign.
type UpdateCampaignRequest struct {
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Description string `json:"description,omitempty"`
CampaignStatus CampaignStatus `json:"campaign_status,omitempty"`
Audience string `json:"audience,omitempty"`
TriggerRules string `json:"trigger_rules,omitempty"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
Also verify that Update method in campaign/service.go accepts UpdateCampaignRequest or adjust the handler accordingly. The existing Update method signature may be Update(ctx, id uint, campaign *Campaign). We'll add a convenience method:
func (s *CampaignService) UpdateFromRequest(ctx context.Context, id uint, req *UpdateCampaignRequest) (*Campaign, error) {
campaign, err := s.GetByID(ctx, id)
if err != nil {
return nil, err
}
if req.Title != "" { campaign.Title = req.Title }
if req.Message != "" { campaign.Message = req.Message }
if req.Description != "" { campaign.Description = req.Description }
if req.CampaignStatus != "" { campaign.CampaignStatus = req.CampaignStatus }
if req.Audience != "" { campaign.Audience = req.Audience }
if req.TriggerRules != "" { campaign.TriggerRules = req.TriggerRules }
if req.ScheduledAt != nil { campaign.ScheduledAt = req.ScheduledAt }
if req.Enabled != nil { campaign.Enabled = *req.Enabled }
if err := s.Update(ctx, id, campaign); err != nil {
return nil, err
}
return campaign, nil
}
Step 3: Verify compilation
cd /home/yanghao05/Workspace/gochat && go build ./internal/campaign/... ./internal/handler/api/v1/...
Step 4: Commit
git add internal/campaign/service.go internal/handler/api/v1/campaign_handler.go
git commit -m "feat: add CampaignHandler with CRUD + send placeholder"
Task 10: Create AttachmentHandler
Objective: Create the HTTP handler for Attachment upload/download/delete. Attachment model + repo already exist.
Files:
- Create:
internal/handler/api/v1/attachment_handler.go
Step 1: Read existing attachment model and repo to confirm
Attachment model is in internal/model/attachment.go — fields: MessageID, AccountID, FileType, ExternalURL, FileURL, ThumbURL, FileSize, FileName, Width, Height, AltText, Metadata.
Attachment repo is in internal/repository/attachment_repo.go — we need to verify what methods exist and add upload-related ones if needed.
Step 2: Write attachment_handler.go
package v1
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/pkg/response"
)
// AttachmentHandler handles file attachment upload/download/delete.
// Reference: Chatwoot message attachments — upload, download, delete
type AttachmentHandler struct {
repo *repository.AttachmentRepo
uploadDir string // base directory for uploaded files
}
func NewAttachmentHandler(repo *repository.AttachmentRepo, uploadDir string) *AttachmentHandler {
return &AttachmentHandler{repo: repo, uploadDir: uploadDir}
}
// Upload uploads a file attachment.
// POST /api/v1/accounts/:id/attachments/upload
func (h *AttachmentHandler) Upload(c *gin.Context) {
accountID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return
}
file, err := c.FormFile("file")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "no file provided")
return
}
// Determine file type from extension
ext := filepath.Ext(file.Filename)
fileType := fileTypeFromExt(ext)
// Save file to upload directory
accountDir := filepath.Join(h.uploadDir, fmt.Sprintf("account_%d", accountID))
os.MkdirAll(accountDir, 0755)
dest := filepath.Join(accountDir, file.Filename)
if err := c.SaveUploadedFile(file, dest); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to save file")
return
}
attachment := &model.Attachment{
AccountID: uint(accountID),
FileType: fileType,
FileName: file.Filename,
FileSize: int(file.Size),
FileURL: fmt.Sprintf("/uploads/account_%d/%s", accountID, file.Filename),
}
if err := h.repo.Create(c.Request.Context(), attachment); err != nil {
os.Remove(dest) // cleanup file on DB error
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create attachment record")
return
}
response.Created(c, attachment)
}
// Download downloads an attachment file.
// GET /api/v1/accounts/:id/attachments/:attachment_id/download
func (h *AttachmentHandler) Download(c *gin.Context) {
attachmentID, err := strconv.ParseUint(c.Param("attachment_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid attachment_id")
return
}
attachment, err := h.repo.GetByID(c.Request.Context(), uint(attachmentID))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "attachment not found")
return
}
filePath := filepath.Join(h.uploadDir, attachment.FileURL)
c.File(filePath)
}
// Delete deletes an attachment and its file.
// DELETE /api/v1/accounts/:id/attachments/:attachment_id
func (h *AttachmentHandler) Delete(c *gin.Context) {
attachmentID, err := strconv.ParseUint(c.Param("attachment_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid attachment_id")
return
}
attachment, err := h.repo.GetByID(c.Request.Context(), uint(attachmentID))
if err != nil {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "attachment not found")
return
}
// Delete file from disk
filePath := filepath.Join(h.uploadDir, attachment.FileURL)
os.Remove(filePath) // ignore error — file may already be gone
// Delete DB record
if err := h.repo.Delete(c.Request.Context(), uint(attachmentID)); err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete attachment")
return
}
response.OK(c, gin.H{"id": attachmentID})
}
func fileTypeFromExt(ext string) string {
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg":
return "image"
case ".mp4", ".webm", ".avi", ".mov":
return "video"
case ".mp3", ".wav", ".ogg", ".flac":
return "audio"
default:
return "file"
}
}
Step 3: Check attachment_repo.go for Create/GetByID/Delete methods
Read internal/repository/attachment_repo.go and verify these methods exist. If any are missing, add them:
func (r *AttachmentRepo) Create(ctx context.Context, attachment *model.Attachment) error {
return r.db.WithContext(ctx).Create(attachment).Error
}
func (r *AttachmentRepo) GetByID(ctx context.Context, id uint) (*model.Attachment, error) {
var attachment model.Attachment
err := r.db.WithContext(ctx).First(&attachment, id).Error
if err != nil { return nil, err }
return &attachment, nil
}
func (r *AttachmentRepo) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.Attachment{}, id).Error
}
Step 4: Verify compilation
cd /home/yanghao05/Workspace/gochat && go build ./internal/repository/... ./internal/handler/api/v1/...
Step 5: Commit
git add internal/repository/attachment_repo.go internal/handler/api/v1/attachment_handler.go
git commit -m "feat: add AttachmentHandler with upload/download/delete"
Task 11: Add Help Center search endpoint
Objective: Add a search endpoint to the existing HelpCenter (Portal) for searching articles by title/content.
Files:
- Modify:
internal/repository/article_repo.go(add Search method if missing) - Modify:
internal/service/article_service.go(add Search method if missing) - Modify:
internal/handler/api/v1/article_handler.go(add Search handler method)
Step 1: Check if article_repo.go already has a Search method
grep 'func.*Search' internal/repository/article_repo.go
If missing, add:
// Search searches articles by title or content within a portal.
func (r *ArticleRepo) Search(ctx context.Context, portalID uint, query string, offset, limit int) ([]model.Article, int64, error) {
var articles []model.Article
var total int64
likeQuery := "%" + query + "%"
condition := r.db.WithContext(ctx).Model(&model.Article{}).
Where("portal_id = ? AND (title ILIKE ? OR content ILIKE ?)", portalID, likeQuery, likeQuery)
if err := condition.Count(&total).Error; err != nil {
return nil, 0, err
}
err := condition.Offset(offset).Limit(limit).Order("title ASC").Find(&articles).Error
return articles, total, err
}
Step 2: Add Search to article_service.go
func (s *ArticleService) Search(ctx context.Context, portalID uint, query string, offset, limit int) ([]model.Article, int64, error) {
if query == "" {
return s.repo.FindByPortal(ctx, portalID, offset, limit)
}
return s.repo.Search(ctx, portalID, query, offset, limit)
}
Step 3: Add Search handler method to article_handler.go
// Search searches articles within a portal.
// GET /api/v1/accounts/:id/portals/:portal_id/articles/search?q=...
func (h *ArticleHandler) Search(c *gin.Context) {
portalID, err := strconv.ParseUint(c.Param("portal_id"), 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid portal_id")
return
}
q := c.Query("q")
pg := pagination.Parse(c)
articles, total, err := h.svc.Search(c.Request.Context(), uint(portalID), q, pg.Offset, pg.PerPage)
if err != nil {
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to search articles")
return
}
response.OKWithMeta(c, articles, pg.Page, pg.PerPage, total)
}
Step 4: Verify compilation
cd /home/yanghao05/Workspace/gochat && go build ./internal/repository/... ./internal/service/... ./internal/handler/api/v1/...
Step 5: Commit
git add internal/repository/article_repo.go internal/service/article_service.go internal/handler/api/v1/article_handler.go
git commit -m "feat: add Help Center article search endpoint"
Task 12: Wire everything into bootstrap.go + router.go
Objective: Register all new handlers in the Handlers struct, wire repos/services/handlers in bootstrap, and add route groups.
Files:
- Modify:
internal/router/router.go(add handler fields + route registrations) - Modify:
internal/app/bootstrap.go(wire repos/services/handlers)
Step 1: Add handler fields to Handlers struct in router.go
In the Handlers struct, add after existing fields:
Company *v1.CompanyHandler
Note *v1.NoteHandler
Label *v1.LabelHandler
Campaign *v1.CampaignHandler
Attachment *v1.AttachmentHandler
Step 2: Add repos in bootstrap.go (Step 7 section)
After existing repo declarations, add:
companyRepo := repository.NewCompanyRepo(db)
noteRepo := repository.NewNoteRepo(db)
labelRepo := repository.NewLabelRepo(db)
Note: campaignRepo doesn't need a separate repo — CampaignService uses db directly. attachmentRepo already exists.
Step 3: Add services in bootstrap.go (Step 8 section)
// P2增值模块 services
companyService := service.NewCompanyService(companyRepo)
noteService := service.NewNoteService(noteRepo)
labelService := service.NewLabelService(labelRepo)
campaignService := campaign.NewCampaignService(db)
For the campaign import, add: "github.com/gochat/gochat/internal/campaign" to imports.
For the attachment handler, it needs an upload directory. Use a config-based path:
uploadDir := cfg.Upload.Dir // or a default like "./uploads"
if uploadDir == "" { uploadDir = "./uploads" }
Step 4: Add handlers in bootstrap.go (Step 9 section)
In the handlers := &router.Handlers{ block, add:
Company: v1.NewCompanyHandler(companyService),
Note: v1.NewNoteHandler(noteService),
Label: v1.NewLabelHandler(labelService),
Campaign: v1.NewCampaignHandler(campaignService),
Attachment: v1.NewAttachmentHandler(attachmentRepo, uploadDir),
Step 5: Add route groups in router.go registerV1Routes function
After the existing accountScoped routes, add these new groups:
// Company routes (ref: Chatwoot resources :companies)
companies := accountScoped.Group("/companies")
{
companies.GET("/", h.Company.List)
companies.POST("/", h.Company.Create)
companies.GET("/search", h.Company.Search)
companies.GET("/:company_id", h.Company.Get)
companies.PUT("/:company_id", h.Company.Update)
companies.DELETE("/:company_id", h.Company.Delete)
// Contact association nested under company
companyContacts := companies.Group("/:company_id/contacts")
{
companyContacts.GET("/", h.Company.ListContacts)
companyContacts.POST("/:contact_id", h.Company.AddContact)
companyContacts.DELETE("/:contact_id", h.Company.RemoveContact)
}
// Conversation association nested under company (placeholder)
companyConversations := companies.Group("/:company_id/conversations")
{
companyConversations.POST("/", h.Company.AddConversation)
companyConversations.DELETE("/:conversation_id", h.Company.RemoveConversation)
}
}
// Campaign routes (ref: Chatwoot resources :campaigns)
campaigns := accountScoped.Group("/campaigns")
{
campaigns.GET("/", h.Campaign.List)
campaigns.POST("/", h.Campaign.Create)
campaigns.GET("/:campaign_id", h.Campaign.Get)
campaigns.PUT("/:campaign_id", h.Campaign.Update)
campaigns.DELETE("/:campaign_id", h.Campaign.Delete)
campaigns.POST("/:campaign_id/send", h.Campaign.SendMessage)
}
// Attachment routes (ref: Chatwoot attachment upload/download)
attachments := accountScoped.Group("/attachments")
{
attachments.POST("/upload", h.Attachment.Upload)
attachments.GET("/:attachment_id/download", h.Attachment.Download)
attachments.DELETE("/:attachment_id", h.Attachment.Delete)
}
For Notes, they are nested under contacts:
// Note routes nested under contacts (ref: Chatwoot notes on contacts)
// In the contacts group, add:
contacts.GET("/:contact_id/notes", h.Note.List)
contacts.GET("/:contact_id/notes/:note_id", h.Note.Get)
contacts.POST("/:contact_id/notes", h.Note.Create)
contacts.PUT("/:contact_id/notes/:note_id", h.Note.Update)
contacts.DELETE("/:contact_id/notes/:note_id", h.Note.Delete)
For Labels, they are nested under conversations:
// Label routes nested under conversations (ref: Chatwoot labels on conversations)
// In the conversations group, add:
conversations.GET("/:conversation_id/labels", h.Label.List)
conversations.POST("/:conversation_id/labels", h.Label.Add)
conversations.DELETE("/:conversation_id/labels/:label", h.Label.Remove)
// Label stats at account level
accountScoped.GET("/labels/stats", h.Label.Stats)
For Help Center search, add to the articles group:
// In the articles group under portals, add:
articles.GET("/search", h.Article.Search)
Step 6: Verify compilation and build full project
cd /home/yanghao05/Workspace/gochat && go build ./...
Expected: no errors
Step 7: Commit
git add internal/router/router.go internal/app/bootstrap.go
git commit -m "feat: wire Companies/Campaign/Notes/Labels/Attachments into bootstrap and router"
Task 13: Clean up .txt stub files
Objective: Remove the .txt stub files that have been activated as real .go files.
Files:
- Delete:
internal/model/company.go.txt - Delete:
internal/model/contact_note.go.txt - Delete:
internal/model/conversation_label.go.txt - Delete:
internal/model/attachment.go.txt - Keep other .txt stubs that haven't been activated yet
Step 1: Remove activated stubs
rm internal/model/company.go.txt internal/model/contact_note.go.txt internal/model/conversation_label.go.txt internal/model/attachment.go.txt
Step 2: Verify build still works
cd /home/yanghao05/Workspace/gochat && go build ./...
Step 3: Commit
git add -u internal/model/
git commit -m "chore: remove activated .txt stub files"
Task 14: Verify full build + existing tests
Objective: Run the full project build and existing test suite to confirm nothing is broken.
Step 1: Build everything
cd /home/yanghao05/Workspace/gochat && go build ./...
Expected: clean build with no errors
Step 2: Run existing tests (SQLite mode to avoid PG dependency)
cd /home/yanghao05/Workspace/gochat && GOCHAT_TEST_DB=sqlite go test ./internal/model/... ./internal/repository/... -count=1 -timeout 60s
Expected: all existing tests pass
Step 3: Run handler tests
cd /home/yanghao05/Workspace/gochat && GOCHAT_TEST_DB=sqlite go test ./internal/handler/... -count=1 -timeout 60s
Expected: all existing handler tests pass