From af57482d5ee8706fe4d586b6ac59a18a660e381c Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 03:54:16 +0800 Subject: [PATCH] feat(crm): align contact note payloads --- docs/parity/gochat_routes.txt | 3 +- internal/handler/api/v1/company_handler.go | 4 +- .../handler/api/v1/company_handler_test.go | 7 ++ internal/handler/api/v1/contact_handler.go | 62 +++++++++++---- .../api/v1/contact_handler_crud_test.go | 78 +++++++++++++++++-- internal/handler/api/v1/crm_serializer.go | 54 ++++++++++--- internal/repository/company_repo.go | 13 +++- internal/repository/company_repo_test.go | 7 +- internal/repository/note_repo.go | 19 +++-- internal/router/router.go | 1 + internal/service/company_service.go | 2 +- internal/service/contact_service.go | 35 +++++++++ 12 files changed, 238 insertions(+), 47 deletions(-) diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 07803cb8..45250c8a 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -765,6 +765,7 @@ PUT /api/v1/accounts/:account_id/captain/preferences/ PUT /api/v1/accounts/:account_id/captain/scenarios/:scenario_id PUT /api/v1/accounts/:account_id/companies/:company_id PUT /api/v1/accounts/:account_id/contacts/:contact_id +PUT /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id PUT /api/v1/accounts/:account_id/conversations/:conversation_id PUT /api/v1/accounts/:account_id/conversations/:conversation_id/messages/:message_id PUT /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/:call_id @@ -817,4 +818,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 819 +TOTAL: 820 diff --git a/internal/handler/api/v1/company_handler.go b/internal/handler/api/v1/company_handler.go index 3ea6ea72..43a34cf5 100644 --- a/internal/handler/api/v1/company_handler.go +++ b/internal/handler/api/v1/company_handler.go @@ -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. diff --git a/internal/handler/api/v1/company_handler_test.go b/internal/handler/api/v1/company_handler_test.go index e4a0edfc..aa6cc372 100644 --- a/internal/handler/api/v1/company_handler_test.go +++ b/internal/handler/api/v1/company_handler_test.go @@ -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() { diff --git a/internal/handler/api/v1/contact_handler.go b/internal/handler/api/v1/contact_handler.go index 11a784d1..2ac4c7f4 100644 --- a/internal/handler/api/v1/contact_handler.go +++ b/internal/handler/api/v1/contact_handler.go @@ -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 { diff --git a/internal/handler/api/v1/contact_handler_crud_test.go b/internal/handler/api/v1/contact_handler_crud_test.go index ff109433..9198a855 100644 --- a/internal/handler/api/v1/contact_handler_crud_test.go +++ b/internal/handler/api/v1/contact_handler_crud_test.go @@ -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(), ¬e)) - 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(), ¬e)) + 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() { diff --git a/internal/handler/api/v1/crm_serializer.go b/internal/handler/api/v1/crm_serializer.go index 545fcd9b..a0a92c45 100644 --- a/internal/handler/api/v1/crm_serializer.go +++ b/internal/handler/api/v1/crm_serializer.go @@ -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, ¬es[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(¬es[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(¬e.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 diff --git a/internal/repository/company_repo.go b/internal/repository/company_repo.go index f11a1f43..17843132 100644 --- a/internal/repository/company_repo.go +++ b/internal/repository/company_repo.go @@ -161,12 +161,19 @@ func (r *CompanyRepo) ListNotes(ctx context.Context, companyID uint, offset, lim // CreateNote creates a note for a company. func (r *CompanyRepo) CreateNote(ctx context.Context, note *model.CompanyNote) error { - return r.db.WithContext(ctx).Create(note).Error + if err := r.db.WithContext(ctx).Create(note).Error; err != nil { + return err + } + return r.db.WithContext(ctx).Preload("User").First(note, note.ID).Error } // DeleteNote deletes a note belonging to a company. -func (r *CompanyRepo) DeleteNote(ctx context.Context, id uint) error { - return r.db.WithContext(ctx).Delete(&model.CompanyNote{}, id).Error +func (r *CompanyRepo) DeleteNote(ctx context.Context, companyID, id uint) error { + var note model.CompanyNote + if err := r.db.WithContext(ctx).Where("company_id = ? AND id = ?", companyID, id).First(¬e).Error; err != nil { + return err + } + return r.db.WithContext(ctx).Delete(¬e).Error } func (r *CompanyRepo) AddContact(ctx context.Context, companyID, contactID uint) error { diff --git a/internal/repository/company_repo_test.go b/internal/repository/company_repo_test.go index 41da39b1..2bf5b0f2 100644 --- a/internal/repository/company_repo_test.go +++ b/internal/repository/company_repo_test.go @@ -281,7 +281,7 @@ func TestCompanyRepo_DeleteNote(t *testing.T) { note := createTestCompanyNote(t, db, company.ID, user.ID, "Note to delete") - err := repo.DeleteNote(context.Background(), note.ID) + err := repo.DeleteNote(context.Background(), company.ID, note.ID) assert.NoError(t, err) // Verify the note is actually gone @@ -294,9 +294,8 @@ func TestCompanyRepo_DeleteNote_NotFound(t *testing.T) { db := setupTestDB(t) repo := NewCompanyRepo(db) - // Deleting a non-existent note should not error (GORM behavior) - err := repo.DeleteNote(context.Background(), 9999) - assert.NoError(t, err) + err := repo.DeleteNote(context.Background(), 1, 9999) + assert.Error(t, err) } // ========== AddContact Tests ========== diff --git a/internal/repository/note_repo.go b/internal/repository/note_repo.go index 99031d77..0503ce4d 100644 --- a/internal/repository/note_repo.go +++ b/internal/repository/note_repo.go @@ -50,8 +50,12 @@ func (r *NoteRepo) ListByContact(accountID, contactID uint) ([]model.Note, error // GetByID returns a single note by ID within the account scope. func (r *NoteRepo) GetByID(accountID, contactID, noteID uint) (*model.Note, error) { + return r.GetByIDContext(context.Background(), accountID, contactID, noteID) +} + +func (r *NoteRepo) GetByIDContext(ctx context.Context, accountID, contactID, noteID uint) (*model.Note, error) { var note model.Note - err := r.db.Where("account_id = ? AND contact_id = ? AND id = ?", accountID, contactID, noteID). + err := r.db.WithContext(ctx).Where("account_id = ? AND contact_id = ? AND id = ?", accountID, contactID, noteID). Preload("User"). First(¬e).Error if err != nil { @@ -80,9 +84,12 @@ func (r *NoteRepo) Update(note *model.Note) (*model.Note, error) { return note, nil } -// Delete soft-deletes a note. -// Reference: Chatwoot uses hard delete `@note.destroy!`, we use soft delete for audit trail. +// Delete hard-deletes a note, matching Chatwoot `@note.destroy!` for notes. func (r *NoteRepo) Delete(accountID, contactID, noteID uint) error { - return r.db.Where("account_id = ? AND contact_id = ? AND id = ?", accountID, contactID, noteID). - Delete(&model.Note{}).Error -} \ No newline at end of file + return r.DeleteContext(context.Background(), accountID, contactID, noteID) +} + +func (r *NoteRepo) DeleteContext(ctx context.Context, accountID, contactID, noteID uint) error { + return r.db.WithContext(ctx).Where("account_id = ? AND contact_id = ? AND id = ?", accountID, contactID, noteID). + Unscoped().Delete(&model.Note{}).Error +} diff --git a/internal/router/router.go b/internal/router/router.go index 53da2e4a..5d208ec4 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1003,6 +1003,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { contacts.GET("/:contact_id/notes", h.Contact.ListNotes) contacts.POST("/:contact_id/notes", h.Contact.CreateNote) contacts.GET("/:contact_id/notes/:note_id", h.Contact.ShowNote) + contacts.PUT("/:contact_id/notes/:note_id", h.Contact.UpdateNote) contacts.PATCH("/:contact_id/notes/:note_id", h.Contact.UpdateNote) contacts.DELETE("/:contact_id/notes/:note_id", h.Contact.DestroyNote) diff --git a/internal/service/company_service.go b/internal/service/company_service.go index 3e5c8603..2d7d1886 100644 --- a/internal/service/company_service.go +++ b/internal/service/company_service.go @@ -309,7 +309,7 @@ func (s *CompanyService) DeleteNote(ctx context.Context, noteID, companyID, acco return err } - if err := s.companyRepo.DeleteNote(ctx, noteID); err != nil { + if err := s.companyRepo.DeleteNote(ctx, companyID, noteID); err != nil { applogger.L().Errorf("Delete note %d for company %d: %v", noteID, companyID, err) return err } diff --git a/internal/service/contact_service.go b/internal/service/contact_service.go index 03ce787c..cca8c5a7 100644 --- a/internal/service/contact_service.go +++ b/internal/service/contact_service.go @@ -293,9 +293,44 @@ func (s *ContactService) CreateNote(ctx context.Context, accountID, contactID, u if err := s.noteRepo.Create(ctx, note); err != nil { return nil, err } + if s.repo != nil && s.repo.DB() != nil { + _ = s.repo.DB().WithContext(ctx).Preload("User").First(note, note.ID).Error + } return note, nil } +// GetNote retrieves a single note scoped to account and contact. +func (s *ContactService) GetNote(ctx context.Context, accountID, contactID, noteID uint) (*model.Note, error) { + if s == nil || s.noteRepo == nil { + return nil, errors.New("contact service not ready") + } + return s.noteRepo.GetByIDContext(ctx, accountID, contactID, noteID) +} + +// UpdateNote updates a note scoped to account and contact. +func (s *ContactService) UpdateNote(ctx context.Context, accountID, contactID, noteID uint, req CreateNoteRequest) (*model.Note, error) { + if err := pkgvalidator.ValidateStruct(req); err != nil { + return nil, err + } + note, err := s.noteRepo.GetByIDContext(ctx, accountID, contactID, noteID) + if err != nil { + return nil, err + } + note.Content = req.Content + return s.noteRepo.Update(note) +} + +// DeleteNote removes a note scoped to account and contact. +func (s *ContactService) DeleteNote(ctx context.Context, accountID, contactID, noteID uint) error { + if s == nil || s.noteRepo == nil { + return errors.New("contact service not ready") + } + if _, err := s.noteRepo.GetByIDContext(ctx, accountID, contactID, noteID); err != nil { + return err + } + return s.noteRepo.DeleteContext(ctx, accountID, contactID, noteID) +} + // ListActive retrieves contacts with recent activity for an account. // GET /api/v1/accounts/:id/contacts/active // Reference: Chatwoot contacts#active