From e10923e6b75fe05b283d874d97c61bd2900ead20 Mon Sep 17 00:00:00 2001 From: Josh Freda Date: Wed, 6 Sep 2023 12:34:57 -0500 Subject: [PATCH 01/13] Log user for 'wrong number of people in search result' error (#326) --- internal/api/me.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/api/me.go b/internal/api/me.go index 4bb0a661d..014762cee 100644 --- a/internal/api/me.go +++ b/internal/api/me.go @@ -60,11 +60,15 @@ func MeHandler( return case "GET": - errResp := func(httpCode int, userErrMsg, logErrMsg string, err error) { + errResp := func( + httpCode int, userErrMsg, logErrMsg string, err error, + extraArgs ...interface{}) { l.Error(logErrMsg, - "method", r.Method, - "path", r.URL.Path, - "error", err, + append([]interface{}{ + "error", err, + "method", r.Method, + "path", r.URL.Path, + }, extraArgs...)..., ) http.Error(w, userErrMsg, httpCode) } @@ -87,7 +91,8 @@ func MeHandler( "Error getting user information", fmt.Sprintf( "wrong number of people in search result: %d", len(ppl)), - err, + nil, + "user_email", userEmail, ) return } From 855a23692dce21e3be75531b39b3df74c38a1661 Mon Sep 17 00:00:00 2001 From: Josh Freda Date: Fri, 8 Sep 2023 12:53:05 -0500 Subject: [PATCH 02/13] Support custom document types (#328) * Add document package * Use new document package in API * Use new document package in indexer * Save custom fields differently from other fields in sidebar * Don't validate doc type in frontend * Add an example of a custom document type and remove note to not modify the document_types block --- configs/config.hcl | 10 +- go.mod | 2 + go.sum | 4 + internal/api/approvals.go | 234 +++--- internal/api/documents.go | 478 ++++++----- internal/api/documents_related_resources.go | 58 +- internal/api/drafts.go | 623 ++++++++------- internal/api/drafts_shareable.go | 6 +- internal/api/reviews.go | 187 +++-- internal/cmd/commands/indexer/indexer.go | 1 + internal/indexer/indexer.go | 42 +- internal/indexer/refresh_headers.go | 68 +- pkg/document/doc.go | 3 + pkg/document/document.go | 346 ++++++++ pkg/document/replace_header.go | 830 ++++++++++++++++++++ web/app/components/document/sidebar.hbs | 16 +- web/app/components/document/sidebar.ts | 39 +- web/app/routes/authenticated/new/doc.js | 17 - web/app/types/document.d.ts | 1 + 19 files changed, 2170 insertions(+), 795 deletions(-) create mode 100644 pkg/document/doc.go create mode 100644 pkg/document/document.go create mode 100644 pkg/document/replace_header.go diff --git a/configs/config.hcl b/configs/config.hcl index 1704f3228..bb7ea7413 100644 --- a/configs/config.hcl +++ b/configs/config.hcl @@ -14,9 +14,7 @@ algolia { write_api_key = "" } -// document_types configures document types. Currently this block should not be -// modified, but Hermes will support custom document types in the near future. -// *** DO NOT MODIFY document_types *** +// document_types configures document types. document_types { document_type "RFC" { long_name = "Request for Comments" @@ -65,6 +63,12 @@ document_types { type = "people" } } + + // document_type "MEMO" { + // long_name = "Memo" + // description = "Create a Memo document to share an idea or brief note with colleagues." + // template = "file-id-for-a-blank-doc" + // } } // email configures Hermes to send email notifications. diff --git a/go.mod b/go.mod index 8cb5f9a3e..24a842025 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,9 @@ require ( github.com/hashicorp/go-hclog v1.2.0 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/hcl/v2 v2.11.1 + github.com/iancoleman/strcase v0.3.0 github.com/mitchellh/cli v1.1.2 + github.com/mitchellh/mapstructure v1.5.0 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/stretchr/testify v1.8.1 golang.org/x/oauth2 v0.3.0 diff --git a/go.sum b/go.sum index 2b8b379ee..8f6640ebb 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,8 @@ github.com/hashicorp/hcl/v2 v2.11.1 h1:yTyWcXcm9XB0TEkyU/JCRU6rYy4K+mgLtzn2wlrJb github.com/hashicorp/hcl/v2 v2.11.1/go.mod h1:FwWsfWEjyV/CMj8s/gqAuiviY72rJ1/oayI9WftqcKg= github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= @@ -219,6 +221,8 @@ github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HK github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= diff --git a/internal/api/approvals.go b/internal/api/approvals.go index 9862fe3a5..58890142f 100644 --- a/internal/api/approvals.go +++ b/internal/api/approvals.go @@ -4,9 +4,11 @@ import ( "fmt" "net/http" + "github.com/algolia/algoliasearch-client-go/v3/algolia/errs" "github.com/hashicorp-forge/hermes/internal/config" "github.com/hashicorp-forge/hermes/internal/helpers" "github.com/hashicorp-forge/hermes/pkg/algolia" + "github.com/hashicorp-forge/hermes/pkg/document" gw "github.com/hashicorp-forge/hermes/pkg/googleworkspace" hcd "github.com/hashicorp-forge/hermes/pkg/hashicorpdocs" "github.com/hashicorp-forge/hermes/pkg/models" @@ -55,44 +57,39 @@ func ApprovalHandler( return } - // Get base document object from Algolia so we can determine the doc type. - baseDocObj := &hcd.BaseDoc{} - err = ar.Docs.GetObject(docID, &baseDocObj) + // Get document object from Algolia. + var algoObj map[string]any + err = ar.Docs.GetObject(docID, &algoObj) if err != nil { - l.Error("error requesting base document object from Algolia", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error requesting changes of document", - http.StatusInternalServerError) - return + // Handle 404 from Algolia and only log a warning. + if _, is404 := errs.IsAlgoliaErrWithCode(err, 404); is404 { + l.Warn("document object not found in Algolia", + "error", err, + "path", r.URL.Path, + "method", r.Method, + "doc_id", docID, + ) + http.Error(w, "Document not found", http.StatusNotFound) + return + } else { + l.Error("error requesting document from Algolia", + "error", err, + "doc_id", docID, + ) + http.Error(w, "Error accessing document", + http.StatusInternalServerError) + return + } } - // Create new document object of the proper doc type. - docObj, err := hcd.NewEmptyDoc(baseDocObj.DocType) + // Convert Algolia object to a document. + doc, err := document.NewFromAlgoliaObject( + algoObj, cfg.DocumentTypes.DocumentType) if err != nil { - l.Error("error creating new empty doc", + l.Error("error converting Algolia object to document type", "error", err, - "path", r.URL.Path, - "method", r.Method, "doc_id", docID, ) - http.Error(w, "Error requesting changes of document", - http.StatusInternalServerError) - return - } - - // Get document object from Algolia. - err = ar.Docs.GetObject(docID, &docObj) - if err != nil { - l.Error("error getting document from Algolia", - "error", err, - "doc_id", docID, - "method", r.Method, - "path", r.URL.Path, - ) http.Error(w, "Error accessing document", http.StatusInternalServerError) return @@ -100,36 +97,35 @@ func ApprovalHandler( // Authorize request. userEmail := r.Context().Value("userEmail").(string) - if docObj.GetStatus() != "In-Review" { + if doc.Status != "In-Review" { http.Error(w, "Can only request changes of documents in the \"In-Review\" status", http.StatusBadRequest) return } - if !contains(docObj.GetApprovers(), userEmail) { + if !contains(doc.Approvers, userEmail) { http.Error(w, "Not authorized as a document approver", http.StatusUnauthorized) return } - if contains(docObj.GetChangesRequestedBy(), userEmail) { + if contains(doc.ChangesRequestedBy, userEmail) { http.Error(w, "Document already has changes requested by user", http.StatusBadRequest) return } // Add email to slice of users who have requested changes of the document. - docObj.SetChangesRequestedBy( - append(docObj.GetChangesRequestedBy(), userEmail)) + doc.ChangesRequestedBy = append(doc.ChangesRequestedBy, userEmail) // If user had previously approved, delete email from slice of users who // have approved the document. var newApprovedBy []string - for _, a := range docObj.GetApprovedBy() { + for _, a := range doc.ApprovedBy { if a != userEmail { newApprovedBy = append(newApprovedBy, a) } } - docObj.SetApprovedBy(newApprovedBy) + doc.ApprovedBy = newApprovedBy // Get latest Google Drive file revision. latestRev, err := s.GetLatestRevision(docID) @@ -153,45 +149,55 @@ func ApprovalHandler( "path", r.URL.Path, "doc_id", docID, "rev_id", latestRev.Id) - http.Error(w, "Error requesting changes", + http.Error(w, "Error updating document status", http.StatusInternalServerError) return } // Record file revision in the Algolia document object. revisionName := fmt.Sprintf("Changes requested by %s", userEmail) - docObj.SetFileRevision(latestRev.Id, revisionName) + doc.SetFileRevision(latestRev.Id, revisionName) - // Save modified doc object in Algolia. - res, err := aw.Docs.SaveObject(docObj) + // Convert document to Algolia object. + docObj, err := doc.ToAlgoliaObject(true) if err != nil { - l.Error("error saving requested changes doc object in Algolia", + l.Error("error converting document to Algolia object", "error", err, - "doc_id", docID, "method", r.Method, "path", r.URL.Path, + "doc_id", docID, ) - http.Error(w, "Error requesting changes of document", + http.Error(w, "Error updating document status", + http.StatusInternalServerError) + return + } + + // Save new modified doc object in Algolia. + res, err := aw.Docs.SaveObject(docObj) + if err != nil { + l.Error("error saving approved document in Algolia", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID) + http.Error(w, "Error updating document status", http.StatusInternalServerError) return } err = res.Wait() if err != nil { - l.Error("error saving requested changes doc object in Algolia", + l.Error("error saving patched document in Algolia", "error", err, - "doc_id", docID, "method", r.Method, "path", r.URL.Path, - ) - http.Error(w, "Error requesting changes of document", + "doc_id", docID) + http.Error(w, "Error updating document status", http.StatusInternalServerError) return } // Replace the doc header. - err = docObj.ReplaceHeader( - docID, cfg.BaseURL, true, s) - if err != nil { + if err := doc.ReplaceHeader(cfg.BaseURL, false, s); err != nil { l.Error("error replacing doc header", "error", err, "doc_id", docID, @@ -204,7 +210,7 @@ func ApprovalHandler( } // Update document reviews in the database. - if err := updateDocumentReviewsInDatabase(docObj, db); err != nil { + if err := updateDocumentReviewsInDatabase(*doc, db); err != nil { l.Error("error updating document reviews in the database", "error", err, "doc_id", docID, @@ -256,43 +262,38 @@ func ApprovalHandler( return } - // Get base document object from Algolia so we can determine the doc type. - baseDocObj := &hcd.BaseDoc{} - err = ar.Docs.GetObject(docID, &baseDocObj) - if err != nil { - l.Error("error requesting base document object from Algolia", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error creating review", - http.StatusInternalServerError) - return - } - - // Create new document object of the proper doc type. - docObj, err := hcd.NewEmptyDoc(baseDocObj.DocType) + // Get document object from Algolia. + var algoObj map[string]any + err = ar.Docs.GetObject(docID, &algoObj) if err != nil { - l.Error("error creating new empty doc", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error creating review", - http.StatusInternalServerError) - return + // Handle 404 from Algolia and only log a warning. + if _, is404 := errs.IsAlgoliaErrWithCode(err, 404); is404 { + l.Warn("document object not found in Algolia", + "error", err, + "path", r.URL.Path, + "method", r.Method, + "doc_id", docID, + ) + http.Error(w, "Document not found", http.StatusNotFound) + return + } else { + l.Error("error requesting document from Algolia", + "error", err, + "doc_id", docID, + ) + http.Error(w, "Error accessing document", + http.StatusInternalServerError) + return + } } - // Get document object from Algolia. - err = ar.Docs.GetObject(docID, &docObj) + // Convert Algolia object to a document. + doc, err := document.NewFromAlgoliaObject( + algoObj, cfg.DocumentTypes.DocumentType) if err != nil { - l.Error("error getting document from Algolia", + l.Error("error converting Algolia object to document type", "error", err, "doc_id", docID, - "method", r.Method, - "path", r.URL.Path, ) http.Error(w, "Error accessing document", http.StatusInternalServerError) @@ -301,19 +302,19 @@ func ApprovalHandler( // Authorize request. userEmail := r.Context().Value("userEmail").(string) - if docObj.GetStatus() != "In-Review" && docObj.GetStatus() != "In Review" { + if doc.Status != "In-Review" && doc.Status != "In Review" { http.Error(w, "Only documents in the \"In-Review\" status can be approved", http.StatusBadRequest) return } - if !contains(docObj.GetApprovers(), userEmail) { + if !contains(doc.Approvers, userEmail) { http.Error(w, "Not authorized as a document approver", http.StatusUnauthorized) return } - if contains(docObj.GetApprovedBy(), userEmail) { + if contains(doc.ApprovedBy, userEmail) { http.Error(w, "Document already approved by user", http.StatusBadRequest) @@ -321,17 +322,17 @@ func ApprovalHandler( } // Add email to slice of users who have approved the document. - docObj.SetApprovedBy(append(docObj.GetApprovedBy(), userEmail)) + doc.ApprovedBy = append(doc.ApprovedBy, userEmail) // If the user had previously requested changes, delete email from slice // of users who have requested changes of the document. var newChangesRequestedBy []string - for _, a := range docObj.GetChangesRequestedBy() { + for _, a := range doc.ChangesRequestedBy { if a != userEmail { newChangesRequestedBy = append(newChangesRequestedBy, a) } } - docObj.SetChangesRequestedBy(newChangesRequestedBy) + doc.ChangesRequestedBy = newChangesRequestedBy // Get latest Google Drive file revision. latestRev, err := s.GetLatestRevision(docID) @@ -362,37 +363,48 @@ func ApprovalHandler( // Record file revision in the Algolia document object. revisionName := fmt.Sprintf("Approved by %s", userEmail) - docObj.SetFileRevision(latestRev.Id, revisionName) + doc.SetFileRevision(latestRev.Id, revisionName) - // Save modified doc object in Algolia. - res, err := aw.Docs.SaveObject(docObj) + // Convert document to Algolia object. + docObj, err := doc.ToAlgoliaObject(true) if err != nil { - l.Error("error saving approved doc object in Algolia", + l.Error("error converting document to Algolia object", "error", err, - "doc_id", docID, "method", r.Method, "path", r.URL.Path, + "doc_id", docID, ) - http.Error(w, "Error approving document", + http.Error(w, "Error updating document status", + http.StatusInternalServerError) + return + } + + // Save new modified doc object in Algolia. + res, err := aw.Docs.SaveObject(docObj) + if err != nil { + l.Error("error saving approved document in Algolia", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID) + http.Error(w, "Error updating document status", http.StatusInternalServerError) return } err = res.Wait() if err != nil { - l.Error("error saving approved doc object in Algolia", + l.Error("error saving approved document in Algolia", "error", err, - "doc_id", docID, "method", r.Method, "path", r.URL.Path, - ) - http.Error(w, "Error approving document", + "doc_id", docID) + http.Error(w, "Error updating document status", http.StatusInternalServerError) return } // Replace the doc header. - err = docObj.ReplaceHeader( - docID, cfg.BaseURL, true, s) + err = doc.ReplaceHeader(cfg.BaseURL, false, s) if err != nil { l.Error("error replacing doc header", "error", err, @@ -406,7 +418,7 @@ func ApprovalHandler( } // Update document reviews in the database. - if err := updateDocumentReviewsInDatabase(docObj, db); err != nil { + if err := updateDocumentReviewsInDatabase(*doc, db); err != nil { l.Error("error updating document reviews in the database", "error", err, "doc_id", docID, @@ -434,26 +446,26 @@ func ApprovalHandler( }) } -// updateDocumentReviewsInDatabase takes a Doc (Algolia) object and updates the -// associated document reviews in the database. -func updateDocumentReviewsInDatabase(doc hcd.Doc, db *gorm.DB) error { +// updateDocumentReviewsInDatabase takes a document and updates the associated +// document reviews in the database. +func updateDocumentReviewsInDatabase(doc document.Document, db *gorm.DB) error { var docReviews []models.DocumentReview - for _, a := range doc.GetApprovers() { + for _, a := range doc.Approvers { u := models.User{ EmailAddress: a, } - if helpers.StringSliceContains(doc.GetApprovedBy(), a) { + if helpers.StringSliceContains(doc.ApprovedBy, a) { docReviews = append(docReviews, models.DocumentReview{ Document: models.Document{ - GoogleFileID: doc.GetObjectID(), + GoogleFileID: doc.ObjectID, }, User: u, Status: models.ApprovedDocumentReviewStatus, }) - } else if helpers.StringSliceContains(doc.GetChangesRequestedBy(), a) { + } else if helpers.StringSliceContains(doc.ChangesRequestedBy, a) { docReviews = append(docReviews, models.DocumentReview{ Document: models.Document{ - GoogleFileID: doc.GetObjectID(), + GoogleFileID: doc.ObjectID, }, User: u, Status: models.ChangesRequestedDocumentReviewStatus, diff --git a/internal/api/documents.go b/internal/api/documents.go index 3635d5ad7..e3181342a 100644 --- a/internal/api/documents.go +++ b/internal/api/documents.go @@ -1,18 +1,18 @@ package api import ( - "bytes" "encoding/json" "errors" "fmt" - "io/ioutil" "net/http" + "reflect" "regexp" "time" "github.com/algolia/algoliasearch-client-go/v3/algolia/errs" "github.com/hashicorp-forge/hermes/internal/config" "github.com/hashicorp-forge/hermes/pkg/algolia" + "github.com/hashicorp-forge/hermes/pkg/document" gw "github.com/hashicorp-forge/hermes/pkg/googleworkspace" hcd "github.com/hashicorp-forge/hermes/pkg/hashicorpdocs" "github.com/hashicorp-forge/hermes/pkg/models" @@ -23,21 +23,13 @@ import ( // DocumentPatchRequest contains a subset of documents fields that are allowed // to be updated with a PATCH request. type DocumentPatchRequest struct { - Approvers []string `json:"approvers,omitempty"` - Contributors []string `json:"contributors,omitempty"` - Status string `json:"status,omitempty"` - Summary string `json:"summary,omitempty"` + Approvers *[]string `json:"approvers,omitempty"` + Contributors *[]string `json:"contributors,omitempty"` + CustomFields *[]document.CustomField `json:"customFields,omitempty"` + Status *string `json:"status,omitempty"` + Summary *string `json:"summary,omitempty"` // Tags []string `json:"tags,omitempty"` - Title string `json:"title,omitempty"` - - // TODO: These are all current custom editable fields for all supported doc - // types. We should instead make this dynamic. - CurrentVersion *string `json:"currentVersion,omitempty"` - PRD *string `json:"prd,omitempty"` - PRFAQ *string `json:"prfaq,omitempty"` - RFC *string `json:"rfc,omitempty"` - Stakeholders *[]string `json:"stakeholders,omitempty"` - TargetVersion *string `json:"targetVersion,omitempty"` + Title *string `json:"title,omitempty"` } type documentSubcollectionRequestType int @@ -71,13 +63,13 @@ func DocumentHandler( return } - // Get base document object from Algolia so we can determine the doc type. - baseDocObj := &hcd.BaseDoc{} - err = ar.Docs.GetObject(docID, &baseDocObj) + // Get document object from Algolia. + var algoObj map[string]any + err = ar.Docs.GetObject(docID, &algoObj) if err != nil { // Handle 404 from Algolia and only log a warning. if _, is404 := errs.IsAlgoliaErrWithCode(err, 404); is404 { - l.Warn("base document object not found", + l.Warn("document object not found in Algolia", "error", err, "path", r.URL.Path, "method", r.Method, @@ -86,10 +78,8 @@ func DocumentHandler( http.Error(w, "Document not found", http.StatusNotFound) return } else { - l.Error("error requesting base document object from Algolia", + l.Error("error requesting document from Algolia", "error", err, - "path", r.URL.Path, - "method", r.Method, "doc_id", docID, ) http.Error(w, "Error accessing document", @@ -98,13 +88,12 @@ func DocumentHandler( } } - // Create new document object of the proper doc type. - docObj, err := hcd.NewEmptyDoc(baseDocObj.DocType) + // Convert Algolia object to a document. + doc, err := document.NewFromAlgoliaObject( + algoObj, cfg.DocumentTypes.DocumentType) if err != nil { - l.Error("error creating new empty doc", + l.Error("error converting Algolia object to document type", "error", err, - "path", r.URL.Path, - "method", r.Method, "doc_id", docID, ) http.Error(w, "Error accessing document", @@ -112,24 +101,12 @@ func DocumentHandler( return } - // Get document object from Algolia. - err = ar.Docs.GetObject(docID, &docObj) - if err != nil { - l.Error("error retrieving document object from Algolia", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error accessing document", http.StatusInternalServerError) - return - } - // Pass request off to associated subcollection (part of the URL after the // document ID) handler, if appropriate. switch reqType { case relatedResourcesDocumentSubcollectionRequestType: - documentsResourceRelatedResourcesHandler(w, r, docID, docObj, l, ar, db) + documentsResourceRelatedResourcesHandler( + w, r, docID, *doc, cfg, l, ar, db) return case shareableDocumentSubcollectionRequestType: l.Warn("invalid shareable request for documents collection", @@ -170,16 +147,13 @@ func DocumentHandler( http.Error(w, "Error requesting document", http.StatusInternalServerError) return } - docObj.SetModifiedTime(modifiedTime.Unix()) - - // Set custom editable fields. - docObj.SetCustomEditableFields() + doc.ModifiedTime = modifiedTime.Unix() // Get document from database. - doc := models.Document{ + model := models.Document{ GoogleFileID: docID, } - if err := doc.Get(db); err != nil { + if err := model.Get(db); err != nil { l.Error("error getting document from database", "error", err, "path", r.URL.Path, @@ -193,7 +167,22 @@ func DocumentHandler( // Set locked value for response to value from the database (this value // isn't stored in Algolia). - docObj.SetLocked(doc.Locked) + doc.Locked = model.Locked + + // Convert document to Algolia object because this is how it is expected + // by the frontend. + docObj, err := doc.ToAlgoliaObject(false) + if err != nil { + l.Error("error converting document to Algolia object", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + http.Error(w, "Error getting document", + http.StatusInternalServerError) + return + } // Write response. w.Header().Set("Content-Type", "application/json") @@ -241,28 +230,11 @@ func DocumentHandler( case "PATCH": // Authorize request (only the owner can PATCH the doc). userEmail := r.Context().Value("userEmail").(string) - if docObj.GetOwners()[0] != userEmail { + if doc.Owners[0] != userEmail { http.Error(w, "Not a document owner", http.StatusUnauthorized) return } - // Copy request body so we can use both for validation using the request - // struct, and then afterwards for patching the document JSON. - buf, err := ioutil.ReadAll(r.Body) - if err != nil { - l.Error("error reading request body", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID) - http.Error(w, "Error patching document", - http.StatusInternalServerError) - return - } - body := ioutil.NopCloser(bytes.NewBuffer(buf)) - newBody := ioutil.NopCloser(bytes.NewBuffer(buf)) - r.Body = newBody - // Decode request. The request struct validates that the request only // contains fields that are allowed to be patched. var req DocumentPatchRequest @@ -273,6 +245,48 @@ func DocumentHandler( return } + // Validate custom fields. + if req.CustomFields != nil { + for _, cf := range *req.CustomFields { + cef, ok := doc.CustomEditableFields[cf.Name] + if !ok { + l.Error("custom field not found", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docID) + http.Error(w, "Bad request: invalid custom field", + http.StatusBadRequest) + return + } + if cf.DisplayName != cef.DisplayName { + l.Error("invalid custom field display name", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "custom_field_display_name", cf.DisplayName, + "doc_id", docID) + http.Error(w, "Bad request: invalid custom field display name", + http.StatusBadRequest) + return + } + if cf.Type != cef.Type { + l.Error("invalid custom field type", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "custom_field_type", cf.Type, + "doc_id", docID) + http.Error(w, "Bad request: invalid custom field type", + http.StatusBadRequest) + return + } + } + } + // Check if document is locked. locked, err := hcd.IsLocked(docID, db, s, l) if err != nil { @@ -291,28 +305,135 @@ func DocumentHandler( return } + // Patch document (for Algolia). + // Approvers. + if req.Approvers != nil { + doc.Approvers = *req.Approvers + } + // Contributors. + if req.Contributors != nil { + doc.Contributors = *req.Contributors + } + // Custom fields. + if req.CustomFields != nil { + for _, cf := range *req.CustomFields { + switch cf.Type { + case "STRING": + if _, ok := cf.Value.(string); ok { + if err := doc.UpsertCustomField(cf); err != nil { + l.Error("error upserting custom string field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docID, + ) + http.Error(w, + "Error patching document", + http.StatusInternalServerError) + return + } + } + case "PEOPLE": + if reflect.TypeOf(cf.Value).Kind() != reflect.Slice { + l.Error("invalid value type for people custom field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docID) + http.Error(w, + fmt.Sprintf( + "Bad request: invalid value type for custom field %q", + cf.Name, + ), + http.StatusBadRequest) + return + } + for _, v := range cf.Value.([]any) { + if _, ok := v.(string); !ok { + l.Error("invalid value type for people custom field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docID) + http.Error(w, + fmt.Sprintf( + "Bad request: invalid value type for custom field %q", + cf.Name, + ), + http.StatusBadRequest) + return + } + } + if err := doc.UpsertCustomField(cf); err != nil { + l.Error("error upserting custom people field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docID, + ) + http.Error(w, + "Error patching document", + http.StatusInternalServerError) + return + } + default: + l.Error("invalid custom field type", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "custom_field_type", cf.Type, + "doc_id", docID) + http.Error(w, + fmt.Sprintf( + "Bad request: invalid type for custom field %q", + cf.Name, + ), + http.StatusBadRequest) + return + } + } + } + // Status. + // TODO: validate status. + if req.Status != nil { + doc.Status = *req.Status + } + // Summary. + if req.Summary != nil { + doc.Summary = *req.Summary + } + // Title. + if req.Title != nil { + doc.Title = *req.Title + } + // Compare approvers in req and stored object in Algolia // before we save the patched objected var approversToEmail []string - if len(docObj.GetApprovers()) == 0 && len(req.Approvers) != 0 { + if len(doc.Approvers) == 0 && req.Approvers != nil && len(*req.Approvers) != 0 { // If there are no approvers of the document // email the approvers in the request - approversToEmail = req.Approvers - } else if len(req.Approvers) != 0 { + approversToEmail = *req.Approvers + } else if req.Approvers != nil && len(*req.Approvers) != 0 { // Only compare when there are stored approvers // and approvers in the request - approversToEmail = compareSlices(docObj.GetApprovers(), req.Approvers) + approversToEmail = compareSlices(doc.Approvers, *req.Approvers) } - // Patch document by decoding the (now validated) request body JSON to the - // document object. - err = json.NewDecoder(body).Decode(docObj) + // Convert document to Algolia object. + docObj, err := doc.ToAlgoliaObject(true) if err != nil { - l.Error("error decoding request body to document object", + l.Error("error converting document to Algolia object", "error", err, "method", r.Method, "path", r.URL.Path, - "doc_id", docID) + "doc_id", docID, + ) http.Error(w, "Error patching document", http.StatusInternalServerError) return @@ -372,7 +493,7 @@ Hermes http.StatusInternalServerError) return } - body := fmt.Sprintf(rawBody, docURL, docObj.GetDocNumber(), docObj.GetTitle()) + body := fmt.Sprintf(rawBody, docURL, doc.DocNumber, doc.Title) // TODO: use an asynchronous method for sending emails because we // can't currently recover gracefully on a failure here. @@ -380,7 +501,7 @@ Hermes _, err = s.SendEmail( []string{approverEmail}, cfg.Email.FromAddress, - fmt.Sprintf("Document review requested for %s", docObj.GetDocNumber()), + fmt.Sprintf("Document review requested for %s", doc.DocNumber), body, ) if err != nil { @@ -400,8 +521,7 @@ Hermes } // Replace the doc header. - err = docObj.ReplaceHeader(docID, cfg.BaseURL, true, s) - if err != nil { + if err := doc.ReplaceHeader(cfg.BaseURL, false, s); err != nil { l.Error("error replacing document header", "error", err, "doc_id", docID) http.Error(w, "Error patching document", @@ -411,13 +531,13 @@ Hermes // Rename file with new title. s.RenameFile(docID, - fmt.Sprintf("[%s] %s", docObj.GetDocNumber(), docObj.GetTitle())) + fmt.Sprintf("[%s] %s", doc.DocNumber, doc.Title)) // Get document record from database so we can modify it for updating. - d := models.Document{ + model := models.Document{ GoogleFileID: docID, } - if err := d.Get(db); err != nil { + if err := model.Get(db); err != nil { l.Error("error getting document from database", "error", err, "method", r.Method, @@ -428,145 +548,133 @@ Hermes // truth yet. } else { // Approvers. - if len(req.Approvers) > 0 { + if req.Approvers != nil { var approvers []*models.User - for _, a := range docObj.GetApprovers() { + for _, a := range doc.Approvers { u := models.User{ EmailAddress: a, } approvers = append(approvers, &u) } - d.Approvers = approvers + model.Approvers = approvers } // Contributors. - if len(req.Contributors) > 0 { + if req.Contributors != nil { var contributors []*models.User - for _, a := range docObj.GetContributors() { + for _, a := range doc.Contributors { u := &models.User{ EmailAddress: a, } contributors = append(contributors, u) } - d.Contributors = contributors + model.Contributors = contributors } // Custom fields. - switch docObj.GetDocType() { - case "FRD": - if req.PRD != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "FRD", - "PRD", - *req.PRD, - ) - } - if req.PRFAQ != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "FRD", - "PRFAQ", - *req.PRFAQ, - ) - } - case "PRD": - if req.RFC != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "PRD", - "RFC", - *req.RFC, - ) - } - if req.Stakeholders != nil { - d.CustomFields, err = models.UpsertStringSliceDocumentCustomField( - d.CustomFields, - "PRD", - "Stakeholders", - *req.Stakeholders, - ) - if err != nil { - l.Error( - "error getting upserting stakeholders into document custom fields", - "error", err, - "method", r.Method, - "path", r.URL.Path, - "doc_id", docID, + if req.CustomFields != nil { + for _, cf := range *req.CustomFields { + switch cf.Type { + case "STRING": + if v, ok := cf.Value.(string); ok { + model.CustomFields = models.UpsertStringDocumentCustomField( + model.CustomFields, + doc.DocType, + cf.DisplayName, + v, + ) + } else { + l.Warn("invalid value type for string custom field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docID) + // Don't return an HTTP error because the database isn't the + // source of truth yet. + } + case "PEOPLE": + if reflect.TypeOf(cf.Value).Kind() != reflect.Slice { + l.Warn("invalid value type for people custom field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docID) + // Don't return an HTTP error because the database isn't the + // source of truth yet. + break + } + cfVal := []string{} + for _, v := range cf.Value.([]any) { + if v, ok := v.(string); ok { + cfVal = append(cfVal, v) + } else { + l.Warn("invalid value type for people custom field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docID) + // Don't return an HTTP error because the database isn't the + // source of truth yet. + } + } + + model.CustomFields, err = models.UpsertStringSliceDocumentCustomField( + model.CustomFields, + doc.DocType, + cf.DisplayName, + cfVal, ) - // Don't return an HTTP error because the database isn't the - // source of truth yet. - } - } - case "RFC": - if req.CurrentVersion != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "RFC", - "Current Version", - *req.CurrentVersion, - ) - } - if req.PRD != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "RFC", - "PRD", - *req.PRD, - ) - } - if req.Stakeholders != nil { - d.CustomFields, err = models.UpsertStringSliceDocumentCustomField( - d.CustomFields, - "RFC", - "Stakeholders", - *req.Stakeholders, - ) - if err != nil { - l.Error( - "error getting upserting stakeholders into document custom fields", + if err != nil { + l.Warn("invalid value type for people custom field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docID) + // Don't return an HTTP error because the database isn't the + // source of truth yet. + } + default: + l.Error("invalid custom field type", "error", err, "method", r.Method, "path", r.URL.Path, - "doc_id", docID, - ) - // Don't return an HTTP error because the database isn't the - // source of truth yet. + "custom_field", cf.Name, + "custom_field_type", cf.Type, + "doc_id", docID) + http.Error(w, + fmt.Sprintf( + "Bad request: invalid type for custom field %q", + cf.Name, + ), + http.StatusBadRequest) + return } } - if req.TargetVersion != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "RFC", - "Target Version", - *req.TargetVersion, - ) - } - default: - l.Error("Document contains invalid docType", - "doc_type", docObj.GetDocType(), - ) } // Make sure all custom fields have the document ID. - for _, cf := range d.CustomFields { - cf.DocumentID = d.ID + for _, cf := range model.CustomFields { + cf.DocumentID = model.ID } // Document modified time. - d.DocumentModifiedAt = time.Unix(docObj.GetModifiedTime(), 0) + model.DocumentModifiedAt = time.Unix(doc.ModifiedTime, 0) // Summary. - if req.Summary != "" { - d.Summary = req.Summary + if req.Summary != nil { + model.Summary = *req.Summary } // Title. - if req.Title != "" { - d.Title = req.Title + if req.Title != nil { + model.Title = *req.Title } // Update document in the database. - if err := d.Upsert(db); err != nil { + if err := model.Upsert(db); err != nil { l.Error("error updating document", "error", err, "method", r.Method, diff --git a/internal/api/documents_related_resources.go b/internal/api/documents_related_resources.go index e6f003f13..30a63d86b 100644 --- a/internal/api/documents_related_resources.go +++ b/internal/api/documents_related_resources.go @@ -2,11 +2,11 @@ package api import ( "encoding/json" - "fmt" "net/http" + "github.com/hashicorp-forge/hermes/internal/config" "github.com/hashicorp-forge/hermes/pkg/algolia" - hcd "github.com/hashicorp-forge/hermes/pkg/hashicorpdocs" + "github.com/hashicorp-forge/hermes/pkg/document" "github.com/hashicorp-forge/hermes/pkg/models" "github.com/hashicorp/go-hclog" "gorm.io/gorm" @@ -51,7 +51,8 @@ func documentsResourceRelatedResourcesHandler( w http.ResponseWriter, r *http.Request, docID string, - docObj hcd.Doc, + doc document.Document, + cfg *config.Config, l hclog.Logger, algoRead *algolia.Client, db *gorm.DB, @@ -115,8 +116,9 @@ func documentsResourceRelatedResourcesHandler( } // Add Hermes document related resources. for _, hdrr := range hdrrs { - algoDoc, err := getDocumentFromAlgolia( - hdrr.Document.GoogleFileID, algoRead) + // Get document object from Algolia. + var algoObj map[string]any + err = algoRead.Docs.GetObject(hdrr.Document.GoogleFileID, &algoObj) if err != nil { l.Error("error getting related resource document from Algolia", "error", err, @@ -130,13 +132,26 @@ func documentsResourceRelatedResourcesHandler( return } + // Convert Algolia object to a document. + doc, err := document.NewFromAlgoliaObject( + algoObj, cfg.DocumentTypes.DocumentType) + if err != nil { + l.Error("error converting Algolia object to document type", + "error", err, + "doc_id", docID, + ) + http.Error(w, "Error accessing draft document", + http.StatusInternalServerError) + return + } + resp.HermesDocuments = append( resp.HermesDocuments, hermesDocumentRelatedResourceGetResponse{ GoogleFileID: hdrr.Document.GoogleFileID, - Title: algoDoc.GetTitle(), - DocumentType: algoDoc.GetDocType(), - DocumentNumber: algoDoc.GetDocNumber(), + Title: doc.Title, + DocumentType: doc.DocType, + DocumentNumber: doc.DocNumber, SortOrder: hdrr.RelatedResource.SortOrder, }) } @@ -161,7 +176,7 @@ func documentsResourceRelatedResourcesHandler( // Authorize request (only the document owner can replace related // resources). userEmail := r.Context().Value("userEmail").(string) - if docObj.GetOwners()[0] != userEmail { + if doc.Owners[0] != userEmail { http.Error(w, "Not a document owner", http.StatusUnauthorized) return } @@ -236,28 +251,3 @@ func documentsResourceRelatedResourcesHandler( return } } - -// getDocumentFromAlgolia gets a document object from Algolia. -func getDocumentFromAlgolia( - docID string, algo *algolia.Client) (hcd.Doc, error) { - - // Get base document object from Algolia so we can determine the doc type. - baseDocObj := &hcd.BaseDoc{} - if err := algo.Docs.GetObject(docID, &baseDocObj); err != nil { - return nil, fmt.Errorf( - "error retrieving base document object from Algolia: %w", err) - } - - // Create new document object of the proper doc type. - docObj, err := hcd.NewEmptyDoc(baseDocObj.DocType) - if err != nil { - return nil, fmt.Errorf("error creating new empty doc") - } - - // Get document object from Algolia. - if err := algo.Docs.GetObject(docID, &docObj); err != nil { - return nil, fmt.Errorf("error retrieving document object from Algolia") - } - - return docObj, nil -} diff --git a/internal/api/drafts.go b/internal/api/drafts.go index 5782cd384..8959e4189 100644 --- a/internal/api/drafts.go +++ b/internal/api/drafts.go @@ -1,11 +1,10 @@ package api import ( - "bytes" "encoding/json" "fmt" - "io/ioutil" "net/http" + "reflect" "strconv" "strings" "time" @@ -15,6 +14,7 @@ import ( "github.com/algolia/algoliasearch-client-go/v3/algolia/search" "github.com/hashicorp-forge/hermes/internal/config" "github.com/hashicorp-forge/hermes/pkg/algolia" + "github.com/hashicorp-forge/hermes/pkg/document" gw "github.com/hashicorp-forge/hermes/pkg/googleworkspace" hcd "github.com/hashicorp-forge/hermes/pkg/hashicorpdocs" "github.com/hashicorp-forge/hermes/pkg/models" @@ -36,21 +36,13 @@ type DraftsRequest struct { // DraftsPatchRequest contains a subset of drafts fields that are allowed to // be updated with a PATCH request. type DraftsPatchRequest struct { - Approvers []string `json:"approvers,omitempty"` - Contributors []string `json:"contributors,omitempty"` - Product string `json:"product,omitempty"` - Summary string `json:"summary,omitempty"` + Approvers *[]string `json:"approvers,omitempty"` + Contributors *[]string `json:"contributors,omitempty"` + CustomFields *[]document.CustomField `json:"customFields,omitempty"` + Product *string `json:"product,omitempty"` + Summary *string `json:"summary,omitempty"` // Tags []string `json:"tags,omitempty"` - Title string `json:"title,omitempty"` - - // TODO: These are all current custom editable fields for all supported doc - // types. We should instead make this dynamic. - CurrentVersion *string `json:"currentVersion,omitempty"` - PRD *string `json:"prd,omitempty"` - PRFAQ *string `json:"prfaq,omitempty"` - RFC *string `json:"rfc,omitempty"` - Stakeholders *[]string `json:"stakeholders,omitempty"` - TargetVersion *string `json:"targetVersion,omitempty"` + Title *string `json:"title,omitempty"` } type DraftsResponse struct { @@ -99,17 +91,14 @@ func DraftsHandler( } // Validate document type. - switch req.DocType { - case "FRD": - case "RFC": - case "PRD": - case "": - l.Error("Bad request: docType is required") - http.Error(w, "Bad request: docType is required", http.StatusBadRequest) - return - default: - l.Error("Bad request: invalid docType", "doc_type", req.DocType) - http.Error(w, "Bad request: invalid docType", http.StatusBadRequest) + if !validateDocType(cfg.DocumentTypes.DocumentType, req.DocType) { + l.Error("invalid document type", + "method", r.Method, + "path", r.URL.Path, + "doc_type", req.DocType, + ) + http.Error( + w, "Bad request: invalid document type", http.StatusBadRequest) return } @@ -181,7 +170,8 @@ func DraftsHandler( "o_id:" + id, } - baseDocObj := &hcd.BaseDoc{ + // Build document. + doc := &document.Document{ ObjectID: f.Id, Title: req.Title, AppCreated: true, @@ -199,7 +189,7 @@ func DraftsHandler( Tags: req.Tags, } - res, err := aw.Drafts.SaveObject(baseDocObj) + res, err := aw.Drafts.SaveObject(doc) if err != nil { l.Error("error saving draft doc in Algolia", "error", err, "doc_id", f.Id) http.Error(w, "Error creating document draft", @@ -214,34 +204,8 @@ func DraftsHandler( return } - // Create new document object of the proper doc type. - docObj, err := hcd.NewEmptyDoc(baseDocObj.DocType) - if err != nil { - l.Error("error creating new empty doc", - "error", err, - "doc_id", f.Id, - ) - http.Error(w, "Error accessing draft document", - http.StatusInternalServerError) - return - } - - // Get document object from Algolia. - err = ar.Drafts.GetObject(f.Id, &docObj) - if err != nil { - l.Error("error requesting document draft from Algolia", - "error", err, - "doc_id", f.Id, - ) - http.Error(w, "Error accessing draft document", - http.StatusInternalServerError) - return - } - // Replace the doc header. - err = docObj.ReplaceHeader( - f.Id, cfg.BaseURL, true, s) - if err != nil { + if err = doc.ReplaceHeader(cfg.BaseURL, true, s); err != nil { l.Error("error replacing draft doc header", "error", err, "doc_id", f.Id) http.Error(w, "Error creating document draft", @@ -270,8 +234,7 @@ func DraftsHandler( http.StatusInternalServerError) return } - // TODO: add custom fields. - d := models.Document{ + model := models.Document{ GoogleFileID: f.Id, Approvers: approvers, Contributors: contributors, @@ -290,7 +253,7 @@ func DraftsHandler( Summary: req.Summary, Title: req.Title, } - if err := d.Create(db); err != nil { + if err := model.Create(db); err != nil { l.Error("error creating document in database", "error", err, "doc_id", f.Id, @@ -453,13 +416,13 @@ func DraftsDocumentHandler( return } - // Get base document object from Algolia so we can determine the doc type. - baseDocObj := &hcd.BaseDoc{} - err = ar.Drafts.GetObject(docId, &baseDocObj) + // Get document object from Algolia. + var algoObj map[string]any + err = ar.Drafts.GetObject(docId, &algoObj) if err != nil { // Handle 404 from Algolia and only log a warning. if _, is404 := errs.IsAlgoliaErrWithCode(err, 404); is404 { - l.Warn("base document object not found", + l.Warn("document object not found in Algolia", "error", err, "path", r.URL.Path, "method", r.Method, @@ -468,10 +431,8 @@ func DraftsDocumentHandler( http.Error(w, "Draft document not found", http.StatusNotFound) return } else { - l.Error("error requesting base document object from Algolia", + l.Error("error requesting document draft from Algolia", "error", err, - "path", r.URL.Path, - "method", r.Method, "doc_id", docId, ) http.Error(w, "Error accessing draft document", @@ -480,22 +441,11 @@ func DraftsDocumentHandler( } } - // Create new document object of the proper doc type. - docObj, err := hcd.NewEmptyDoc(baseDocObj.DocType) - if err != nil { - l.Error("error creating new empty doc", - "error", err, - "doc_id", docId, - ) - http.Error(w, "Error accessing draft document", - http.StatusInternalServerError) - return - } - - // Get document object from Algolia. - err = ar.Drafts.GetObject(docId, &docObj) + // Convert Algolia object to a document. + doc, err := document.NewFromAlgoliaObject( + algoObj, cfg.DocumentTypes.DocumentType) if err != nil { - l.Error("error requesting document draft from Algolia", + l.Error("error converting Algolia object to document type", "error", err, "doc_id", docId, ) @@ -505,10 +455,10 @@ func DraftsDocumentHandler( } // Get document from database. - doc := models.Document{ + model := models.Document{ GoogleFileID: docId, } - if err := doc.Get(db); err != nil { + if err := model.Get(db); err != nil { l.Error("error getting document draft from database", "error", err, "path", r.URL.Path, @@ -525,13 +475,13 @@ func DraftsDocumentHandler( // require owner access only. userEmail := r.Context().Value("userEmail").(string) var isOwner, isContributor bool - if docObj.GetOwners()[0] == userEmail { + if doc.Owners[0] == userEmail { isOwner = true } - if contains(docObj.GetContributors(), userEmail) { + if contains(doc.Contributors, userEmail) { isContributor = true } - if !isOwner && !isContributor && !doc.ShareableAsDraft { + if !isOwner && !isContributor && !model.ShareableAsDraft { http.Error(w, "Only owners or contributors can access a non-shared draft document", http.StatusUnauthorized) @@ -542,10 +492,10 @@ func DraftsDocumentHandler( // draft document ID) handler, if appropriate. switch reqType { case relatedResourcesDocumentSubcollectionRequestType: - documentsResourceRelatedResourcesHandler(w, r, docId, docObj, l, ar, db) + documentsResourceRelatedResourcesHandler(w, r, docId, *doc, cfg, l, ar, db) return case shareableDocumentSubcollectionRequestType: - draftsShareableHandler(w, r, docId, docObj, *cfg, l, ar, s, db) + draftsShareableHandler(w, r, docId, *doc, *cfg, l, ar, s, db) return } @@ -578,14 +528,26 @@ func DraftsDocumentHandler( http.Error(w, "Error requesting document draft", http.StatusInternalServerError) return } - docObj.SetModifiedTime(modifiedTime.Unix()) - - // Set custom editable fields. - docObj.SetCustomEditableFields() + doc.ModifiedTime = modifiedTime.Unix() // Set locked value for response to value from the database (this value // isn't stored in Algolia). - docObj.SetLocked(doc.Locked) + doc.Locked = model.Locked + + // Convert document to Algolia object because this is how it is expected + // by the frontend. + docObj, err := doc.ToAlgoliaObject(false) + if err != nil { + l.Error("error converting document to Algolia object", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docId, + ) + http.Error(w, "Error getting document draft", + http.StatusInternalServerError) + return + } // Write response. w.Header().Set("Content-Type", "application/json") @@ -693,23 +655,6 @@ func DraftsDocumentHandler( } case "PATCH": - // Copy request body so we can use both for validation using the request - // struct, and then afterwards for patching the document JSON. - buf, err := ioutil.ReadAll(r.Body) - if err != nil { - l.Error("error reading request body", - "error", err, - "method", r.Method, - "path", r.URL.Path, - "doc_id", docId) - http.Error(w, "Error patching document draft", - http.StatusInternalServerError) - return - } - body := ioutil.NopCloser(bytes.NewBuffer(buf)) - newBody := ioutil.NopCloser(bytes.NewBuffer(buf)) - r.Body = newBody - // Decode request. The request struct validates that the request only // contains fields that are allowed to be patched. var req DraftsPatchRequest @@ -722,8 +667,8 @@ func DraftsDocumentHandler( // Validate product if it is in the patch request. var productAbbreviation string - if req.Product != "" { - p := models.Product{Name: req.Product} + if req.Product != nil && *req.Product != "" { + p := models.Product{Name: *req.Product} if err := p.Get(db); err != nil { l.Error("error getting product", "error", err, @@ -741,6 +686,48 @@ func DraftsDocumentHandler( productAbbreviation = p.Abbreviation } + // Validate custom fields. + if req.CustomFields != nil { + for _, cf := range *req.CustomFields { + cef, ok := doc.CustomEditableFields[cf.Name] + if !ok { + l.Error("custom field not found", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docId) + http.Error(w, "Bad request: invalid custom field", + http.StatusBadRequest) + return + } + if cf.DisplayName != cef.DisplayName { + l.Error("invalid custom field display name", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "custom_field_display_name", cf.DisplayName, + "doc_id", docId) + http.Error(w, "Bad request: invalid custom field display name", + http.StatusBadRequest) + return + } + if cf.Type != cef.Type { + l.Error("invalid custom field type", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "custom_field_type", cf.Type, + "doc_id", docId) + http.Error(w, "Bad request: invalid custom field type", + http.StatusBadRequest) + return + } + } + } + // Check if document is locked. locked, err := hcd.IsLocked(docId, db, s, l) if err != nil { @@ -763,37 +750,26 @@ func DraftsDocumentHandler( // before we save the patched objected // Find out contributors to share the document with var contributorsToAddSharing []string - if len(docObj.GetContributors()) == 0 && len(req.Contributors) != 0 { - // If there are no contributors of the document - // add the contributors in the request - contributorsToAddSharing = req.Contributors - } else if len(req.Contributors) != 0 { - // Only compare when there are stored contributors - // and contributors in the request - contributorsToAddSharing = compareSlices(docObj.GetContributors(), req.Contributors) - } - // Find out contributors to remove from sharing the document var contributorsToRemoveSharing []string - // TODO: figure out how we want to handle user removing all contributors - // from the sidebar select - if len(docObj.GetContributors()) != 0 && len(req.Contributors) != 0 { - // Compare contributors when there are stored contributors - // and there are contributors in the request - contributorsToRemoveSharing = compareSlices(req.Contributors, docObj.GetContributors()) - } - - // Patch document by decoding the (now validated) request body JSON to the - // document object. - err = json.NewDecoder(body).Decode(docObj) - if err != nil { - l.Error("error decoding request body to document object", - "error", err, - "method", r.Method, - "path", r.URL.Path, - "doc_id", docId) - http.Error(w, "Error patching document draft", - http.StatusInternalServerError) - return + if req.Contributors != nil { + if len(doc.Contributors) == 0 && len(*req.Contributors) != 0 { + // If there are no contributors of the document + // add the contributors in the request + contributorsToAddSharing = *req.Contributors + } else if len(*req.Contributors) != 0 { + // Only compare when there are stored contributors + // and contributors in the request + contributorsToAddSharing = compareSlices(doc.Contributors, *req.Contributors) + } + // Find out contributors to remove from sharing the document + // var contributorsToRemoveSharing []string + // TODO: figure out how we want to handle user removing all contributors + // from the sidebar select + if len(doc.Contributors) != 0 && len(*req.Contributors) != 0 { + // Compare contributors when there are stored contributors + // and there are contributors in the request + contributorsToRemoveSharing = compareSlices(*req.Contributors, doc.Contributors) + } } // Share file with contributors. @@ -824,7 +800,7 @@ func DraftsDocumentHandler( // Only remove contributor if the email // associated with the permission doesn't // match owner email(s). - if !contains(docObj.GetOwners(), c) { + if !contains(doc.Owners, c) { if err := removeSharing(s, docId, c); err != nil { l.Error("error removing contributor from file", "error", err, @@ -843,177 +819,219 @@ func DraftsDocumentHandler( "contributors_count", len(contributorsToRemoveSharing)) } - // Get document record from database so we can modify it for updating. - d := models.Document{ - GoogleFileID: docId, - } - if err := d.Get(db); err != nil { - l.Error("error getting document from database", - "error", err, - "method", r.Method, - "path", r.URL.Path, - "doc_id", docId, - ) - // Don't return an HTTP error because the database isn't the source of - // truth yet. - } else { - // Approvers. - if len(req.Approvers) > 0 { - var approvers []*models.User - for _, a := range docObj.GetApprovers() { - u := models.User{ - EmailAddress: a, - } - approvers = append(approvers, &u) + // Approvers. + if req.Approvers != nil { + doc.Approvers = *req.Approvers + + var approvers []*models.User + for _, a := range doc.Approvers { + u := models.User{ + EmailAddress: a, } - d.Approvers = approvers + approvers = append(approvers, &u) } + model.Approvers = approvers + } - // Contributors. - if len(req.Contributors) > 0 { - var contributors []*models.User - for _, a := range docObj.GetContributors() { - u := &models.User{ - EmailAddress: a, - } - contributors = append(contributors, u) + // Contributors. + if req.Contributors != nil { + doc.Contributors = *req.Contributors + + var contributors []*models.User + for _, a := range doc.Contributors { + u := &models.User{ + EmailAddress: a, } - d.Contributors = contributors + contributors = append(contributors, u) } + model.Contributors = contributors + } - // Custom fields. - switch docObj.GetDocType() { - case "FRD": - if req.PRD != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "FRD", - "PRD", - *req.PRD, - ) - } - if req.PRFAQ != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "FRD", - "PRFAQ", - *req.PRFAQ, - ) - } - case "PRD": - if req.RFC != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "PRD", - "RFC", - *req.RFC, - ) - } - if req.Stakeholders != nil { - d.CustomFields, err = models.UpsertStringSliceDocumentCustomField( - d.CustomFields, - "PRD", - "Stakeholders", - *req.Stakeholders, - ) - if err != nil { - l.Error( - "error getting upserting stakeholders into document custom fields", + // Custom fields. + if req.CustomFields != nil { + for _, cf := range *req.CustomFields { + switch cf.Type { + case "STRING": + if v, ok := cf.Value.(string); ok { + if err := doc.UpsertCustomField(cf); err != nil { + l.Error("error upserting custom string field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docId, + ) + http.Error(w, + "Error patching document", + http.StatusInternalServerError) + return + } + + model.CustomFields = models.UpsertStringDocumentCustomField( + model.CustomFields, + doc.DocType, + cf.DisplayName, + v, + ) + } else { + l.Error("invalid value type for string custom field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docId) + http.Error(w, + fmt.Sprintf( + "Bad request: invalid value type for custom field %q", + cf.Name, + ), + http.StatusBadRequest) + return + } + case "PEOPLE": + if reflect.TypeOf(cf.Value).Kind() != reflect.Slice { + l.Error("invalid value type for people custom field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docId) + http.Error(w, + fmt.Sprintf( + "Bad request: invalid value type for custom field %q", + cf.Name, + ), + http.StatusBadRequest) + return + } + cfVal := []string{} + for _, v := range cf.Value.([]any) { + if v, ok := v.(string); ok { + cfVal = append(cfVal, v) + } else { + l.Error("invalid value type for people custom field", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "doc_id", docId) + http.Error(w, + fmt.Sprintf( + "Bad request: invalid value type for custom field %q", + cf.Name, + ), + http.StatusBadRequest) + return + } + } + + if err := doc.UpsertCustomField(cf); err != nil { + l.Error("error upserting custom people field", "error", err, "method", r.Method, "path", r.URL.Path, + "custom_field", cf.Name, "doc_id", docId, ) - // Don't return an HTTP error because the database isn't the - // source of truth yet. + http.Error(w, + "Error patching document", + http.StatusInternalServerError) + return } - } - case "RFC": - if req.CurrentVersion != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "RFC", - "Current Version", - *req.CurrentVersion, - ) - } - if req.PRD != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "RFC", - "PRD", - *req.PRD, - ) - } - if req.Stakeholders != nil { - d.CustomFields, err = models.UpsertStringSliceDocumentCustomField( - d.CustomFields, - "RFC", - "Stakeholders", - *req.Stakeholders, + + model.CustomFields, err = models.UpsertStringSliceDocumentCustomField( + model.CustomFields, + doc.DocType, + cf.DisplayName, + cfVal, ) if err != nil { - l.Error( - "error getting upserting stakeholders into document custom fields", + l.Error("invalid value type for people custom field", "error", err, "method", r.Method, "path", r.URL.Path, - "doc_id", docId, - ) - // Don't return an HTTP error because the database isn't the - // source of truth yet. + "custom_field", cf.Name, + "doc_id", docId) + http.Error(w, + fmt.Sprintf( + "Bad request: invalid value type for custom field %q", + cf.Name, + ), + http.StatusBadRequest) + return } + default: + l.Error("invalid custom field type", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "custom_field", cf.Name, + "custom_field_type", cf.Type, + "doc_id", docId) + http.Error(w, + fmt.Sprintf( + "Bad request: invalid type for custom field %q", + cf.Name, + ), + http.StatusBadRequest) + return } - if req.TargetVersion != nil { - d.CustomFields = models.UpsertStringDocumentCustomField( - d.CustomFields, - "RFC", - "Target Version", - *req.TargetVersion, - ) - } - default: - l.Error("Document contains invalid docType", - "doc_type", docObj.GetDocType(), - ) - } - // Make sure all custom fields have the document ID. - for _, cf := range d.CustomFields { - cf.DocumentID = d.ID } + } - // Document modified time. - d.DocumentModifiedAt = time.Unix(docObj.GetModifiedTime(), 0) + // Make sure all custom fields in the database model have the document ID. + for _, cf := range model.CustomFields { + cf.DocumentID = model.ID + } - // Product. - if req.Product != "" { - d.Product = models.Product{Name: req.Product} + // Document modified time. + model.DocumentModifiedAt = time.Unix(doc.ModifiedTime, 0) - // Update doc number in Algolia object. - docObj.SetDocNumber(fmt.Sprintf("%s-???", productAbbreviation)) - } + // Product. + if req.Product != nil { + model.Product = models.Product{Name: *req.Product} - // Summary. - if req.Summary != "" { - d.Summary = req.Summary - } + // Update doc number in document. + doc.DocNumber = fmt.Sprintf("%s-???", productAbbreviation) + } - // Title. - if req.Title != "" { - d.Title = req.Title - } + // Summary. + if req.Summary != nil { + doc.Summary = *req.Summary + model.Summary = *req.Summary + } - // Update document in the database. - if err := d.Upsert(db); err != nil { - l.Error("error updating document", - "error", err, - "method", r.Method, - "path", r.URL.Path, - "doc_id", docId, - ) - // Don't return an HTTP error because the database isn't the source of - // truth yet. - } + // Title. + if req.Title != nil { + doc.Title = *req.Title + model.Title = *req.Title + } + + // Update document in the database. + if err := model.Upsert(db); err != nil { + l.Error("error updating document in the database", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docId, + ) + // Don't return an HTTP error because the database isn't the source of + // truth yet. + } + // } + + // Convert document to Algolia object. + docObj, err := doc.ToAlgoliaObject(true) + if err != nil { + l.Error("error converting document to Algolia object", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docId, + ) + http.Error(w, "Error patching document draft", + http.StatusInternalServerError) + return } // Save new modified draft doc object in Algolia. @@ -1035,19 +1053,21 @@ func DraftsDocumentHandler( } // Replace the doc header. - err = docObj.ReplaceHeader( - docId, cfg.BaseURL, true, s) - if err != nil { + if err := doc.ReplaceHeader(cfg.BaseURL, true, s); err != nil { l.Error("error replacing draft doc header", - "error", err, "doc_id", docId) - http.Error(w, "Error patching document draft", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docId, + ) + http.Error(w, "Error replacing header of document draft", http.StatusInternalServerError) return } // Rename file with new title. s.RenameFile(docId, - fmt.Sprintf("[%s] %s", docObj.GetDocNumber(), docObj.GetTitle())) + fmt.Sprintf("[%s] %s", doc.DocNumber, doc.Title)) w.WriteHeader(http.StatusOK) l.Info("patched draft document", "doc_id", docId) @@ -1138,6 +1158,21 @@ func getDocTypeTemplate( return template } +// validateDocType returns true if the name (docType) is contained in the a +// slice of configured document types. +func validateDocType( + docTypes []*config.DocumentType, + docType string, +) bool { + for _, t := range docTypes { + if t.Name == docType { + return true + } + } + + return false +} + // removeSharing lists permissions for a document and then // deletes the permission for the supplied user email func removeSharing(s *gw.Service, docId, email string) error { diff --git a/internal/api/drafts_shareable.go b/internal/api/drafts_shareable.go index 67ae5f4c3..efb30571f 100644 --- a/internal/api/drafts_shareable.go +++ b/internal/api/drafts_shareable.go @@ -6,8 +6,8 @@ import ( "github.com/hashicorp-forge/hermes/internal/config" "github.com/hashicorp-forge/hermes/pkg/algolia" + "github.com/hashicorp-forge/hermes/pkg/document" gw "github.com/hashicorp-forge/hermes/pkg/googleworkspace" - hcd "github.com/hashicorp-forge/hermes/pkg/hashicorpdocs" "github.com/hashicorp-forge/hermes/pkg/models" "github.com/hashicorp/go-hclog" "gorm.io/gorm" @@ -25,7 +25,7 @@ func draftsShareableHandler( w http.ResponseWriter, r *http.Request, docID string, - docObj hcd.Doc, + doc document.Document, cfg config.Config, l hclog.Logger, algoRead *algolia.Client, @@ -70,7 +70,7 @@ func draftsShareableHandler( case "PUT": // Authorize request (only the document owner is authorized). userEmail := r.Context().Value("userEmail").(string) - if docObj.GetOwners()[0] != userEmail { + if doc.Owners[0] != userEmail { http.Error(w, "Only the document owner can change shareable settings", http.StatusForbidden) return diff --git a/internal/api/reviews.go b/internal/api/reviews.go index a59a36a4e..d1818b7fd 100644 --- a/internal/api/reviews.go +++ b/internal/api/reviews.go @@ -9,9 +9,11 @@ import ( "strings" "time" + "github.com/algolia/algoliasearch-client-go/v3/algolia/errs" "github.com/hashicorp-forge/hermes/internal/config" "github.com/hashicorp-forge/hermes/internal/email" "github.com/hashicorp-forge/hermes/pkg/algolia" + "github.com/hashicorp-forge/hermes/pkg/document" gw "github.com/hashicorp-forge/hermes/pkg/googleworkspace" hcd "github.com/hashicorp-forge/hermes/pkg/hashicorpdocs" "github.com/hashicorp-forge/hermes/pkg/links" @@ -64,57 +66,47 @@ func ReviewHandler( return } - // Get base document object from Algolia so we can determine the doc type. - baseDocObj := &hcd.BaseDoc{} - err = ar.Drafts.GetObject(docID, &baseDocObj) - if err != nil { - l.Error("error requesting base document object from Algolia", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error creating review", - http.StatusInternalServerError) - return - } - - // Create new document object of the proper doc type. - docObj, err := hcd.NewEmptyDoc(baseDocObj.DocType) + // Get document object from Algolia. + var algoObj map[string]any + err = ar.Drafts.GetObject(docID, &algoObj) if err != nil { - l.Error("error creating new empty doc", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error creating review", - http.StatusInternalServerError) - return + // Handle 404 from Algolia and only log a warning. + if _, is404 := errs.IsAlgoliaErrWithCode(err, 404); is404 { + l.Warn("document object not found in Algolia", + "error", err, + "path", r.URL.Path, + "method", r.Method, + "doc_id", docID, + ) + http.Error(w, "Draft document not found", http.StatusNotFound) + return + } else { + l.Error("error requesting document draft from Algolia", + "error", err, + "doc_id", docID, + ) + http.Error(w, "Error accessing draft document", + http.StatusInternalServerError) + return + } } - // Get document object from Algolia. - err = ar.Drafts.GetObject(docID, &docObj) + // Convert Algolia object to a document. + doc, err := document.NewFromAlgoliaObject( + algoObj, cfg.DocumentTypes.DocumentType) if err != nil { - l.Error("error getting document from Algolia", + l.Error("error converting Algolia object to document type", "error", err, "doc_id", docID, - "method", r.Method, - "path", r.URL.Path, ) - http.Error(w, "Error creating review", + http.Error(w, "Error accessing draft document", http.StatusInternalServerError) return } - l.Info("retrieved document draft", - "doc_id", docID, - "method", r.Method, - "path", r.URL.Path, - ) // Get latest product number. latestNum, err := models.GetLatestProductNumber( - db, docObj.GetDocType(), docObj.GetProduct()) + db, doc.DocType, doc.Product) if err != nil { l.Error("error getting product document number", "error", err, @@ -129,7 +121,7 @@ func ReviewHandler( // Get product from database so we can get the product abbreviation. product := models.Product{ - Name: docObj.GetProduct(), + Name: doc.Product, } if err := product.Get(db); err != nil { l.Error("error getting product", @@ -145,24 +137,22 @@ func ReviewHandler( // Set the document number. nextDocNum := latestNum + 1 - docObj.SetDocNumber(fmt.Sprintf("%s-%03d", + doc.DocNumber = fmt.Sprintf("%s-%03d", product.Abbreviation, - nextDocNum)) + nextDocNum) // Change document status to "In-Review". - docObj.SetStatus("In-Review") + doc.Status = "In-Review" // Replace the doc header. - err = docObj.ReplaceHeader( - docID, cfg.BaseURL, true, s) - if err != nil { + if err = doc.ReplaceHeader(cfg.BaseURL, false, s); err != nil { l.Error("error replacing doc header", "error", err, "doc_id", docID) http.Error(w, "Error creating review", http.StatusInternalServerError) if err := revertReviewCreation( - docObj, product.Abbreviation, "", nil, cfg, aw, s, + *doc, product.Abbreviation, "", nil, cfg, aw, s, ); err != nil { l.Error("error reverting review creation", "error", err, @@ -204,7 +194,7 @@ func ReviewHandler( http.Error(w, "Error creating review", http.StatusInternalServerError) return } - docObj.SetModifiedTime(modifiedTime.Unix()) + doc.ModifiedTime = modifiedTime.Unix() // Get latest Google Drive file revision. latestRev, err := s.GetLatestRevision(docID) @@ -218,7 +208,7 @@ func ReviewHandler( http.StatusInternalServerError) if err := revertReviewCreation( - docObj, product.Abbreviation, "", nil, cfg, aw, s, + *doc, product.Abbreviation, "", nil, cfg, aw, s, ); err != nil { l.Error("error reverting review creation", "error", err, @@ -242,7 +232,7 @@ func ReviewHandler( http.StatusInternalServerError) if err := revertReviewCreation( - docObj, product.Abbreviation, "", nil, cfg, aw, s, + *doc, product.Abbreviation, "", nil, cfg, aw, s, ); err != nil { l.Error("error reverting review creation", "error", err, @@ -261,7 +251,21 @@ func ReviewHandler( // Record file revision in the Algolia document object. revisionName := "Requested review" - docObj.SetFileRevision(latestRev.Id, revisionName) + doc.SetFileRevision(latestRev.Id, revisionName) + + // Convert document to Algolia object. + docObj, err := doc.ToAlgoliaObject(true) + if err != nil { + l.Error("error converting document to Algolia object", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + http.Error(w, "Error patching document draft", + http.StatusInternalServerError) + return + } // Move document object to docs index in Algolia. saveRes, err := aw.Docs.SaveObject(docObj) @@ -291,7 +295,7 @@ func ReviewHandler( http.StatusInternalServerError) if err := revertReviewCreation( - docObj, product.Abbreviation, latestRev.Id, nil, cfg, aw, s, + *doc, product.Abbreviation, latestRev.Id, nil, cfg, aw, s, ); err != nil { l.Error("error reverting review creation", "error", err, @@ -309,7 +313,7 @@ func ReviewHandler( http.StatusInternalServerError) if err := revertReviewCreation( - docObj, product.Abbreviation, latestRev.Id, nil, cfg, aw, s, + *doc, product.Abbreviation, latestRev.Id, nil, cfg, aw, s, ); err != nil { l.Error("error reverting review creation", "error", err, @@ -332,7 +336,7 @@ func ReviewHandler( http.StatusInternalServerError) if err := revertReviewCreation( - docObj, product.Abbreviation, latestRev.Id, nil, cfg, aw, s, + *doc, product.Abbreviation, latestRev.Id, nil, cfg, aw, s, ); err != nil { l.Error("error reverting review creation", "error", err, @@ -349,7 +353,7 @@ func ReviewHandler( ) // Create shortcut in hierarchical folder structure. - shortcut, err := createShortcut(cfg, docObj, s) + shortcut, err := createShortcut(cfg, *doc, s) if err != nil { l.Error("error creating shortcut", "error", err, @@ -360,7 +364,7 @@ func ReviewHandler( http.StatusInternalServerError) if err := revertReviewCreation( - docObj, product.Abbreviation, latestRev.Id, shortcut, cfg, aw, s, + *doc, product.Abbreviation, latestRev.Id, shortcut, cfg, aw, s, ); err != nil { l.Error("error reverting review creation", "error", err, @@ -378,7 +382,7 @@ func ReviewHandler( // Create go-link. if err := links.SaveDocumentRedirectDetails( - aw, docID, docObj.GetDocType(), docObj.GetDocNumber()); err != nil { + aw, docID, doc.DocType, doc.DocNumber); err != nil { l.Error("error saving redirect details", "error", err, "doc_id", docID, @@ -388,7 +392,7 @@ func ReviewHandler( http.StatusInternalServerError) if err := revertReviewCreation( - docObj, product.Abbreviation, latestRev.Id, shortcut, cfg, aw, s, + *doc, product.Abbreviation, latestRev.Id, shortcut, cfg, aw, s, ); err != nil { l.Error("error reverting review creation", "error", err, @@ -418,7 +422,7 @@ func ReviewHandler( http.StatusInternalServerError) if err := revertReviewCreation( - docObj, product.Abbreviation, latestRev.Id, shortcut, cfg, aw, s, + *doc, product.Abbreviation, latestRev.Id, shortcut, cfg, aw, s, ); err != nil { l.Error("error reverting review creation", "error", err, @@ -440,7 +444,7 @@ func ReviewHandler( http.StatusInternalServerError) if err := revertReviewCreation( - docObj, product.Abbreviation, latestRev.Id, shortcut, cfg, aw, s, + *doc, product.Abbreviation, latestRev.Id, shortcut, cfg, aw, s, ); err != nil { l.Error("error reverting review creation", "error", err, @@ -467,16 +471,16 @@ func ReviewHandler( } // Send emails to approvers. - if len(docObj.GetApprovers()) > 0 { + if len(doc.Approvers) > 0 { // TODO: use an asynchronous method for sending emails because we // can't currently recover gracefully from a failure here. - for _, approverEmail := range docObj.GetApprovers() { + for _, approverEmail := range doc.Approvers { err := email.SendReviewRequestedEmail( email.ReviewRequestedEmailData{ BaseURL: cfg.BaseURL, - DocumentOwner: docObj.GetOwners()[0], - DocumentShortName: docObj.GetDocNumber(), - DocumentTitle: docObj.GetTitle(), + DocumentOwner: doc.Owners[0], + DocumentShortName: doc.DocNumber, + DocumentTitle: doc.Title, DocumentURL: docURL, }, []string{approverEmail}, @@ -504,7 +508,7 @@ func ReviewHandler( // Send emails to product subscribers. p := models.Product{ - Name: docObj.GetProduct(), + Name: doc.Product, } if err := p.Get(db); err != nil { l.Error("error getting product from database", @@ -525,12 +529,12 @@ func ReviewHandler( err := email.SendSubscriberDocumentPublishedEmail( email.SubscriberDocumentPublishedEmailData{ BaseURL: cfg.BaseURL, - DocumentOwner: docObj.GetOwners()[0], - DocumentShortName: docObj.GetDocNumber(), - DocumentTitle: docObj.GetTitle(), - DocumentType: docObj.GetDocType(), + DocumentOwner: doc.Owners[0], + DocumentShortName: doc.DocNumber, + DocumentTitle: doc.Title, + DocumentType: doc.DocType, DocumentURL: docURL, - Product: docObj.GetProduct(), + Product: doc.Product, }, []string{subscriber.EmailAddress}, cfg.Email.FromAddress, @@ -577,12 +581,12 @@ func ReviewHandler( // ("Shortcuts Folder/RFC/MyProduct/") under docsFolder. func createShortcut( cfg *config.Config, - docObj hcd.Doc, + doc document.Document, s *gw.Service) (shortcut *drive.File, retErr error) { // Get folder for doc type. docTypeFolder, err := s.GetSubfolder( - cfg.GoogleWorkspace.ShortcutsFolder, docObj.GetDocType()) + cfg.GoogleWorkspace.ShortcutsFolder, doc.DocType) if err != nil { return nil, fmt.Errorf("error getting doc type subfolder: %w", err) } @@ -590,14 +594,14 @@ func createShortcut( // Doc type folder wasn't found, so create it. if docTypeFolder == nil { docTypeFolder, err = s.CreateFolder( - docObj.GetDocType(), cfg.GoogleWorkspace.ShortcutsFolder) + doc.DocType, cfg.GoogleWorkspace.ShortcutsFolder) if err != nil { return nil, fmt.Errorf("error creating doc type subfolder: %w", err) } } // Get folder for doc type + product. - productFolder, err := s.GetSubfolder(docTypeFolder.Id, docObj.GetProduct()) + productFolder, err := s.GetSubfolder(docTypeFolder.Id, doc.Product) if err != nil { return nil, fmt.Errorf("error getting product subfolder: %w", err) } @@ -605,7 +609,7 @@ func createShortcut( // Product folder wasn't found, so create it. if productFolder == nil { productFolder, err = s.CreateFolder( - docObj.GetProduct(), docTypeFolder.Id) + doc.Product, docTypeFolder.Id) if err != nil { return nil, fmt.Errorf("error creating product subfolder: %w", err) } @@ -613,7 +617,7 @@ func createShortcut( // Create shortcut. if shortcut, err = s.CreateShortcut( - docObj.GetObjectID(), + doc.ObjectID, productFolder.Id); err != nil { return nil, fmt.Errorf("error creating shortcut: %w", err) @@ -642,7 +646,7 @@ func getDocumentURL(baseURL, docID string) (string, error) { // TODO: use some sort of undo stack of functions instead of checking if the // arguments for this function are set. func revertReviewCreation( - docObj hcd.Doc, + doc document.Document, productAbbreviation string, fileRevision string, shortcut *drive.File, @@ -655,7 +659,7 @@ func revertReviewCreation( // Delete go-link if it exists. if err := links.DeleteDocumentRedirectDetails( - a, docObj.GetObjectID(), docObj.GetDocType(), docObj.GetDocNumber(), + a, doc.ObjectID, doc.DocType, doc.DocNumber, ); err != nil { result = multierror.Append( result, fmt.Errorf("error deleting go-link: %w", err)) @@ -671,27 +675,37 @@ func revertReviewCreation( // Move document back to drafts folder in Google Drive. if _, err := s.MoveFile( - docObj.GetObjectID(), cfg.GoogleWorkspace.DraftsFolder); err != nil { + doc.ObjectID, cfg.GoogleWorkspace.DraftsFolder); err != nil { result = multierror.Append( result, fmt.Errorf("error moving doc back to drafts folder: %w", err)) } // Change back document number to "ABC-???" and status to "WIP". - docObj.SetDocNumber(fmt.Sprintf("%s-???", productAbbreviation)) - docObj.SetStatus("WIP") + doc.DocNumber = fmt.Sprintf("%s-???", productAbbreviation) + doc.Status = "WIP" // Replace the doc header. - if err := docObj.ReplaceHeader( - docObj.GetObjectID(), cfg.BaseURL, true, s); err != nil { + if err := doc.ReplaceHeader( + cfg.BaseURL, true, s); err != nil { result = multierror.Append( result, fmt.Errorf("error replacing the doc header: %w", err)) } - // Delete file revision from Algolia document object. + // Delete file revision from document. if fileRevision != "" { - docObj.DeleteFileRevision(fileRevision) + doc.DeleteFileRevision(fileRevision) + } + + // Convert document to Algolia object. + docObj, err := doc.ToAlgoliaObject(true) + if err != nil { + result = multierror.Append( + result, fmt.Errorf("error converting document to Algolia object")) + + // We can't go any further so just return here. + return result } // Save doc back in the drafts index and delete it from the docs index. @@ -705,7 +719,7 @@ func revertReviewCreation( result = multierror.Append( result, fmt.Errorf("error saving draft in Algolia: %w", err)) } - delRes, err := a.Docs.DeleteObject(docObj.GetObjectID()) + delRes, err := a.Docs.DeleteObject(doc.ObjectID) if err != nil { result = multierror.Append( result, fmt.Errorf("error deleting doc in Algolia: %w", err)) @@ -721,6 +735,7 @@ func revertReviewCreation( // setLatestProductDocumentNumberinDB sets the latest product document number in // the database. +// TODO: remove along with ProductLatestDocumentNumber (not used). func setLatestProductDocumentNumberinDB( doc hcd.Doc, db *gorm.DB, diff --git a/internal/cmd/commands/indexer/indexer.go b/internal/cmd/commands/indexer/indexer.go index ad2ec9885..6a095a4d9 100644 --- a/internal/cmd/commands/indexer/indexer.go +++ b/internal/cmd/commands/indexer/indexer.go @@ -105,6 +105,7 @@ func (c *Command) Run(args []string) int { indexer.WithAlgoliaClient(algo), indexer.WithBaseURL(cfg.BaseURL), indexer.WithDatabase(db), + indexer.WithDocumentTypes(cfg.DocumentTypes.DocumentType), indexer.WithDocumentsFolderID(cfg.GoogleWorkspace.DocsFolder), indexer.WithDraftsFolderID(cfg.GoogleWorkspace.DraftsFolder), indexer.WithGoogleWorkspaceService(goog), diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index ca2984bb2..a64567f83 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -9,9 +9,10 @@ import ( "time" validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/hashicorp-forge/hermes/internal/config" "github.com/hashicorp-forge/hermes/pkg/algolia" + "github.com/hashicorp-forge/hermes/pkg/document" gw "github.com/hashicorp-forge/hermes/pkg/googleworkspace" - hcd "github.com/hashicorp-forge/hermes/pkg/hashicorpdocs" "github.com/hashicorp-forge/hermes/pkg/links" "github.com/hashicorp-forge/hermes/pkg/models" "github.com/hashicorp/go-hclog" @@ -43,6 +44,9 @@ type Indexer struct { // documents to index. DocumentsFolderID string + // DocumentTypes are a slice of document types from the application config. + DocumentTypes []*config.DocumentType + // DraftsFolderID is the Google Drive ID of the folder containing draft // documents to index. DraftsFolderID string @@ -101,6 +105,7 @@ func (idx *Indexer) validate() error { validation.Field(&idx.BaseURL, validation.Required), validation.Field(&idx.Database, validation.Required), validation.Field(&idx.DocumentsFolderID, validation.Required), + validation.Field(&idx.DocumentTypes, validation.Required), validation.Field(&idx.DraftsFolderID, validation.Required), validation.Field(&idx.GoogleWorkspaceService, validation.Required), ) @@ -134,6 +139,13 @@ func WithDocumentsFolderID(d string) IndexerOption { } } +// WithDocumentTypes sets the document types. +func WithDocumentTypes(dts []*config.DocumentType) IndexerOption { + return func(i *Indexer) { + i.DocumentTypes = dts + } +} + // WithDraftsFolderID sets the drafts folder ID. func WithDraftsFolderID(d string) IndexerOption { return func(i *Indexer) { @@ -410,16 +422,18 @@ func (idx *Indexer) Run() error { os.Exit(1) } - // Create new document object of the proper document type. - docObj, err := hcd.NewEmptyDoc(dbDoc.DocumentType.Name) - if err != nil { - logError("error creating new empty document", err) + // Get document object from Algolia. + var algoObj map[string]any + if err = algo.Docs.GetObject(file.Id, &algoObj); err != nil { + logError("error retrieving document object from Algolia", err) os.Exit(1) } - // Get document object from Algolia. - if err := algo.Docs.GetObject(file.Id, &docObj); err != nil { - logError("error retrieving document object from Algolia", err) + // Convert Algolia object to a document. + doc, err := document.NewFromAlgoliaObject( + algoObj, idx.DocumentTypes) + if err != nil { + logError("error converting Algolia object to document", err) os.Exit(1) } @@ -440,11 +454,11 @@ func (idx *Indexer) Run() error { } // Update document object with content and latest modified time. - docObj.SetContent(string(content)) - docObj.SetModifiedTime(modifiedTime.Unix()) + doc.Content = (string(content)) + doc.ModifiedTime = modifiedTime.Unix() // Save the document in Algolia. - if err := saveDocInAlgolia(docObj, idx.AlgoliaClient); err != nil { + if err := saveDocInAlgolia(*doc, idx.AlgoliaClient); err != nil { return fmt.Errorf("error saving document in Algolia: %w", err) } @@ -482,7 +496,7 @@ func (idx *Indexer) Run() error { // saveDoc saves a document struct and its redirect details in Algolia. func saveDocInAlgolia( - doc hcd.Doc, + doc document.Document, algo *algolia.Client, ) error { // Save document object. @@ -496,9 +510,9 @@ func saveDocInAlgolia( } // Save document redirect details. - if doc.GetDocNumber() != "" { + if doc.DocNumber != "" { err = links.SaveDocumentRedirectDetails( - algo, doc.GetObjectID(), doc.GetDocType(), doc.GetDocNumber()) + algo, doc.ObjectID, doc.DocType, doc.DocNumber) if err != nil { return err } diff --git a/internal/indexer/refresh_headers.go b/internal/indexer/refresh_headers.go index cac266ba0..22ac17ad5 100644 --- a/internal/indexer/refresh_headers.go +++ b/internal/indexer/refresh_headers.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/hashicorp-forge/hermes/pkg/algolia" + "github.com/hashicorp-forge/hermes/pkg/document" hcd "github.com/hashicorp-forge/hermes/pkg/hashicorpdocs" "github.com/hashicorp-forge/hermes/pkg/models" "google.golang.org/api/drive/v3" @@ -173,30 +173,37 @@ func refreshDocumentHeader( return } - // Get base document object from Algolia so we can determine the document - // type. - var baseDocObj hcd.BaseDoc - if err := getAlgoliaDocObject(algo, file.Id, ft, &baseDocObj); err != nil { - log.Error("error getting document object from Algolia", - "error", err, - "google_file_id", file.Id, + // Get document object from Algolia. + var algoObj map[string]any + switch ft { + case draftsFolderType: + if err = algo.Drafts.GetObject(file.Id, &algoObj); err != nil { + log.Error("error getting draft document object from Algolia", + "error", err, + "google_file_id", file.Id, + ) + os.Exit(1) + } + case documentsFolderType: + if err = algo.Docs.GetObject(file.Id, &algoObj); err != nil { + log.Error("error getting document object from Algolia", + "error", err, + "google_file_id", file.Id, + ) + os.Exit(1) + } + default: + log.Error("bad folder type", + "folder_type", ft, ) os.Exit(1) } - // Create new document object of the proper document type. - docObj, err := hcd.NewEmptyDoc(baseDocObj.DocType) + // Convert Algolia object to a document. + doc, err := document.NewFromAlgoliaObject( + algoObj, idx.DocumentTypes) if err != nil { - log.Error("error creating new empty document object", - "error", err, - "google_file_id", file.Id, - ) - os.Exit(1) - } - - // Get document object from Algolia. - if err := getAlgoliaDocObject(algo, file.Id, ft, &docObj); err != nil { - log.Error("error getting document object from Algolia", + log.Error("error converting Algolia object to document", "error", err, "google_file_id", file.Id, ) @@ -204,8 +211,8 @@ func refreshDocumentHeader( } // Replace document header. - if err := docObj.ReplaceHeader( - file.Id, idx.BaseURL, true, idx.GoogleWorkspaceService); err != nil { + if err := doc.ReplaceHeader( + idx.BaseURL, true, idx.GoogleWorkspaceService); err != nil { log.Error("error replacing document header", "error", err, "google_file_id", file.Id, @@ -244,20 +251,3 @@ func refreshDocumentHeader( "google_file_id", file.Id, ) } - -func getAlgoliaDocObject( - algo *algolia.Client, - objectID string, - ft folderType, - target interface{}, -) error { - switch ft { - case draftsFolderType: - return algo.Drafts.GetObject(objectID, &target) - case documentsFolderType: - return algo.Docs.GetObject(objectID, &target) - default: - return fmt.Errorf("bad folder type: %v", ft) - } - -} diff --git a/pkg/document/doc.go b/pkg/document/doc.go new file mode 100644 index 000000000..6a2b21c8d --- /dev/null +++ b/pkg/document/doc.go @@ -0,0 +1,3 @@ +// Package document defines a document struct and contains logic for working +// with these documents. +package document diff --git a/pkg/document/document.go b/pkg/document/document.go new file mode 100644 index 000000000..05eb0759f --- /dev/null +++ b/pkg/document/document.go @@ -0,0 +1,346 @@ +package document + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/hashicorp-forge/hermes/internal/config" + "github.com/iancoleman/strcase" + "github.com/mitchellh/mapstructure" +) + +type Document struct { + // ObjectID is the Google Drive file ID for the document. + ObjectID string `json:"objectID,omitempty"` + + // Title is the title of the document. It does not contain the document number + // (e.g., "TF-123"). + Title string `json:"title,omitempty"` + + // DocType is the type of document (e.g., "RFC", "PRD"). + DocType string `json:"docType,omitempty"` + + // DocNumber is a unique document identifier containing a product/area + // abbreviation and a unique number (e.g., "TF-123"). + DocNumber string `json:"docNumber,omitempty"` + + // AppCreated should be set to true if the document was created through this + // application, and false if created directly in Google Docs and indexed + // afterwards. + AppCreated bool `json:"appCreated,omitempty"` + + // ApprovedBy is a slice of email address strings for users that have approved + // the document. + ApprovedBy []string `json:"approvedBy,omitempty"` + + // Approvers is a slice of email address strings for users whose approvals + // are requested for the document. + Approvers []string `json:"approvers,omitempty"` + + // ChangesRequestedBy is a slice of email address strings for users that have + // requested changes for the document. + ChangesRequestedBy []string `json:"changesRequestedBy,omitempty"` + + // Contributors is a slice of email address strings for users who have + // contributed to the document. + Contributors []string `json:"contributors,omitempty"` + + // Content is the plaintext content of the document. + Content string `json:"content,omitempty"` + + // Created is the UTC time of document creation, in a RFC 3339 string format. + Created string `json:"created,omitempty"` + + // CreatedTime is the time of document creation, in Unix time. + CreatedTime int64 `json:"createdTime,omitempty"` + + // CustomEditableFields are all document-type-specific fields that are + // editable. + CustomEditableFields map[string]CustomDocTypeField `json:"customEditableFields,omitempty"` + + // CustomFields are custom fields that contain values too. + // TODO: consolidate with CustomEditableFields. + CustomFields []CustomField `json:"customFields,omitempty"` + + // FileRevisions is a map of file revision IDs to custom names. + FileRevisions map[string]string `json:"fileRevisions,omitempty"` + + // TODO: LinkedDocs is not used yet. + LinkedDocs []string `json:"linkedDocs,omitempty"` + + // Locked is true if the document is locked for editing. + Locked bool `json:"locked,omitempty"` + + // MetaTags contains metadata tags that can be used for filtering in Algolia. + MetaTags []string `json:"_tags,omitempty"` + + // Created is the time that the document was last modified, in Unix time. + ModifiedTime int64 `json:"modifiedTime,omitempty"` + + // Owners is a slice of email address strings for document owners. Hermes + // generally only uses the first element as the document owner, but this is a + // slice for historical reasons as some HashiCorp documents have had multiple + // owners in the past. + Owners []string `json:"owners,omitempty"` + + // OwnerPhotos is a slice of URL strings for the profile photos of the + // document owners (in the same order as the Owners field). + OwnerPhotos []string `json:"ownerPhotos,omitempty"` + + // Product is the product or area that the document relates to. + Product string `json:"product,omitempty"` + + // Summary is a summary of the document. + Summary string `json:"summary,omitempty"` + + // Status is the status of the document (e.g., "WIP", "In-Review", "Approved", + // "Obsolete"). + Status string `json:"status,omitempty"` + + // Tags is a slice of tags to help users discover the document based on their + // interests. + Tags []string `json:"tags,omitempty"` + + // ThumbnailLink is a URL string for the document thumbnail image. + ThumbnailLink string `json:"thumbnailLink,omitempty"` +} + +type CustomDocTypeField struct { + // DisplayName is the display name of the custom document-type field. + DisplayName string `json:"displayName"` + + // Type is the type of the custom document-type field. It is used by the + // frontend to display the proper input component. + // Valid values: "PEOPLE", "STRING". + Type string `json:"type"` +} + +type CustomField struct { + // Name is the name of the custom field. + // TODO: consolidate with DisplayName and make corresponding frontend changes + // to support this. + Name string `json:"name"` + + // DisplayName is the display name of the custom field. + DisplayName string `json:"displayName"` + + // Type is the type of the custom field. It is used by the frontend to display + // the proper input component. + // Valid values: "PEOPLE", "STRING". + Type string `json:"type"` + + // Value is the value of the custom field. + Value any +} + +// NewFromAlgoliaObject creates a document from a document Algolia object. +func NewFromAlgoliaObject( + in map[string]any, docTypes []*config.DocumentType) (*Document, error) { + + doc := &Document{} + + if err := mapstructure.Decode(in, &doc); err != nil { + return nil, fmt.Errorf("error decoding to document: %w", err) + } + + // Build CustomFields and CustomEditableFields. + // Note: This is redundant but we're doing this to maintain compatibility with + // the way these documents have been defined. This will change with the next + // version of the API. + cefs := make(map[string]CustomDocTypeField) + cfs := []CustomField{} + + if objDocType, ok := in["docType"]; !ok { + return nil, fmt.Errorf("docType not found in object") + } else { + foundDocType := false + for _, dt := range docTypes { + if dt.Name == objDocType { + foundDocType = true + for _, cf := range dt.CustomFields { + ccName := strcase.ToLowerCamel(cf.Name) + switch cf.Type { + case "string": + if v, ok := in[ccName]; ok { + if v, ok := v.(string); ok { + cfs = append(cfs, CustomField{ + Name: ccName, + DisplayName: cf.Name, + Type: "STRING", + Value: v, + }) + } else { + return nil, fmt.Errorf( + "wrong type for custom field key %q, want string", ccName) + } + } + cefs[ccName] = CustomDocTypeField{ + DisplayName: cf.Name, + Type: "STRING", + } + case "people": + cfVal := []string{} + if v, ok := in[ccName]; ok { + if reflect.TypeOf(v).Kind() == reflect.Slice { + for _, vv := range v.([]any) { + if vv, ok := vv.(string); ok { + cfVal = append(cfVal, vv) + } else { + return nil, fmt.Errorf( + "wrong type for custom field key %q, want []string", + ccName) + } + } + cfs = append(cfs, CustomField{ + Name: ccName, + DisplayName: cf.Name, + Type: "PEOPLE", + Value: cfVal, + }) + } else { + return nil, fmt.Errorf( + "wrong type for custom field key %q, want []string", dt.Name) + } + } + cefs[ccName] = CustomDocTypeField{ + DisplayName: cf.Name, + Type: "PEOPLE", + } + default: + return nil, fmt.Errorf( + "unknown type for custom field key %q: %s", dt.Name, cf.Type) + } + } + break + } + } + if !foundDocType { + return nil, fmt.Errorf("invalid doc type: %s", objDocType) + } + doc.CustomFields = cfs + doc.CustomEditableFields = cefs + } + + return doc, nil +} + +// ToAlgoliaObject converts a document to a document Algolia object. +func (d Document) ToAlgoliaObject( + removeCustomEditableFields bool) (map[string]any, error) { + + // Remove CustomEditableFields, if configured. + if removeCustomEditableFields { + d.CustomEditableFields = nil + } + + // Save and remove custom fields. + cfs := d.CustomFields + d.CustomFields = nil + + // Convert to Algolia object by marshaling to JSON and unmarshaling back. + var obj map[string]any + if bytes, err := json.Marshal(d); err != nil { + return nil, fmt.Errorf("error marshaling document object to JSON: %w", err) + } else { + if err := json.Unmarshal(bytes, &obj); err != nil { + return nil, fmt.Errorf("error unmarshaling JSON to object: %w", err) + } + } + + // Set custom fields. + for _, cf := range cfs { + obj[cf.Name] = cf.Value + } + + return obj, nil +} + +func (d *Document) UpsertCustomField(cf CustomField) error { + // Build new document CustomFields. + var newCFs []CustomField + foundCF := false + for _, docCF := range d.CustomFields { + if docCF.Name == cf.Name { + // Validate rest of custom field. + if cf.DisplayName != docCF.DisplayName { + return fmt.Errorf("incorrect display name for custom field") + } + switch cf.Type { + case "PEOPLE": + if reflect.TypeOf(cf.Value).Kind() != reflect.Slice { + return fmt.Errorf("incorrect value type for custom field") + } + for _, v := range cf.Value.([]any) { + // Make sure slice is a string slice. + if _, ok := v.(string); !ok { + return fmt.Errorf("incorrect value type for custom field") + } + } + case "STRING": + if _, ok := cf.Value.(string); !ok { + return fmt.Errorf("incorrect value type for custom field") + } + } + newCFs = append(newCFs, cf) + foundCF = true + } else { + newCFs = append(newCFs, docCF) + } + } + + // If we didn't find the custom field, insert it. + if !foundCF { + newCFs = append(newCFs, cf) + } + + d.CustomFields = newCFs + + return nil +} + +func (d *Document) DeleteFileRevision(revisionID string) { + delete(d.FileRevisions, revisionID) +} + +func (d *Document) SetFileRevision(revisionID, revisionName string) { + if d.FileRevisions == nil { + d.FileRevisions = map[string]string{ + revisionID: revisionName, + } + } else { + d.FileRevisions[revisionID] = revisionName + } +} + +func GetStringValue(in map[string]any, key string) (string, error) { + if v, ok := in[key]; ok { + if v, ok := v.(string); ok { + return v, nil + } else { + return "", fmt.Errorf("wrong type for key %q, want string", key) + } + } else { + return "", fmt.Errorf("key %q not found", key) + } +} + +func GetStringSliceValue(in map[string]any, key string) ([]string, error) { + ret := []string{} + if v, ok := in[key]; ok { + if reflect.TypeOf(v).Kind() == reflect.Slice { + for _, vv := range v.([]any) { + if vv, ok := vv.(string); ok { + ret = append(ret, vv) + } else { + return nil, fmt.Errorf("wrong type for key %q, want []string", key) + } + } + return ret, nil + } else { + return nil, fmt.Errorf("wrong type for key %q, want []string", key) + } + } else { + return nil, fmt.Errorf("key %q not found", key) + } +} diff --git a/pkg/document/replace_header.go b/pkg/document/replace_header.go new file mode 100644 index 000000000..63bc9151b --- /dev/null +++ b/pkg/document/replace_header.go @@ -0,0 +1,830 @@ +package document + +import ( + "fmt" + "math" + "net/url" + "path" + "reflect" + "strings" + "unicode/utf8" + + "github.com/hashicorp-forge/hermes/internal/helpers" + gw "github.com/hashicorp-forge/hermes/pkg/googleworkspace" + "google.golang.org/api/docs/v1" +) + +// ReplaceHeader replaces the document header, which is assumed to be the first +// table in the document. +// +// The resulting table looks like this: +// +// |-----------------------------------------------------------------------------| +// | Title: {{title}} | +// |-----------------------------------------------------------------------------| +// | Summary: {{summary}} | +// |-----------------------------------------------------------------------------| +// | | +// |-----------------------------------------------------------------------------| +// | Created: {{created}} | Status: {{status}} | +// |-----------------------------------------------------------------------------| +// | | +// |-----------------------------------------------------------------------------| +// | Product: {{product}} | Owner: {{owner}} | +// |-----------------------------------------------------------------------------| +// | Contributors: {{contributors}} | Approvers: {{approvers}} | +// |-----------------------------------------------------------------------------| +// | Custom field: {{custom_field_value}} | Custom field: {{custom_field_value}} | +// |-----------------------------------------------------------------------------| +// | ... | ... | +// |-----------------------------------------------------------------------------| +// | | +// |-----------------------------------------------------------------------------| +// | NOTE: This document is managed by Hermes... | +// |-----------------------------------------------------------------------------| + +func (doc *Document) ReplaceHeader( + baseURL string, isDraft bool, s *gw.Service) error { + + // Get doc. + d, err := s.GetDoc(doc.ObjectID) + if err != nil { + return fmt.Errorf("error getting doc: %w", err) + } + + // Find the start and end indexes of the first table (assume that it is the + // doc header). + var ( + endIndex int64 + startIndex int64 + t *docs.Table + headerTableFound bool + ) + elems := d.Body.Content + for _, e := range elems { + if e.Table != nil { + t = e.Table + startIndex = e.StartIndex + endIndex = e.EndIndex + break + } + } + // startIndex should be 2, but we'll allow a little leeway in case someone + // accidentally added a newline or something. + if t != nil && startIndex < 5 { + headerTableFound = true + } else { + // Header table wasn't found, so we'll insert a new one at index 2. + startIndex = 2 + } + + // Delete existing header. + if headerTableFound { + req := &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{ + { + DeleteContentRange: &docs.DeleteContentRangeRequest{ + Range: &docs.Range{ + SegmentId: "", + StartIndex: startIndex, + EndIndex: endIndex + 1, + }, + }, + }, + }, + } + _, err = s.Docs.Documents.BatchUpdate(doc.ObjectID, req).Do() + if err != nil { + return fmt.Errorf("error deleting existing header: %w", err) + } + } + + // Calculate number of rows in the header table. + // The number of custom field rows is the number of custom fields divided by + // two and rounded up to the nearest integer. + customFieldRows := math.Ceil(float64(len(doc.CustomFields)) / float64(2)) + tableRows := 9 + int64(customFieldRows) + + // Insert new header table. + req := &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{ + { + InsertTable: &docs.InsertTableRequest{ + Columns: 2, + Location: &docs.Location{ + Index: startIndex - 1, + }, + Rows: tableRows, + }, + }, + }, + } + _, err = s.Docs.Documents.BatchUpdate(doc.ObjectID, req).Do() + if err != nil { + return fmt.Errorf("error inserting header table: %w", err) + } + + // Find new table index. + elems = d.Body.Content + for _, e := range elems { + if e.Table != nil { + startIndex = e.StartIndex + break + } + } + + // Apply formatting to the table. + req = &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{ + { + // Remove table borders (by setting width to 0 and setting color to + // white as a backup), and remove padding (by setting to 0). + UpdateTableCellStyle: &docs.UpdateTableCellStyleRequest{ + Fields: "borderBottom,borderLeft,borderRight,borderTop,paddingBottom,paddingLeft,paddingRight,paddingTop", + TableCellStyle: &docs.TableCellStyle{ + BorderBottom: &docs.TableCellBorder{ + Color: &docs.OptionalColor{ + Color: &docs.Color{ + RgbColor: &docs.RgbColor{ + Blue: 1.0, + Green: 1.0, + Red: 1.0, + }, + }, + }, + DashStyle: "SOLID", + Width: &docs.Dimension{ + Magnitude: 0, + Unit: "PT", + }, + }, + BorderLeft: &docs.TableCellBorder{ + Color: &docs.OptionalColor{ + Color: &docs.Color{ + RgbColor: &docs.RgbColor{ + Blue: 1.0, + Green: 1.0, + Red: 1.0, + }, + }, + }, + DashStyle: "SOLID", + Width: &docs.Dimension{ + Magnitude: 0, + Unit: "PT", + }, + }, + BorderRight: &docs.TableCellBorder{ + Color: &docs.OptionalColor{ + Color: &docs.Color{ + RgbColor: &docs.RgbColor{ + Blue: 1.0, + Green: 1.0, + Red: 1.0, + }, + }, + }, + DashStyle: "SOLID", + Width: &docs.Dimension{ + Magnitude: 0, + Unit: "PT", + }, + }, + BorderTop: &docs.TableCellBorder{ + Color: &docs.OptionalColor{ + Color: &docs.Color{ + RgbColor: &docs.RgbColor{ + Blue: 1.0, + Green: 1.0, + Red: 1.0, + }, + }, + }, + DashStyle: "SOLID", + Width: &docs.Dimension{ + Magnitude: 0, + Unit: "PT", + }, + }, + PaddingBottom: &docs.Dimension{ + Magnitude: 0, + Unit: "PT", + }, + PaddingLeft: &docs.Dimension{ + Magnitude: 0, + Unit: "PT", + }, + PaddingRight: &docs.Dimension{ + Magnitude: 0, + Unit: "PT", + }, + PaddingTop: &docs.Dimension{ + Magnitude: 0, + Unit: "PT", + }, + }, + TableRange: &docs.TableRange{ + ColumnSpan: 2, + RowSpan: tableRows, + TableCellLocation: &docs.TableCellLocation{ + ColumnIndex: 0, + RowIndex: 0, + TableStartLocation: &docs.Location{ + Index: startIndex, + }, + }, + }, + }, + }, + + // Update Title row minimum height. + { + UpdateTableRowStyle: &docs.UpdateTableRowStyleRequest{ + Fields: "minRowHeight", + RowIndices: []int64{0}, + TableRowStyle: &docs.TableRowStyle{ + MinRowHeight: &docs.Dimension{ + Magnitude: 27, + Unit: "PT", + }, + }, + TableStartLocation: &docs.Location{ + Index: startIndex, + }, + }, + }, + + // Update Summary row minimum height. + { + UpdateTableRowStyle: &docs.UpdateTableRowStyleRequest{ + Fields: "minRowHeight", + RowIndices: []int64{1}, + TableRowStyle: &docs.TableRowStyle{ + MinRowHeight: &docs.Dimension{ + Magnitude: 11, + Unit: "PT", + }, + }, + TableStartLocation: &docs.Location{ + Index: startIndex, + }, + }, + }, + + // Merge cells for the Title row. + { + MergeTableCells: &docs.MergeTableCellsRequest{ + TableRange: &docs.TableRange{ + ColumnSpan: 2, + RowSpan: 1, + TableCellLocation: &docs.TableCellLocation{ + ColumnIndex: 0, + RowIndex: 0, + TableStartLocation: &docs.Location{ + Index: startIndex, + }, + }, + }, + }, + }, + + // Merge cells for the Summary row. + { + MergeTableCells: &docs.MergeTableCellsRequest{ + TableRange: &docs.TableRange{ + ColumnSpan: 2, + RowSpan: 1, + TableCellLocation: &docs.TableCellLocation{ + ColumnIndex: 0, + RowIndex: 1, + TableStartLocation: &docs.Location{ + Index: startIndex, + }, + }, + }, + }, + }, + + // Merge cells for blank row after the Summary row. + { + MergeTableCells: &docs.MergeTableCellsRequest{ + TableRange: &docs.TableRange{ + ColumnSpan: 2, + RowSpan: 1, + TableCellLocation: &docs.TableCellLocation{ + ColumnIndex: 0, + RowIndex: 2, + TableStartLocation: &docs.Location{ + Index: startIndex, + }, + }, + }, + }, + }, + + // Merge cells for blank row after the Created/Status row. + { + MergeTableCells: &docs.MergeTableCellsRequest{ + TableRange: &docs.TableRange{ + ColumnSpan: 2, + RowSpan: 1, + TableCellLocation: &docs.TableCellLocation{ + ColumnIndex: 0, + RowIndex: 4, + TableStartLocation: &docs.Location{ + Index: startIndex, + }, + }, + }, + }, + }, + + // Merge cells for blank row before the "Managed by Hermes" note row. + { + MergeTableCells: &docs.MergeTableCellsRequest{ + TableRange: &docs.TableRange{ + ColumnSpan: 2, + RowSpan: 1, + TableCellLocation: &docs.TableCellLocation{ + ColumnIndex: 0, + // RowIndex: 10, + RowIndex: tableRows - 2, + TableStartLocation: &docs.Location{ + Index: startIndex, + }, + }, + }, + }, + }, + + // Merge cells for the "Managed by Hermes" note row. + { + MergeTableCells: &docs.MergeTableCellsRequest{ + TableRange: &docs.TableRange{ + ColumnSpan: 2, + RowSpan: 1, + TableCellLocation: &docs.TableCellLocation{ + ColumnIndex: 0, + // RowIndex: 11, + RowIndex: tableRows - 1, + TableStartLocation: &docs.Location{ + Index: startIndex, + }, + }, + }, + }, + }, + }, + } + _, err = s.Docs.Documents.BatchUpdate(doc.ObjectID, req).Do() + if err != nil { + return fmt.Errorf("error applying formatting to header table: %w", err) + } + + // Populate table. + var ( + pos int // Use to track position in document. + reqs []*docs.Request + cellReqs []*docs.Request // Temp var used for createTextCellRequests() results. + cellLength int // Temp var used for createTextCellRequests() results. + ) + + // Title cell. + pos = int(startIndex) + 3 + titleText := fmt.Sprintf("[%s] %s", doc.DocNumber, doc.Title) + reqs = append(reqs, + []*docs.Request{ + { + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Fields: "bold,fontSize,foregroundColor", + Range: &docs.Range{ + StartIndex: int64(pos), + EndIndex: int64(pos + 1), + }, + TextStyle: &docs.TextStyle{ + Bold: true, + FontSize: &docs.Dimension{ + Magnitude: 20, + Unit: "PT", + }, + ForegroundColor: &docs.OptionalColor{ + Color: &docs.Color{ + RgbColor: &docs.RgbColor{ + Blue: 0.2627451, + Green: 0.2627451, + Red: 0.2627451, + }, + }, + }, + }, + }, + }, + { + InsertText: &docs.InsertTextRequest{ + Location: &docs.Location{ + Index: int64(pos), + }, + Text: titleText, + }, + }, + }..., + ) + pos += len(titleText) + 5 + + // Summary cell. + summaryText := fmt.Sprintf("Summary: %s", doc.Summary) + reqs = append(reqs, + []*docs.Request{ + { + InsertText: &docs.InsertTextRequest{ + Location: &docs.Location{ + Index: int64(pos), + }, + Text: summaryText, + }, + }, + + // Bold "Summary:". + { + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Fields: "bold", + Range: &docs.Range{ + StartIndex: int64(pos), + EndIndex: int64(pos + 8), + }, + TextStyle: &docs.TextStyle{ + Bold: true, + }, + }, + }, + }..., + ) + pos += len(summaryText) + 5 + + // Blank row after summary row. + reqs = append(reqs, + []*docs.Request{ + { + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Fields: "fontSize", + Range: &docs.Range{ + StartIndex: int64(pos), + EndIndex: int64(pos + 1), + }, + TextStyle: &docs.TextStyle{ + FontSize: &docs.Dimension{ + Magnitude: 8, + Unit: "PT", + }, + }, + }, + }, + }...) + pos += 5 + + // Created cell. + cellReqs, cellLength = createTextCellRequests( + "Created", doc.Created, int64(pos)) + reqs = append(reqs, cellReqs...) + pos += cellLength + 2 + + // Status cell. + cellReqs, cellLength = createTextCellRequests( + "Status", "WIP | In-Review | Approved | Obsolete", int64(pos)) + reqs = append(reqs, cellReqs...) + var statusStartIndex, statusEndIndex int + switch strings.ToLower(doc.Status) { + case "in review": + fallthrough + case "in-review": + statusStartIndex = 14 + statusEndIndex = 23 + case "approved": + statusStartIndex = 26 + statusEndIndex = 34 + case "obsolete": + statusStartIndex = 37 + statusEndIndex = 45 + case "wip": + fallthrough + default: + // Default to "WIP" for all unknown statuses. + statusStartIndex = 8 + statusEndIndex = 11 + } + reqs = append(reqs, + // Bold the status. + &docs.Request{ + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Fields: "bold", + Range: &docs.Range{ + StartIndex: int64(pos + statusStartIndex), + EndIndex: int64(pos + statusEndIndex), + }, + TextStyle: &docs.TextStyle{ + Bold: true, + }, + }, + }) + pos += cellLength + 3 + + // Blank row after Created/Status row. + reqs = append(reqs, + []*docs.Request{ + { + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Fields: "fontSize", + Range: &docs.Range{ + StartIndex: int64(pos), + EndIndex: int64(pos + 1), + }, + TextStyle: &docs.TextStyle{ + FontSize: &docs.Dimension{ + Magnitude: 8, + Unit: "PT", + }, + }, + }, + }, + }...) + pos += 5 + + // Product cell. + cellReqs, cellLength = createTextCellRequests( + "Product", doc.Product, int64(pos)) + reqs = append(reqs, cellReqs...) + pos += cellLength + 2 + + // Owner cell. + cellReqs, cellLength = createTextCellRequests( + "Owner", doc.Owners[0], int64(pos)) + reqs = append(reqs, cellReqs...) + pos += cellLength + 3 + + // Contributors cell. + cellReqs, cellLength = createTextCellRequests( + "Contributors", strings.Join(doc.Contributors[:], ", "), int64(pos)) + reqs = append(reqs, cellReqs...) + pos += cellLength + 2 + + // Approvers cell. + // Build approvers slice with a check next to reviewers who have approved. + var approvers []string + for _, approver := range doc.Approvers { + if helpers.StringSliceContains(doc.ApprovedBy, approver) { + approvers = append(approvers, "✅ "+approver) + } else if helpers.StringSliceContains(doc.ChangesRequestedBy, approver) { + approvers = append(approvers, "❌ "+approver) + } else { + approvers = append(approvers, approver) + } + } + cellReqs, cellLength = createTextCellRequests( + "Approvers", strings.Join(approvers[:], ", "), int64(pos)) + reqs = append(reqs, cellReqs...) + pos += cellLength + 3 + + // Custom fields. + for i, cf := range doc.CustomFields { + switch cf.Type { + case "PEOPLE": + cfVal := []string{} + + if reflect.TypeOf(cf.Value).Kind() == reflect.Slice { + switch reflect.TypeOf(cf.Value).Elem().Kind() { + case reflect.Interface: + // If the value is an interface slice, convert to a string slice. + for _, v := range cf.Value.([]any) { + if vv, ok := v.(string); ok { + cfVal = append(cfVal, vv) + } else { + return fmt.Errorf( + "wrong type for custom field %q, want []string", cf.Name) + } + } + case reflect.String: + if v, ok := cf.Value.([]string); ok { + cfVal = v + } else { + return fmt.Errorf( + "error asserting value for custom field %q as []string", cf.Name) + } + default: + return fmt.Errorf( + "wrong type for custom field %q, want []string", cf.Name) + } + } else { + return fmt.Errorf( + "wrong type for custom field %q, want []string", cf.Name) + } + + // Change string slice to comma-separated value. + cellReqs, cellLength = createTextCellRequests( + cf.DisplayName, strings.Join(cfVal[:], ", "), int64(pos)) + reqs = append(reqs, cellReqs...) + + case "STRING": + if v, ok := cf.Value.(string); ok { + // TODO: Don't hardcode these custom fields and instead create something + // like a "HERMES_DOCUMENT" custom field type. + switch cf.DisplayName { + case "PRD": + fallthrough + case "RFC": + cellReqs, cellLength = createTextCellRequests( + cf.DisplayName, cf.DisplayName, int64(pos)) + reqs = append(reqs, cellReqs...) + reqs = append(reqs, + []*docs.Request{ + // Add link to document. + { + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Fields: "link", + Range: &docs.Range{ + StartIndex: int64(pos + 5), + EndIndex: int64(pos + 8), + }, + TextStyle: &docs.TextStyle{ + Link: &docs.Link{ + Url: v, + }, + }, + }, + }, + }...) + default: + cellReqs, cellLength = createTextCellRequests( + cf.DisplayName, v, int64(pos)) + reqs = append(reqs, cellReqs...) + } + + } else { + return fmt.Errorf( + "wrong type for custom field %q, want string", cf.Name) + } + + default: + return fmt.Errorf("invalid custom field type: %s", cf.Type) + } + + if i%2 == 0 { + // If this is a first-column custom field, move the position in the + // document up by the cell length + 2. + pos += cellLength + 2 + + // Add 3 more if this is the last custom field (to move the position to + // the next row). + if i == len(doc.CustomFields)-1 { + pos += 3 + } + } else { + // If this is a second-column custom field, move the position in the + // document up by the cell length + 3. + pos += cellLength + 3 + } + } + + // Blank row. + reqs = append(reqs, + []*docs.Request{ + { + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Fields: "fontSize", + Range: &docs.Range{ + StartIndex: int64(pos), + EndIndex: int64(pos + 1), + }, + TextStyle: &docs.TextStyle{ + FontSize: &docs.Dimension{ + Magnitude: 8, + Unit: "PT", + }, + }, + }, + }, + }...) + pos += 5 + + // "Managed by Hermes..." note cell. + docURL, err := url.Parse(baseURL) + if err != nil { + return fmt.Errorf("error parsing base URL: %w", err) + } + docURL.Path = path.Join(docURL.Path, "document", doc.ObjectID) + docURLString := docURL.String() + docURLString = strings.TrimRight(docURLString, "/") + if isDraft { + docURLString += "?draft=true" + } + cellReqs, cellLength = createTextCellRequests( + "NOTE", + "This document is managed by Hermes and this header will be periodically overwritten using document metadata.", + int64(pos)) + reqs = append(reqs, cellReqs...) + reqs = append(reqs, + []*docs.Request{ + // Add link to document in Hermes. + { + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Fields: "link", + Range: &docs.Range{ + StartIndex: int64(pos + 11), + EndIndex: int64(pos + 19), + }, + TextStyle: &docs.TextStyle{ + Link: &docs.Link{ + Url: docURLString, + }, + }, + }, + }, + + // Add link to Hermes. + { + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Fields: "link", + Range: &docs.Range{ + StartIndex: int64(pos + 34), + EndIndex: int64(pos + 40), + }, + TextStyle: &docs.TextStyle{ + Link: &docs.Link{ + Url: baseURL, + }, + }, + }, + }, + }...) + pos += cellLength + 5 + + // Do the batch update. + _, err = s.Docs.Documents.BatchUpdate(doc.ObjectID, + &docs.BatchUpdateDocumentRequest{ + Requests: reqs}). + Do() + if err != nil { + return fmt.Errorf("error populating table: %w", err) + } + + // Rename file with new title. + err = s.RenameFile( + doc.ObjectID, fmt.Sprintf("[%s] %s", doc.DocNumber, doc.Title)) + if err != nil { + return fmt.Errorf("error renaming file with new title: %w", err) + } + + return nil +} + +// createTextCellRequests creates a slice of Google Docs requests for header +// table cells consisting of `cellName: cellVal`. +func createTextCellRequests( + cellName, cellVal string, + startIndex int64) (reqs []*docs.Request, cellLength int) { + + if cellVal == "" { + cellVal = "N/A" + } + cellText := fmt.Sprintf("%s: %s", cellName, cellVal) + cellLength = utf8.RuneCountInString(cellText) + + reqs = []*docs.Request{ + { + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Fields: "fontSize", + Range: &docs.Range{ + StartIndex: startIndex, + EndIndex: startIndex + 1, + }, + TextStyle: &docs.TextStyle{ + FontSize: &docs.Dimension{ + Magnitude: 8, + Unit: "PT", + }, + }, + }, + }, + { + InsertText: &docs.InsertTextRequest{ + Location: &docs.Location{ + Index: startIndex, + }, + Text: cellText, + }, + }, + { + UpdateTextStyle: &docs.UpdateTextStyleRequest{ + Fields: "bold", + Range: &docs.Range{ + StartIndex: startIndex, + EndIndex: startIndex + int64(len(cellName)), + }, + TextStyle: &docs.TextStyle{ + Bold: true, + }, + }, + }, + } + + return +} diff --git a/web/app/components/document/sidebar.hbs b/web/app/components/document/sidebar.hbs index 4826d42f4..7e17268b3 100644 --- a/web/app/components/document/sidebar.hbs +++ b/web/app/components/document/sidebar.hbs @@ -20,7 +20,7 @@
@@ -104,7 +104,7 @@ data-test-editable={{this.isOwner}} @value={{this.title}} @onChange={{this.updateTitle}} - @loading={{this.save.isRunning}} + @loading={{this.saveIsRunning}} @disabled={{not this.isOwner}} @isRequired={{true}} > @@ -146,7 +146,7 @@ data-test-editable={{this.isOwner}} @value={{this.summary}} @onChange={{perform this.updateSummary}} - @loading={{this.save.isRunning}} + @loading={{this.saveIsRunning}} @disabled={{not this.isOwner}} > <:default as |F|> @@ -185,7 +185,7 @@ data-test-document-product-area-editable @selected={{this.product}} @onChange={{this.updateProduct.perform}} - @isSaving={{this.save.isRunning}} + @isSaving={{this.saveIsRunning}} @formatIsBadge={{true}} @renderOut={{true}} /> @@ -213,7 +213,7 @@ data-test-editable={{this.isOwner}} @value={{this.contributors}} @onChange={{perform this.save "contributors"}} - @loading={{this.save.isRunning}} + @loading={{this.saveIsRunning}} @disabled={{not this.isOwner}} > <:default> @@ -251,7 +251,7 @@ data-test-editable={{this.isOwner}} @value={{this.approvers}} @onChange={{perform this.save "approvers"}} - @loading={{this.save.isRunning}} + @loading={{this.saveIsRunning}} @disabled={{this.editingIsDisabled}} > <:default> @@ -318,8 +318,8 @@ @document={{@document}} @field={{field}} @attributes={{attributes}} - @onChange={{perform this.save field}} - @loading={{this.save.isRunning}} + @onChange={{perform this.saveCustomField field attributes}} + @loading={{this.saveIsRunning}} @disabled={{not this.isOwner}} /> diff --git a/web/app/components/document/sidebar.ts b/web/app/components/document/sidebar.ts index 05a6d1fa3..20488c733 100644 --- a/web/app/components/document/sidebar.ts +++ b/web/app/components/document/sidebar.ts @@ -17,7 +17,11 @@ import RouterService from "@ember/routing/router-service"; import SessionService from "hermes/services/session"; import FlashMessageService from "ember-cli-flash/services/flash-messages"; import { AuthenticatedUser } from "hermes/services/authenticated-user"; -import { HermesDocument, HermesUser } from "hermes/types/document"; +import { + CustomEditableField, + HermesDocument, + HermesUser, +} from "hermes/types/document"; import { assert } from "@ember/debug"; import Route from "@ember/routing/route"; import Ember from "ember"; @@ -583,6 +587,10 @@ export default class DocumentSidebarComponent extends Component { if (field && val !== undefined) { let serializedValue; @@ -603,6 +611,35 @@ export default class DocumentSidebarComponent extends Component { + if (field && val !== undefined) { + let serializedValue; + + if (typeof val === "string") { + serializedValue = cleanString(val); + } else { + serializedValue = val.map((p: HermesUser) => p.email); + } + + field.name = fieldName; + field.value = serializedValue; + + try { + await this.patchDocument.perform({ + customFields: [field], + }); + } catch (err) { + this.showFlashError(err as Error, "Unable to save document"); + } + } + } + ); + patchDocument = task(async (fields) => { const endpoint = this.isDraft ? "drafts" : "documents"; diff --git a/web/app/routes/authenticated/new/doc.js b/web/app/routes/authenticated/new/doc.js index cb8b2d694..bfcefae3d 100644 --- a/web/app/routes/authenticated/new/doc.js +++ b/web/app/routes/authenticated/new/doc.js @@ -14,23 +14,6 @@ export default class AuthenticatedNewDocRoute extends Route { }; async model(params) { - // Validate docType. - switch (params.docType) { - case "FRD": - case "PRD": - case "RFC": - break; - default: - this.flashMessages.add({ - message: `Invalid document type: ${params.docType}`, - title: "Invalid document type", - type: "critical", - timeout: 7000, - extendedTimeout: 1000, - }); - this.router.transitionTo("authenticated.new"); - } - return RSVP.hash({ docType: params?.docType, }); diff --git a/web/app/types/document.d.ts b/web/app/types/document.d.ts index 053360b6b..fefc2c2e7 100644 --- a/web/app/types/document.d.ts +++ b/web/app/types/document.d.ts @@ -39,6 +39,7 @@ export interface CustomEditableFields { } export interface CustomEditableField { + name?: string; displayName: string; type: "STRING" | "PEOPLE"; value?: string | string[]; From 6938158096e041d037c073d5b32701c7d79a1d22 Mon Sep 17 00:00:00 2001 From: Jeff Daley Date: Fri, 8 Sep 2023 14:43:09 -0400 Subject: [PATCH 03/13] Remove `docType` from NewDocForm submit button (#331) --- web/app/components/new/doc-form.hbs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/app/components/new/doc-form.hbs b/web/app/components/new/doc-form.hbs index af0f6a5e1..8e7f683f5 100644 --- a/web/app/components/new/doc-form.hbs +++ b/web/app/components/new/doc-form.hbs @@ -1,6 +1,6 @@ {{! @glint-nocheck: not typesafe yet }} {{#if this.docIsBeingCreated}} -
+
Creating @@ -12,7 +12,7 @@
{{else}}
@@ -22,7 +22,7 @@ class="hds-typography-display-500 hds-font-weight-bold hds-foreground-strong" >Create your {{@docType}}
-
+
One or two sentences outlining your doc. @@ -160,7 +160,7 @@ @owner={{this.authenticatedUser.info.email}} /> Date: Fri, 8 Sep 2023 15:28:34 -0400 Subject: [PATCH 04/13] Update Create Draft grammar (#332) --- web/app/components/new/doc-form.hbs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/web/app/components/new/doc-form.hbs b/web/app/components/new/doc-form.hbs index 8e7f683f5..e26029e34 100644 --- a/web/app/components/new/doc-form.hbs +++ b/web/app/components/new/doc-form.hbs @@ -3,9 +3,7 @@
- Creating - {{@docType}} - draft... + Creating draft in Google Drive...
This usually takes 10-20 seconds.
@@ -160,7 +158,7 @@ @owner={{this.authenticatedUser.info.email}} /> Date: Mon, 11 Sep 2023 18:49:13 -0400 Subject: [PATCH 05/13] Remove "choose a template" subhead (#330) --- web/app/templates/authenticated/new/index.hbs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web/app/templates/authenticated/new/index.hbs b/web/app/templates/authenticated/new/index.hbs index 4e924cbc2..e869ba5d1 100644 --- a/web/app/templates/authenticated/new/index.hbs +++ b/web/app/templates/authenticated/new/index.hbs @@ -1,12 +1,11 @@ {{page-title "New Doc"}}

Choose a template

-

Start by choosing the document type you choose to create.

    {{#each @model as |docType|}}
  1. @@ -18,7 +17,7 @@ {{if docType.moreInfoLink 'template-card--with-link'}}" >
    -

    +

    {{docType.name}}