feat(crm): align contact note payloads

This commit is contained in:
2026-06-05 03:54:16 +08:00
parent 096e4ee40d
commit af57482d5e
12 changed files with 238 additions and 47 deletions
+2 -2
View File
@@ -328,7 +328,7 @@ func (h *CompanyHandler) ListNotes(c *gin.Context) {
}
_ = total
c.JSON(http.StatusOK, companyNotesResponse(c.Request.Context(), h.svc.DB(), notes))
c.JSON(http.StatusOK, companyNotesResponse(c.Request.Context(), h.svc.DB(), notes, accountID))
}
// CreateNote creates a note for a company.
@@ -364,7 +364,7 @@ func (h *CompanyHandler) CreateNote(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"payload": note})
c.JSON(http.StatusOK, gin.H{"payload": serializeCompanyNote(c.Request.Context(), h.svc.DB(), note, accountID)})
}
// DeleteNote deletes a note from a company.
@@ -405,6 +405,11 @@ func (s *CompanyHandlerTestSuite) TestListNotes_Success() {
payload := resp["payload"].([]interface{})
assert.Len(s.T(), payload, 2)
first := payload[0].(map[string]interface{})
assert.Contains(s.T(), first, "company_id")
assert.Contains(s.T(), first, "user")
user := first["user"].(map[string]interface{})
assert.Equal(s.T(), "TestUser", user["name"])
}
// ========== CreateNote ==========
@@ -425,6 +430,8 @@ func (s *CompanyHandlerTestSuite) TestCreateNote_Success() {
noteData := resp["payload"].(map[string]interface{})
assert.Equal(s.T(), "This is a new note", noteData["content"])
assert.Equal(s.T(), float64(company.ID), noteData["company_id"])
assert.Contains(s.T(), noteData, "user")
}
func (s *CompanyHandlerTestSuite) TestCreateNote_ValidationError() {
+47 -15
View File
@@ -441,7 +441,7 @@ func (h *ContactHandler) ListNotes(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"payload": notes})
c.JSON(http.StatusOK, contactNotesResponse(notes))
}
// CreateNote creates a note for a contact.
@@ -466,8 +466,8 @@ func (h *ContactHandler) CreateNote(c *gin.Context) {
return
}
var req service.CreateNoteRequest
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
req, bindErr := bindContactNoteRequest(c)
if bindErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
return
}
@@ -478,7 +478,7 @@ func (h *ContactHandler) CreateNote(c *gin.Context) {
return
}
c.JSON(http.StatusCreated, note)
c.JSON(http.StatusOK, serializeContactNote(note))
}
// ShowNote retrieves a single note for a contact.
@@ -496,13 +496,19 @@ func (h *ContactHandler) ShowNote(c *gin.Context) {
return
}
note, svcErr := h.contactNoteSvc.GetByID(c.Request.Context(), accountID, noteID)
contactID, err := parseUintParam(c, "contact_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
return
}
note, svcErr := h.svc.GetNote(c.Request.Context(), accountID, contactID, noteID)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, note)
c.JSON(http.StatusOK, serializeContactNote(note))
}
// UpdateNote updates a note on a contact.
@@ -520,19 +526,25 @@ func (h *ContactHandler) UpdateNote(c *gin.Context) {
return
}
var req service.NoteUpdateRequest
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
contactID, err := parseUintParam(c, "contact_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
return
}
req, bindErr := bindContactNoteRequest(c)
if bindErr != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, bindErr.Error())
return
}
note, svcErr := h.contactNoteSvc.UpdateNote(c.Request.Context(), accountID, noteID, req)
note, svcErr := h.svc.UpdateNote(c.Request.Context(), accountID, contactID, noteID, req)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, note)
c.JSON(http.StatusOK, serializeContactNote(note))
}
// DestroyNote deletes a note on a contact.
@@ -550,15 +562,35 @@ func (h *ContactHandler) DestroyNote(c *gin.Context) {
return
}
if svcErr := h.contactNoteSvc.DeleteNote(c.Request.Context(), accountID, noteID); svcErr != nil {
contactID, err := parseUintParam(c, "contact_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid contact id")
return
}
if svcErr := h.svc.DeleteNote(c.Request.Context(), accountID, contactID, noteID); svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, gin.H{
"id": strconv.FormatUint(uint64(noteID), 10),
"deleted": true,
})
c.Status(http.StatusOK)
}
func bindContactNoteRequest(c *gin.Context) (service.CreateNoteRequest, error) {
var body struct {
Content string `json:"content"`
Note struct {
Content string `json:"content"`
} `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
return service.CreateNoteRequest{}, err
}
content := body.Content
if content == "" {
content = body.Note.Content
}
return service.CreateNoteRequest{Content: content}, nil
}
func includeContactInboxes(c *gin.Context) bool {
@@ -100,6 +100,10 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.ListContactInboxes)
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.ListNotes)
s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.CreateNote)
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/notes/:note_id", s.handler.ShowNote)
s.router.PUT("/api/v1/accounts/:id/contacts/:contact_id/notes/:note_id", s.handler.UpdateNote)
s.router.PATCH("/api/v1/accounts/:id/contacts/:contact_id/notes/:note_id", s.handler.UpdateNote)
s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id/notes/:note_id", s.handler.DestroyNote)
s.router.POST("/api/v1/accounts/:id/actions/contact_merge", s.handler.Merge)
// Create test data
@@ -894,9 +898,15 @@ func (s *ContactHandlerCRUDTestSuite) TestListNotes_Success() {
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
var resp []map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Contains(resp, "payload")
s.Len(resp, 1)
s.Equal("Test note content", resp[0]["content"])
s.Equal(float64(s.account.ID), resp[0]["account_id"])
s.Equal(float64(s.contact.ID), resp[0]["contact_id"])
user := resp[0]["user"].(map[string]interface{})
s.Equal(s.user.Name, user["name"])
s.NotContains(resp[0], "payload")
}
func (s *ContactHandlerCRUDTestSuite) TestListNotes_InvalidAccountID() {
@@ -944,12 +954,68 @@ func (s *ContactHandlerCRUDTestSuite) TestCreateNote_Success() {
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusCreated, w.Code)
s.Equal(http.StatusOK, w.Code)
var note model.Note
var note map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &note))
s.Equal("A new note for the contact", note.Content)
s.NotZero(note.ID)
s.Equal("A new note for the contact", note["content"])
s.Equal(float64(s.account.ID), note["account_id"])
s.Equal(float64(s.contact.ID), note["contact_id"])
s.NotZero(note["id"])
s.Contains(note, "user")
}
func (s *ContactHandlerCRUDTestSuite) TestCreateNote_NestedNotePayload() {
body := map[string]interface{}{
"note": map[string]interface{}{"content": "Nested note content"},
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST",
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes", s.account.ID, s.contact.ID),
bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var note map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &note))
s.Equal("Nested note content", note["content"])
}
func (s *ContactHandlerCRUDTestSuite) TestShowUpdateDestroyNote_RawChatwootShape() {
note := &model.Note{Content: "Original", AccountID: s.account.ID, ContactID: s.contact.ID, UserID: &s.user.ID}
s.Require().NoError(s.db.Create(note).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET",
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes/%d", s.account.ID, s.contact.ID, note.ID), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Equal("Original", resp["content"])
s.NotContains(resp, "success")
s.NotContains(resp, "data")
bodyBytes, _ := json.Marshal(map[string]interface{}{"note": map[string]interface{}{"content": "Updated"}})
w = httptest.NewRecorder()
req, _ = http.NewRequest("PATCH",
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes/%d", s.account.ID, s.contact.ID, note.ID),
bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Equal("Updated", resp["content"])
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE",
fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/notes/%d", s.account.ID, s.contact.ID, note.ID), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Empty(w.Body.String())
}
func (s *ContactHandlerCRUDTestSuite) TestCreateNote_InvalidAccountID() {
+45 -9
View File
@@ -160,21 +160,57 @@ func companyContactsResponse(ctx context.Context, db *gorm.DB, companyID uint, c
}
}
func companyNotesResponse(ctx context.Context, db *gorm.DB, notes []model.CompanyNote) map[string]any {
func companyNotesResponse(ctx context.Context, db *gorm.DB, notes []model.CompanyNote, accountID uint) map[string]any {
payload := make([]any, 0, len(notes))
for i := range notes {
item := map[string]any{
"id": notes[i].ID,
"content": notes[i].Content,
"user_id": notes[i].UserID,
"created_at": notes[i].CreatedAt.Unix(),
"updated_at": notes[i].UpdatedAt.Unix(),
}
payload = append(payload, item)
payload = append(payload, serializeCompanyNote(ctx, db, &notes[i], accountID))
}
return map[string]any{"payload": payload}
}
func contactNotesResponse(notes []model.Note) []any {
payload := make([]any, 0, len(notes))
for i := range notes {
payload = append(payload, serializeContactNote(&notes[i]))
}
return payload
}
func serializeContactNote(note *model.Note) map[string]any {
item := map[string]any{
"id": note.ID,
"content": note.Content,
"account_id": note.AccountID,
"contact_id": note.ContactID,
"created_at": note.CreatedAt.Unix(),
"updated_at": note.UpdatedAt.Unix(),
}
if note.UserID != nil {
item["user_id"] = *note.UserID
}
if note.User != nil && note.User.ID != 0 {
item["user"] = serializeUser(note.User, note.AccountID)
}
return item
}
func serializeCompanyNote(ctx context.Context, db *gorm.DB, note *model.CompanyNote, accountID uint) map[string]any {
item := map[string]any{
"id": note.ID,
"content": note.Content,
"company_id": note.CompanyID,
"user_id": note.UserID,
"created_at": note.CreatedAt.Unix(),
"updated_at": note.UpdatedAt.Unix(),
}
if note.User.ID != 0 {
item["user"] = serializeUser(&note.User, accountID)
} else if db != nil && note.UserID != 0 {
item["user"] = serializeUserFromDB(ctx, db, note.UserID, accountID)
}
return item
}
func companyContactsCount(ctx context.Context, db *gorm.DB, company *model.Company) int64 {
if db == nil || company == nil || company.ID == 0 {
return 0