package v1 import ( "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" ) // CaptainDocumentHandler handles CaptainDocument REST API endpoints. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/documents_controller.rb type CaptainDocumentHandler struct { svc *service.CaptainDocumentService } // NewCaptainDocumentHandler creates a new CaptainDocumentHandler. func NewCaptainDocumentHandler(svc *service.CaptainDocumentService) *CaptainDocumentHandler { return &CaptainDocumentHandler{svc: svc} } // Create creates a new captain document. // POST /api/v1/accounts/:account_id/captain_assistants/:assistant_id/documents func (h *CaptainDocumentHandler) Create(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } var req service.CreateDocumentRequest if err := bindNestedJSONPayload(c, "document", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } assistantID := req.AssistantID if assistantID == 0 { assistantID, _ = parseUintParam(c, "assistant_id") } if assistantID == 0 { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "Missing Assistant") return } doc, err := h.svc.Create(c.Request.Context(), assistantID, accountID, &req) if err != nil { applogger.L().Errorf("Create captain document: %v", err) response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "failed to create document") return } c.JSON(http.StatusOK, captainDocumentPayload(doc)) } // Get retrieves a captain document by ID. // GET /api/v1/accounts/:account_id/captain_documents/:id func (h *CaptainDocumentHandler) Get(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } id, err := parseUintAnyParam(c, "document_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } doc, err := h.svc.GetByAccount(c.Request.Context(), accountID, id) if err != nil { applogger.L().Errorf("Get captain document: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, "document not found") return } c.JSON(http.StatusOK, captainDocumentPayload(doc)) } // Update updates an existing captain document. // PUT /api/v1/accounts/:account_id/captain_documents/:id func (h *CaptainDocumentHandler) Update(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } var req service.UpdateDocumentRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } doc, err := h.svc.Update(c.Request.Context(), uint(id), &req) if err != nil { applogger.L().Errorf("Update captain document: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update document") return } response.OK(c, doc) } // Delete deletes a captain document. // DELETE /api/v1/accounts/:account_id/captain_documents/:id func (h *CaptainDocumentHandler) Delete(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } id, err := parseUintAnyParam(c, "document_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } if err := h.svc.DeleteByAccount(c.Request.Context(), accountID, id); err != nil { applogger.L().Errorf("Delete captain document: %v", err) response.AbortWithStatusError(c, captainAssistantErrorStatus(err), response.ErrInternal, "failed to delete document") return } response.NoContent(c) } // List retrieves documents for an assistant. // GET /api/v1/accounts/:account_id/captain_assistants/:assistant_id/documents func (h *CaptainDocumentHandler) List(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) assistantID, _ := parseOptionalUintQueryParam(c, "assistant_id") if assistantID == 0 { assistantID, _ = parseUintParam(c, "assistant_id") } docs, count, currentPage, err := h.svc.ListByAccount(c.Request.Context(), accountID, service.ListDocumentsRequest{ AssistantID: assistantID, Page: page, PerPage: 25, Filter: c.Query("filter"), Source: c.Query("source"), Sort: c.Query("sort"), SearchKey: c.Query("search_key"), }) if err != nil { applogger.L().Errorf("List captain documents: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list documents") return } payload := make([]gin.H, 0, len(docs)) for i := range docs { payload = append(payload, captainDocumentPayload(&docs[i])) } c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"total_count": count, "page": currentPage}}) } // ProcessDocument triggers document content extraction and embedding generation. // POST /api/v1/accounts/:account_id/captain_documents/:id/process func (h *CaptainDocumentHandler) ProcessDocument(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } if err := h.svc.ProcessDocument(c.Request.Context(), uint(id)); err != nil { applogger.L().Errorf("ProcessDocument: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to process document") return } response.OK(c, gin.H{"processed": true}) } // SyncDocument triggers re-fetching content from the external URL. // POST /api/v1/accounts/:account_id/captain_documents/:id/sync func (h *CaptainDocumentHandler) SyncDocument(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } id, err := parseUintAnyParam(c, "document_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } if _, err := h.svc.RequestSyncDocumentByAccount(c.Request.Context(), accountID, id); err != nil { applogger.L().Errorf("SyncDocument: %v", err) response.AbortWithStatusError(c, captainAssistantErrorStatus(err), response.ErrInternal, "failed to sync document") return } c.Status(http.StatusAccepted) } func captainDocumentPayload(doc *model.CaptainDocument) gin.H { status := doc.Status if status == "" { status = model.DocumentStatusPending } syncStatus := doc.SyncStatus if syncStatus == model.DocumentSyncStatusPending { syncStatus = "syncing" } payload := gin.H{ "account_id": doc.AccountID, "assistant": captainDocumentAssistantPayload(doc), "content": doc.Content, "content_type": "text/html", "created_at": doc.CreatedAt.Unix(), "external_link": doc.ExternalLink, "display_url": doc.ExternalLink, "file_size": 0, "pdf_document": false, "id": doc.ID, "name": doc.Name, "status": status, "sync_status": syncStatus, "sync_in_progress": syncStatus == "syncing" || syncStatus == model.DocumentSyncStatusPending, "last_synced_at": int64PointerValue(doc.LastSyncedAt), "last_sync_attempted_at": int64PointerValue(doc.LastSyncAttemptedAt), "last_sync_error_code": doc.LastSyncErrorCode, "updated_at": doc.UpdatedAt.Unix(), } return payload } func captainDocumentAssistantPayload(doc *model.CaptainDocument) gin.H { if doc.Assistant.ID == 0 { return gin.H{"id": doc.AssistantID} } return captainAssistantPayload(&doc.Assistant) } func int64PointerValue(value *int64) any { if value == nil { return nil } return *value }