Files
gochat/docs/plans/2026-05-24-companies-campaign-helpcenter.md
T
rogee 0dabb8cfa5 docs: 整理文档目录结构 — 清理过时文档、归集功能子目录、统一命名规范
清理:
- 删除 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 相关修改
2026-07-09 14:53:27 +08:00

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(&note, id).Error
	if err != nil {
		return nil, err
	}
	return &note, 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(&notes).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