diff --git a/README.md b/README.md index 110eaf145..0b99662bf 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Hermes was created and is currently maintained by HashiCorp Labs, a small team i 1. Enable the following APIs for [Google Workspace APIs](https://developers.google.com/workspace/guides/enable-apis) + - Admin SDK API - Google Docs API - Google Drive API - Gmail API diff --git a/configs/config.hcl b/configs/config.hcl index c889c35b9..0f9f2630d 100644 --- a/configs/config.hcl +++ b/configs/config.hcl @@ -122,6 +122,9 @@ google_workspace { // drafts_folder contains all draft documents. drafts_folder = "my-drafts-folder-id" + // groups_prefix is the prefix to use when searching for Google Groups. + // groups_prefix = "team-" + // If create_doc_shortcuts is set to true, shortcuts_folder will contain an // organized hierarchy of folders and shortcuts to published files that can be // easily browsed directly in Google Drive: diff --git a/internal/api/v2/approvals.go b/internal/api/v2/approvals.go index d0b0d8a30..1bf6f0693 100644 --- a/internal/api/v2/approvals.go +++ b/internal/api/v2/approvals.go @@ -15,87 +15,104 @@ import ( func ApprovalsHandler(srv server.Server) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "DELETE": - // Validate request. - docID, err := parseResourceIDFromURL(r.URL.Path, "approvals") - if err != nil { - srv.Logger.Error("error parsing document ID", - "error", err, - "method", r.Method, - "path", r.URL.Path, - ) - http.Error(w, "Document ID not found", http.StatusNotFound) - return - } + // Validate request. + docID, err := parseResourceIDFromURL(r.URL.Path, "approvals") + if err != nil { + srv.Logger.Error("error parsing document ID", + "error", err, + "method", r.Method, + "path", r.URL.Path, + ) + http.Error(w, "Document ID not found", http.StatusNotFound) + return + } - // Check if document is locked. - locked, err := hcd.IsLocked(docID, srv.DB, srv.GWService, srv.Logger) - if err != nil { - srv.Logger.Error("error checking document locked status", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error getting document status", http.StatusNotFound) - return - } - // Don't continue if document is locked. - if locked { - http.Error(w, "Document is locked", http.StatusLocked) - return - } + // Check if document is locked. + locked, err := hcd.IsLocked(docID, srv.DB, srv.GWService, srv.Logger) + if err != nil { + srv.Logger.Error("error checking document locked status", + "error", err, + "path", r.URL.Path, + "method", r.Method, + "doc_id", docID, + ) + http.Error(w, "Error getting document status", http.StatusNotFound) + return + } + // Don't continue if document is locked. + if locked { + http.Error(w, "Document is locked", http.StatusLocked) + return + } - // Get document from database. - model := models.Document{ + // Get document from database. + model := models.Document{ + GoogleFileID: docID, + } + if err := model.Get(srv.DB); err != nil { + srv.Logger.Error("error getting document from database", + "error", err, + "path", r.URL.Path, + "method", r.Method, + "doc_id", docID, + ) + http.Error(w, "Error accessing document", + http.StatusInternalServerError) + return + } + + // Get reviews for the document. + var reviews models.DocumentReviews + if err := reviews.Find(srv.DB, models.DocumentReview{ + Document: models.Document{ GoogleFileID: docID, - } - if err := model.Get(srv.DB); err != nil { - srv.Logger.Error("error getting document from database", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error accessing document", - http.StatusInternalServerError) - return - } + }, + }); err != nil { + srv.Logger.Error("error getting reviews for document", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + return + } - // Get reviews for the document. - var reviews models.DocumentReviews - if err := reviews.Find(srv.DB, models.DocumentReview{ - Document: models.Document{ - GoogleFileID: docID, - }, - }); err != nil { - srv.Logger.Error("error getting reviews for document", - "error", err, - "method", r.Method, - "path", r.URL.Path, - "doc_id", docID, - ) - return - } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(srv.DB, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: docID, + }, + }); err != nil { + srv.Logger.Error("error getting group reviews for document", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + return + } - // Convert database model to a document. - doc, err := document.NewFromDatabaseModel( - model, reviews) - if err != nil { - srv.Logger.Error("error converting database model to document type", - "error", err, - "method", r.Method, - "path", r.URL.Path, - "doc_id", docID, - ) - http.Error(w, "Error accessing document", - http.StatusInternalServerError) - return - } + // Convert database model to a document. + doc, err := document.NewFromDatabaseModel( + model, reviews, groupReviews) + if err != nil { + srv.Logger.Error("error converting database model to document type", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + http.Error(w, "Error accessing document", + http.StatusInternalServerError) + return + } + userEmail := r.Context().Value("userEmail").(string) + + switch r.Method { + case "DELETE": // Authorize request. - userEmail := r.Context().Value("userEmail").(string) if doc.Status != "In-Review" { http.Error(w, "Can only request changes of documents in the \"In-Review\" status", @@ -311,74 +328,60 @@ func ApprovalsHandler(srv server.Server) http.Handler { } }() - case "POST": - // Validate request. - docID, err := parseResourceIDFromURL(r.URL.Path, "approvals") - if err != nil { - srv.Logger.Error("error parsing document ID from approvals path", - "error", err, - "method", r.Method, - "path", r.URL.Path, - ) - http.Error(w, "Document ID not found", http.StatusNotFound) + case "OPTIONS": + // Document is not in review or approved status. + if doc.Status != "In-Review" && doc.Status != "Approved" { + w.Header().Set("Allowed", "") return } - // Check if document is locked. - locked, err := hcd.IsLocked(docID, srv.DB, srv.GWService, srv.Logger) - if err != nil { - srv.Logger.Error("error checking document locked status", - "error", err, - "path", r.URL.Path, - "method", r.Method, - "doc_id", docID, - ) - http.Error(w, "Error getting document status", http.StatusNotFound) - return - } - // Don't continue if document is locked. - if locked { - http.Error(w, "Document is locked", http.StatusLocked) + // Document already approved by user. + if contains(doc.ApprovedBy, userEmail) { + w.Header().Set("Allowed", "") return } - // Get document from database. - model := models.Document{ - GoogleFileID: docID, - } - if err := model.Get(srv.DB); err != nil { - srv.Logger.Error("error getting document from database", + // User is not an approver or in an approver group. + inApproverGroup, err := isUserInGroups( + userEmail, doc.ApproverGroups, srv.GWService) + if err != nil { + srv.Logger.Error("error calculating if user is in an approver group", "error", err, - "path", r.URL.Path, "method", r.Method, + "path", r.URL.Path, "doc_id", docID, ) http.Error(w, "Error accessing document", http.StatusInternalServerError) return } - - // Get reviews for the document. - var reviews models.DocumentReviews - if err := reviews.Find(srv.DB, models.DocumentReview{ - Document: models.Document{ - GoogleFileID: docID, - }, - }); err != nil { - srv.Logger.Error("error getting reviews for document", - "error", err, - "method", r.Method, - "path", r.URL.Path, - "doc_id", docID, - ) + if !contains(doc.Approvers, userEmail) && !inApproverGroup { + w.Header().Set("Allowed", "") return } - // Convert database model to a document. - doc, err := document.NewFromDatabaseModel( - model, reviews) + // User can approve. + w.Header().Set("Allowed", "POST") + return + + case "POST": + // Authorize request. + if doc.Status != "In-Review" && doc.Status != "Approved" { + http.Error(w, + `Document status must be "In-Review" or "Approved" to approve`, + http.StatusBadRequest) + return + } + if contains(doc.ApprovedBy, userEmail) { + http.Error(w, + "Document already approved by user", + http.StatusBadRequest) + return + } + inApproverGroup, err := isUserInGroups( + userEmail, doc.ApproverGroups, srv.GWService) if err != nil { - srv.Logger.Error("error converting database model to document type", + srv.Logger.Error("error calculating if user is in an approver group", "error", err, "method", r.Method, "path", r.URL.Path, @@ -388,26 +391,33 @@ func ApprovalsHandler(srv server.Server) http.Handler { http.StatusInternalServerError) return } - - // Authorize request. - userEmail := r.Context().Value("userEmail").(string) - if doc.Status != "In-Review" && doc.Status != "Approved" { - http.Error(w, - `Document status must be "In-Review" or "Approved" to approve`, - http.StatusBadRequest) - return - } - if !contains(doc.Approvers, userEmail) { + if !contains(doc.Approvers, userEmail) && !inApproverGroup { http.Error(w, "Not authorized as a document approver", http.StatusUnauthorized) return } - if contains(doc.ApprovedBy, userEmail) { - http.Error(w, - "Document already approved by user", - http.StatusBadRequest) - return + + // If the user is a group approver, they won't be in the approvers list. + if !contains(doc.Approvers, userEmail) { + doc.Approvers = append(doc.Approvers, userEmail) + + // Add approver in database. + model.Approvers = append(model.Approvers, &models.User{ + EmailAddress: userEmail, + }) + if err := model.Upsert(srv.DB); err != nil { + srv.Logger.Error( + "error updating document in the database to add approver", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + http.Error(w, "Error approving document", + http.StatusInternalServerError) + return + } } // Add email to slice of users who have approved the document. @@ -445,7 +455,7 @@ func ApprovalsHandler(srv server.Server) http.Handler { "path", r.URL.Path, "doc_id", docID, "rev_id", latestRev.Id) - http.Error(w, "Error creating review", + http.Error(w, "Error approving document", http.StatusInternalServerError) return } diff --git a/internal/api/v2/documents.go b/internal/api/v2/documents.go index 0fddfc84e..e24db0c05 100644 --- a/internal/api/v2/documents.go +++ b/internal/api/v2/documents.go @@ -21,12 +21,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"` - CustomFields *[]document.CustomField `json:"customFields,omitempty"` - Owners *[]string `json:"owners,omitempty"` - Status *string `json:"status,omitempty"` - Summary *string `json:"summary,omitempty"` + Approvers *[]string `json:"approvers,omitempty"` + ApproverGroups *[]string `json:"approverGroups,omitempty"` + Contributors *[]string `json:"contributors,omitempty"` + CustomFields *[]document.CustomField `json:"customFields,omitempty"` + Owners *[]string `json:"owners,omitempty"` + Status *string `json:"status,omitempty"` + Summary *string `json:"summary,omitempty"` // Tags []string `json:"tags,omitempty"` Title *string `json:"title,omitempty"` } @@ -98,9 +99,25 @@ func DocumentHandler(srv server.Server) http.Handler { return } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(srv.DB, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: docID, + }, + }); err != nil { + srv.Logger.Error("error getting group reviews for document", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + return + } + // Convert database model to a document. doc, err := document.NewFromDatabaseModel( - model, reviews) + model, reviews, groupReviews) if err != nil { srv.Logger.Error("error converting database model to document type", "error", err, @@ -443,12 +460,29 @@ func DocumentHandler(srv server.Server) http.Handler { // request. approversToEmail = compareSlices(doc.Approvers, *req.Approvers) } + if len(doc.ApproverGroups) == 0 && req.ApproverGroups != nil && + len(*req.ApproverGroups) != 0 { + // If there are no approver groups for the document, add all approver + // groups in the request. + approversToEmail = append(approversToEmail, *req.ApproverGroups...) + } else if req.ApproverGroups != nil && len(*req.ApproverGroups) != 0 { + // Only compare when there are stored approver groups and approver + // groups in the request. + approversToEmail = append( + approversToEmail, + compareSlices(doc.ApproverGroups, *req.ApproverGroups)..., + ) + } // Patch document (for Algolia). // Approvers. if req.Approvers != nil { doc.Approvers = *req.Approvers } + // Approver groups. + if req.ApproverGroups != nil { + doc.ApproverGroups = *req.ApproverGroups + } // Contributors. if req.Contributors != nil { doc.Contributors = *req.Contributors @@ -625,6 +659,18 @@ func DocumentHandler(srv server.Server) http.Handler { model.Approvers = approvers } + // Approver groups. + if req.ApproverGroups != nil { + approverGroups := make([]*models.Group, len(doc.ApproverGroups)) + for i, a := range doc.ApproverGroups { + g := models.Group{ + EmailAddress: a, + } + approverGroups[i] = &g + } + model.ApproverGroups = approverGroups + } + // Contributors. if req.Contributors != nil { var contributors []*models.User diff --git a/internal/api/v2/drafts.go b/internal/api/v2/drafts.go index fc1f54d3e..296c717bb 100644 --- a/internal/api/v2/drafts.go +++ b/internal/api/v2/drafts.go @@ -27,7 +27,6 @@ import ( ) type DraftsRequest struct { - Approvers []string `json:"approvers,omitempty"` Contributors []string `json:"contributors,omitempty"` DocType string `json:"docType,omitempty"` Product string `json:"product,omitempty"` @@ -40,12 +39,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"` - CustomFields *[]document.CustomField `json:"customFields,omitempty"` - Owners *[]string `json:"owners,omitempty"` - Product *string `json:"product,omitempty"` - Summary *string `json:"summary,omitempty"` + Approvers *[]string `json:"approvers,omitempty"` + ApproverGroups *[]string `json:"approverGroups,omitempty"` + Contributors *[]string `json:"contributors,omitempty"` + CustomFields *[]document.CustomField `json:"customFields,omitempty"` + Owners *[]string `json:"owners,omitempty"` + Product *string `json:"product,omitempty"` + Summary *string `json:"summary,omitempty"` // Tags []string `json:"tags,omitempty"` Title *string `json:"title,omitempty"` } @@ -297,12 +297,6 @@ func DraftsHandler(srv server.Server) http.Handler { } // Create document in the database. - var approvers []*models.User - for _, c := range req.Approvers { - approvers = append(approvers, &models.User{ - EmailAddress: c, - }) - } var contributors []*models.User for _, c := range req.Contributors { contributors = append(contributors, &models.User{ @@ -323,7 +317,6 @@ func DraftsHandler(srv server.Server) http.Handler { } model := models.Document{ GoogleFileID: f.Id, - Approvers: approvers, Contributors: contributors, DocumentCreatedAt: createdTime, DocumentModifiedAt: createdTime, @@ -668,9 +661,25 @@ func DraftsDocumentHandler(srv server.Server) http.Handler { return } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(srv.DB, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: docID, + }, + }); err != nil { + srv.Logger.Error("error getting group reviews for document", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + return + } + // Convert database model to a document. doc, err := document.NewFromDatabaseModel( - model, reviews) + model, reviews, groupReviews) if err != nil { srv.Logger.Error("error converting database model to document type", "error", err, @@ -1169,6 +1178,20 @@ func DraftsDocumentHandler(srv server.Server) http.Handler { model.Approvers = approvers } + // Approver groups. + if req.ApproverGroups != nil { + doc.ApproverGroups = *req.ApproverGroups + + approverGroups := make([]*models.Group, len(doc.ApproverGroups)) + for i, a := range doc.ApproverGroups { + g := models.Group{ + EmailAddress: a, + } + approverGroups[i] = &g + } + model.ApproverGroups = approverGroups + } + // Contributors. if req.Contributors != nil { doc.Contributors = *req.Contributors diff --git a/internal/api/v2/groups.go b/internal/api/v2/groups.go new file mode 100644 index 000000000..1d36c6d74 --- /dev/null +++ b/internal/api/v2/groups.go @@ -0,0 +1,168 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp-forge/hermes/internal/server" + admin "google.golang.org/api/admin/directory/v1" +) + +const ( + // maxGroupResults is the maximum total number of group results to return. + maxGroupResults = 20 + + // maxPrefixGroupResults is the maximum number of group results to return that + // use the groups prefix, if configured. + maxPrefixGroupResults = 10 +) + +type GroupsPostRequest struct { + Query string `json:"query,omitempty"` +} + +type GroupsPostResponse []GroupsPostResponseGroup + +type GroupsPostResponseGroup struct { + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` +} + +// GroupsHandler returns information about Google Groups. +func GroupsHandler(srv server.Server) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logArgs := []any{ + "method", r.Method, + "path", r.URL.Path, + } + + // Authorize request. + userEmail := r.Context().Value("userEmail").(string) + if userEmail == "" { + srv.Logger.Error("user email not found in request context", logArgs...) + http.Error( + w, "No authorization information in request", http.StatusUnauthorized) + return + } + + switch r.Method { + case "POST": + // Decode request. + req := &GroupsPostRequest{} + if err := decodeRequest(r, &req); err != nil { + srv.Logger.Warn("error decoding request", + append([]interface{}{ + "error", err, + }, logArgs...)...) + http.Error(w, fmt.Sprintf("Bad request: %q", err), + http.StatusBadRequest) + return + } + + // Sanitize query. + query := req.Query + query = strings.ReplaceAll(query, " ", "-") + + var ( + allGroups []*admin.Group + err error + groups, prefixGroups *admin.Groups + maxNonPrefixGroups = maxGroupResults + ) + + // Retrieve groups with prefix, if configured. + if srv.Config.GoogleWorkspace.GroupsPrefix != "" { + maxNonPrefixGroups = maxGroupResults - maxPrefixGroupResults + + prefixQuery := fmt.Sprintf( + "%s%s", srv.Config.GoogleWorkspace.GroupsPrefix, query) + prefixGroups, err = srv.GWService.AdminDirectory.Groups.List(). + Domain(srv.Config.GoogleWorkspace.Domain). + MaxResults(maxPrefixGroupResults). + Query(fmt.Sprintf("email:%s*", prefixQuery)). + Do() + if err != nil { + srv.Logger.Error("error searching groups with prefix", + append([]interface{}{ + "error", err, + }, logArgs...)...) + http.Error(w, fmt.Sprintf("Error searching groups: %q", err), + http.StatusInternalServerError) + return + } + } + + // Retrieve groups without prefix. + groups, err = srv.GWService.AdminDirectory.Groups.List(). + Domain(srv.Config.GoogleWorkspace.Domain). + MaxResults(int64(maxNonPrefixGroups)). + Query(fmt.Sprintf("email:%s*", query)). + Do() + if err != nil { + srv.Logger.Error("error searching groups without prefix", + append([]interface{}{ + "error", err, + }, logArgs...)...) + http.Error(w, fmt.Sprintf("Error searching groups: %q", err), + http.StatusInternalServerError) + return + } + + allGroups = concatGroupSlicesAndRemoveDuplicates( + prefixGroups.Groups, groups.Groups) + + // Build response, stripping all attributes except email and name. + resp := make(GroupsPostResponse, len(allGroups)) + for i, group := range allGroups { + resp[i] = GroupsPostResponseGroup{ + Email: group.Email, + Name: group.Name, + } + } + + // Write response. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + enc := json.NewEncoder(w) + err = enc.Encode(resp) + if err != nil { + srv.Logger.Error("error encoding groups response", + append([]interface{}{ + "error", err, + }, logArgs...)...) + http.Error(w, "Error searching groups", + http.StatusInternalServerError) + return + } + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + }) +} + +// concatGroupSlicesAndRemoveDuplicates concatenates two group slices and +// removes any duplicate elements from the result. +func concatGroupSlicesAndRemoveDuplicates( + slice1, slice2 []*admin.Group) []*admin.Group { + uniqueMap := make(map[string]*admin.Group) + result := []*admin.Group{} + + // Add elements from both slices to the map. + for _, g := range slice1 { + uniqueMap[g.Email] = g + } + for _, g := range slice2 { + uniqueMap[g.Email] = g + } + + // Add all unique elements from the map to the result slice. + for _, v := range uniqueMap { + result = append(result, v) + } + + return result +} diff --git a/internal/api/v2/helpers.go b/internal/api/v2/helpers.go index 2de4bc28f..710bb2b58 100644 --- a/internal/api/v2/helpers.go +++ b/internal/api/v2/helpers.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/hashicorp-forge/hermes/internal/config" + gw "github.com/hashicorp-forge/hermes/pkg/googleworkspace" "github.com/hashicorp-forge/hermes/pkg/models" "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-multierror" @@ -530,6 +531,27 @@ func CompareAlgoliaAndDatabaseDocument( return result.ErrorOrNil() } +// isUserInGroups returns true if a user is in any supplied groups, false +// otherwise. +func isUserInGroups( + userEmail string, groupEmails []string, svc *gw.Service) (bool, error) { + // Get groups for user. + userGroups, err := svc.AdminDirectory.Groups.List(). + UserKey(userEmail). + Do() + if err != nil { + return false, fmt.Errorf("error getting groups for user: %w", err) + } + + for _, g := range userGroups.Groups { + if contains(groupEmails, g.Email) { + return true, nil + } + } + + return false, nil +} + func getBooleanValue(in map[string]any, key string) (bool, error) { var result bool diff --git a/internal/api/v2/projects_related_resources.go b/internal/api/v2/projects_related_resources.go index ec6375b52..d4b666340 100644 --- a/internal/api/v2/projects_related_resources.go +++ b/internal/api/v2/projects_related_resources.go @@ -113,7 +113,7 @@ func projectsResourceRelatedResourcesHandler( // Convert database model to a document. We don't need document review // data for this endpoint. doc, err := document.NewFromDatabaseModel( - hdrr.Document, models.DocumentReviews{}) + hdrr.Document, models.DocumentReviews{}, models.DocumentGroupReviews{}) if err != nil { srv.Logger.Error("error converting database model to document type", append([]interface{}{ diff --git a/internal/api/v2/reviews.go b/internal/api/v2/reviews.go index 265b2ea2f..aac28f7f7 100644 --- a/internal/api/v2/reviews.go +++ b/internal/api/v2/reviews.go @@ -101,9 +101,25 @@ func ReviewsHandler(srv server.Server) http.Handler { return } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(srv.DB, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: docID, + }, + }); err != nil { + srv.Logger.Error("error getting group reviews for document", + "error", err, + "method", r.Method, + "path", r.URL.Path, + "doc_id", docID, + ) + return + } + // Convert database model to a document. doc, err := document.NewFromDatabaseModel( - model, reviews) + model, reviews, groupReviews) if err != nil { srv.Logger.Error("error converting database model to document type", "error", err, @@ -487,8 +503,12 @@ func ReviewsHandler(srv server.Server) http.Handler { return } - // Give document approvers edit access to the document. - for _, a := range doc.Approvers { + // Create slice of all approvers consisting of individuals and groups. + allApprovers := append(doc.Approvers, doc.ApproverGroups...) + + // Give document approvers and approver groups edit access to the + // document. + for _, a := range allApprovers { if err := srv.GWService.ShareFile(docID, a, "writer"); err != nil { srv.Logger.Error("error sharing file with approver", "error", err, @@ -533,10 +553,10 @@ func ReviewsHandler(srv server.Server) http.Handler { // Send emails to approvers, if enabled. if srv.Config.Email != nil && srv.Config.Email.Enabled { - if len(doc.Approvers) > 0 { + if len(allApprovers) > 0 { // TODO: use an asynchronous method for sending emails because we // can't currently recover gracefully from a failure here. - for _, approverEmail := range doc.Approvers { + for _, approverEmail := range allApprovers { err := email.SendReviewRequestedEmail( email.ReviewRequestedEmailData{ BaseURL: srv.Config.BaseURL, diff --git a/internal/cmd/commands/server/server.go b/internal/cmd/commands/server/server.go index 6153f804d..8f964565a 100644 --- a/internal/cmd/commands/server/server.go +++ b/internal/cmd/commands/server/server.go @@ -376,6 +376,7 @@ func (c *Command) Run(args []string) int { {"/api/v2/documents/", apiv2.DocumentHandler(srv)}, {"/api/v2/drafts", apiv2.DraftsHandler(srv)}, {"/api/v2/drafts/", apiv2.DraftsDocumentHandler(srv)}, + {"/api/v2/groups", apiv2.GroupsHandler(srv)}, {"/api/v2/jira/issues/", apiv2.JiraIssueHandler(srv)}, {"/api/v2/jira/issue/picker", apiv2.JiraIssuePickerHandler(srv)}, {"/api/v2/me", apiv2.MeHandler(srv)}, diff --git a/internal/config/config.go b/internal/config/config.go index 199248443..38afabd5b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -219,6 +219,9 @@ type GoogleWorkspace struct { // DraftsFolder is the folder that contains all document drafts. DraftsFolder string `hcl:"drafts_folder"` + // GroupsPrefix is the prefix to use when searching for Google Groups. + GroupsPrefix string `hcl:"groups_prefix,optional"` + // OAuth2 is the configuration to use OAuth 2.0 to access Google Workspace // APIs. OAuth2 *GoogleWorkspaceOAuth2 `hcl:"oauth2,block"` diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 9d5ff1059..b77478717 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -432,6 +432,20 @@ func (idx *Indexer) Run() error { os.Exit(1) } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(idx.Database, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: file.Id, + }, + }); err != nil { + log.Error("error getting group reviews for document", + "error", err, + "google_file_id", file.Id, + ) + os.Exit(1) + } + // Parse document modified time. modifiedTime, err := time.Parse(time.RFC3339Nano, file.ModifiedTime) if err != nil { @@ -451,7 +465,7 @@ func (idx *Indexer) Run() error { var doc *document.Document if idx.UseDatabaseForDocumentData { // Convert database record to a document. - doc, err = document.NewFromDatabaseModel(dbDoc, reviews) + doc, err = document.NewFromDatabaseModel(dbDoc, reviews, groupReviews) if err != nil { log.Error("error converting database record to document", "error", err, diff --git a/internal/indexer/refresh_headers.go b/internal/indexer/refresh_headers.go index a8939bade..4b15a1ccb 100644 --- a/internal/indexer/refresh_headers.go +++ b/internal/indexer/refresh_headers.go @@ -201,9 +201,23 @@ func refreshDocumentHeader( os.Exit(1) } + // Get group reviews for the document. + var groupReviews models.DocumentGroupReviews + if err := groupReviews.Find(idx.Database, models.DocumentGroupReview{ + Document: models.Document{ + GoogleFileID: file.Id, + }, + }); err != nil { + log.Error("error getting group reviews for document", + "error", err, + "google_file_id", file.Id, + ) + os.Exit(1) + } + // Convert database record to a document. doc, err = document.NewFromDatabaseModel( - model, reviews) + model, reviews, groupReviews) if err != nil { log.Error("error converting database record to document", "error", err, diff --git a/pkg/document/document.go b/pkg/document/document.go index f4cbd9c0b..9fec4f793 100644 --- a/pkg/document/document.go +++ b/pkg/document/document.go @@ -44,6 +44,10 @@ type Document struct { // are requested for the document. Approvers []string `json:"approvers,omitempty"` + // ApproverGroups is a slice of email address strings for groups whose + // approvals are requested for the document. + ApproverGroups []string `json:"approverGroups,omitempty"` + // ChangesRequestedBy is a slice of email address strings for users that have // requested changes for the document. ChangesRequestedBy []string `json:"changesRequestedBy,omitempty"` @@ -234,7 +238,9 @@ func NewFromAlgoliaObject( // NewFromDatabaseModel creates a document from a document database model. func NewFromDatabaseModel( - model models.Document, reviews models.DocumentReviews, + model models.Document, + reviews models.DocumentReviews, + groupReviews models.DocumentGroupReviews, ) (*Document, error) { doc := &Document{} @@ -273,6 +279,13 @@ func NewFromDatabaseModel( doc.Approvers = approvers doc.ChangesRequestedBy = changesRequestedBy + // ApproverGroups. + var approverGroups []string + for _, r := range groupReviews { + approverGroups = append(approverGroups, r.Group.EmailAddress) + } + doc.ApproverGroups = approverGroups + // Contributors. contributors := []string{} for _, c := range model.Contributors { @@ -601,6 +614,20 @@ func (d Document) ToDatabaseModels( } doc.Approvers = approvers + // Approver groups. + var approverGroups []*models.Group + for _, a := range d.ApproverGroups { + g := models.Group{ + EmailAddress: a, + } + // Validate email address. + if _, err := mail.ParseAddress(g.EmailAddress); err != nil { + continue + } + approverGroups = append(approverGroups, &g) + } + doc.ApproverGroups = approverGroups + return doc, reviews, nil } diff --git a/pkg/document/replace_header.go b/pkg/document/replace_header.go index e3ea6430b..bb51069af 100644 --- a/pkg/document/replace_header.go +++ b/pkg/document/replace_header.go @@ -575,7 +575,8 @@ func (doc *Document) ReplaceHeader( // Approvers cell. // Build approvers slice with a check next to reviewers who have approved. - var approvers []string + // Approver groups are listed first. + approvers := doc.ApproverGroups for _, approver := range doc.Approvers { if helpers.StringSliceContains(doc.ApprovedBy, approver) { approvers = append(approvers, "✅ "+approver) diff --git a/pkg/googleworkspace/service.go b/pkg/googleworkspace/service.go index 4275f94af..f8e82c7f8 100644 --- a/pkg/googleworkspace/service.go +++ b/pkg/googleworkspace/service.go @@ -14,6 +14,7 @@ import ( "golang.org/x/oauth2/jwt" "github.com/pkg/browser" + directory "google.golang.org/api/admin/directory/v1" "google.golang.org/api/docs/v1" "google.golang.org/api/drive/v3" "google.golang.org/api/gmail/v1" @@ -24,11 +25,12 @@ import ( // Service provides access to the Google Workspace API. type Service struct { - Docs *docs.Service - Drive *drive.Service - Gmail *gmail.Service - OAuth2 *oauth2api.Service - People *people.PeopleService + AdminDirectory *directory.Service + Docs *docs.Service + Drive *drive.Service + Gmail *gmail.Service + OAuth2 *oauth2api.Service + People *people.PeopleService } // Config is the configuration for interacting with Google Workspace using a @@ -50,6 +52,7 @@ func NewFromConfig(cfg *Config) *Service { Email: cfg.ClientEmail, PrivateKey: []byte(cfg.PrivateKey), Scopes: []string{ + "https://www.googleapis.com/auth/admin.directory.group.readonly", "https://www.googleapis.com/auth/directory.readonly", "https://www.googleapis.com/auth/documents", "https://www.googleapis.com/auth/drive", @@ -60,6 +63,10 @@ func NewFromConfig(cfg *Config) *Service { } client := conf.Client(context.TODO()) + adminDirectorySrv, err := directory.NewService(context.TODO(), option.WithHTTPClient(client)) + if err != nil { + log.Fatalf("Unable to retrieve Admin Directory client: %v", err) + } docSrv, err := docs.NewService(context.TODO(), option.WithHTTPClient(client)) if err != nil { log.Fatalf("Unable to retrieve Docs client: %v", err) @@ -83,11 +90,12 @@ func NewFromConfig(cfg *Config) *Service { peoplePeopleSrv := people.NewPeopleService(peopleSrv) return &Service{ - Docs: docSrv, - Drive: driveSrv, - Gmail: gmailSrv, - OAuth2: oAuth2Srv, - People: peoplePeopleSrv, + AdminDirectory: adminDirectorySrv, + Docs: docSrv, + Drive: driveSrv, + Gmail: gmailSrv, + OAuth2: oAuth2Srv, + People: peoplePeopleSrv, } } @@ -105,6 +113,7 @@ func New() *Service { // If modifying these scopes, delete your previously saved token.json. gc, err := google.ConfigFromJSON(b, + "https://www.googleapis.com/auth/admin.directory.group.readonly", "https://www.googleapis.com/auth/directory.readonly", "https://www.googleapis.com/auth/documents", "https://www.googleapis.com/auth/drive", @@ -114,6 +123,10 @@ func New() *Service { } client := getClient(gc) + adminDirectorySrv, err := directory.NewService(context.TODO(), option.WithHTTPClient(client)) + if err != nil { + log.Fatalf("Unable to retrieve Admin Directory client: %v", err) + } docSrv, err := docs.NewService(context.TODO(), option.WithHTTPClient(client)) if err != nil { log.Fatalf("Unable to retrieve Google Docs client: %v", err) @@ -137,11 +150,12 @@ func New() *Service { peoplePeopleSrv := people.NewPeopleService(peopleSrv) return &Service{ - Docs: docSrv, - Drive: driveSrv, - Gmail: gmailSrv, - OAuth2: oAuth2Srv, - People: peoplePeopleSrv, + AdminDirectory: adminDirectorySrv, + Docs: docSrv, + Drive: driveSrv, + Gmail: gmailSrv, + OAuth2: oAuth2Srv, + People: peoplePeopleSrv, } } diff --git a/pkg/models/document.go b/pkg/models/document.go index a61155309..13af6a753 100644 --- a/pkg/models/document.go +++ b/pkg/models/document.go @@ -21,6 +21,10 @@ type Document struct { // document. Approvers []*User `gorm:"many2many:document_reviews;"` + // ApproverGroups is the list of groups whose approval is requested for the + // document. + ApproverGroups []*Group `gorm:"many2many:document_group_reviews;"` + // Contributors are users who have contributed to the document. Contributors []*User `gorm:"many2many:document_contributors;"` @@ -527,6 +531,16 @@ func (d *Document) createAssocations(db *gorm.DB) error { } d.Approvers = approvers + // Find or create approver groups. + var approverGroups []*Group + for _, a := range d.ApproverGroups { + if err := a.FirstOrCreate(db); err != nil { + return fmt.Errorf("error finding or creating approver groups: %w", err) + } + approverGroups = append(approverGroups, a) + } + d.ApproverGroups = approverGroups + // Find or create contributors. var contributors []*User for _, c := range d.Contributors { @@ -576,6 +590,16 @@ func (d *Document) getAssociations(db *gorm.DB) error { } d.Approvers = approvers + // Get approver groups. + var approverGroups []*Group + for _, a := range d.ApproverGroups { + if err := a.Get(db); err != nil { + return fmt.Errorf("error getting approver group: %w", err) + } + approverGroups = append(approverGroups, a) + } + d.ApproverGroups = approverGroups + // Get contributors. var contributors []*User for _, c := range d.Contributors { @@ -653,6 +677,16 @@ func (d *Document) replaceAssocations(db *gorm.DB) error { return err } + // Replace approver groups. + if err := db. + Session(&gorm.Session{SkipHooks: true}). + Model(&d). + Unscoped(). + Association("ApproverGroups"). + Replace(d.ApproverGroups); err != nil { + return err + } + // Replace contributors. if err := db. Session(&gorm.Session{SkipHooks: true}). diff --git a/pkg/models/document_group_review.go b/pkg/models/document_group_review.go new file mode 100644 index 000000000..9b3582307 --- /dev/null +++ b/pkg/models/document_group_review.go @@ -0,0 +1,159 @@ +package models + +import ( + "fmt" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type DocumentGroupReview struct { + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + + DocumentID uint `gorm:"primaryKey"` + Document Document + GroupID uint `gorm:"primaryKey"` + Group Group +} + +// DocumentReviews is a slice of document reviews. +type DocumentGroupReviews []DocumentGroupReview + +// BeforeSave is a hook to find or create associations before saving. +func (d *DocumentGroupReview) BeforeSave(tx *gorm.DB) error { + // Validate required fields. + if err := validation.ValidateStruct(&d.Document, + validation.Field( + &d.Document.GoogleFileID, validation.Required), + ); err != nil { + return err + } + if err := validation.ValidateStruct(&d.Group, + validation.Field( + &d.Group.EmailAddress, validation.Required), + ); err != nil { + return err + } + + if err := d.getAssociations(tx); err != nil { + return fmt.Errorf("error getting associations: %w", err) + } + + return nil +} + +// Find finds all document group reviews with the provided query, and assigns +// them to the receiver. +func (d *DocumentGroupReviews) Find(db *gorm.DB, dr DocumentGroupReview) error { + // Validate required fields. + if err := validation.ValidateStruct(&dr.Document, + validation.Field( + &dr.Document.GoogleFileID, + validation.When(dr.Group.EmailAddress == "", + validation.Required.Error( + "at least a Document's GoogleFileID or Group's EmailAddress is required"), + ), + ), + ); err != nil { + return err + } + if err := validation.ValidateStruct(&dr.Group, + validation.Field( + &dr.Group.EmailAddress, + validation.When(dr.Document.GoogleFileID == "", + validation.Required.Error( + "at least a Document's GoogleFileID or Group's EmailAddress is required"), + ), + ), + ); err != nil { + return err + } + + // Get document. + if dr.Document.GoogleFileID != "" { + if err := dr.Document.Get(db); err != nil { + return fmt.Errorf("error getting document: %w", err) + } + dr.DocumentID = dr.Document.ID + } + + // Get group. + if dr.Group.EmailAddress != "" { + if err := dr.Group.Get(db); err != nil { + return fmt.Errorf("error getting group: %w", err) + } + dr.GroupID = dr.Group.ID + } + + return db. + Where(DocumentGroupReview{ + DocumentID: dr.DocumentID, + GroupID: dr.GroupID, + }). + Preload(clause.Associations). + Find(&d). + Error +} + +// Get gets the document group review from database db, and assigns it to the +// receiver. +func (d *DocumentGroupReview) Get(db *gorm.DB) error { + // Validate required fields. + if err := validation.ValidateStruct(&d.Document, + validation.Field(&d.Document.GoogleFileID, validation.Required), + ); err != nil { + return err + } + if err := validation.ValidateStruct(&d.Group, + validation.Field(&d.Group.EmailAddress, validation.Required), + ); err != nil { + return err + } + + if err := d.getAssociations(db); err != nil { + return fmt.Errorf("error getting associations: %w", err) + } + + return db. + Where(DocumentGroupReview{ + DocumentID: d.DocumentID, + GroupID: d.GroupID, + }). + Preload(clause.Associations). + First(&d). + Error +} + +// Update updates the document review in database db. +func (d *DocumentGroupReview) Update(db *gorm.DB) error { + if err := d.getAssociations(db); err != nil { + return fmt.Errorf("error getting associations: %w", err) + } + + return db. + Model(&d). + Omit(clause.Associations). + Updates(*d). + Error +} + +// getAssociations gets associations. +func (d *DocumentGroupReview) getAssociations(db *gorm.DB) error { + // Get document. + if err := d.Document.Get(db); err != nil { + return fmt.Errorf("error getting document: %w", err) + } + d.DocumentID = d.Document.ID + + // Get group. + if err := d.Group.Get(db); err != nil { + return fmt.Errorf("error getting group: %w", err) + } + d.GroupID = d.Group.ID + + return nil +} diff --git a/pkg/models/document_group_review_test.go b/pkg/models/document_group_review_test.go new file mode 100644 index 000000000..b9a702474 --- /dev/null +++ b/pkg/models/document_group_review_test.go @@ -0,0 +1,227 @@ +package models + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDocumentGroupReviewModel(t *testing.T) { + dsn := os.Getenv("HERMES_TEST_POSTGRESQL_DSN") + if dsn == "" { + t.Skip("HERMES_TEST_POSTGRESQL_DSN environment variable isn't set") + } + + t.Run("Create and Get", func(t *testing.T) { + db, tearDownTest := setupTest(t, dsn) + defer tearDownTest(t) + + t.Run("Create a document type", func(t *testing.T) { + _, require := assert.New(t), require.New(t) + dt := DocumentType{ + Name: "DT1", + LongName: "DocumentType1", + } + err := dt.FirstOrCreate(db) + require.NoError(err) + }) + + t.Run("Create a product", func(t *testing.T) { + _, require := assert.New(t), require.New(t) + p := Product{ + Name: "Product1", + Abbreviation: "P1", + } + err := p.FirstOrCreate(db) + require.NoError(err) + }) + + t.Run("Get the review before we create the document", func(t *testing.T) { + _, require := assert.New(t), require.New(t) + dr := DocumentGroupReview{ + Document: Document{ + GoogleFileID: "fileID1", + }, + Group: Group{ + EmailAddress: "team-a@approver.com", + }, + } + err := dr.Get(db) + require.Error(err) + }) + + var d Document + t.Run("Create a document", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + d = Document{ + GoogleFileID: "fileID1", + ApproverGroups: []*Group{ + { + EmailAddress: "team-a@approver.com", + }, + { + EmailAddress: "team-b@approver.com", + }, + }, + DocumentType: DocumentType{ + Name: "DT1", + }, + Product: Product{ + Name: "Product1", + }, + } + err := d.Create(db) + require.NoError(err) + assert.EqualValues(1, d.ID) + }) + + t.Run("Get the review", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + dr := DocumentGroupReview{ + Document: Document{ + GoogleFileID: "fileID1", + }, + Group: Group{ + EmailAddress: "team-b@approver.com", + }, + } + err := dr.Get(db) + require.NoError(err) + assert.EqualValues(1, dr.DocumentID) + assert.Equal("fileID1", dr.Document.GoogleFileID) + assert.EqualValues(2, dr.GroupID) + assert.Equal("team-b@approver.com", dr.Group.EmailAddress) + }) + }) + + t.Run("Find", func(t *testing.T) { + db, tearDownTest := setupTest(t, dsn) + defer tearDownTest(t) + + t.Run("Create a document type", func(t *testing.T) { + _, require := assert.New(t), require.New(t) + dt := DocumentType{ + Name: "DT1", + LongName: "DocumentType1", + } + err := dt.FirstOrCreate(db) + require.NoError(err) + }) + + t.Run("Create a product", func(t *testing.T) { + _, require := assert.New(t), require.New(t) + p := Product{ + Name: "Product1", + Abbreviation: "P1", + } + err := p.FirstOrCreate(db) + require.NoError(err) + }) + + var d1, d2, d3 Document + t.Run("Create first document", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + d1 = Document{ + GoogleFileID: "fileID1", + ApproverGroups: []*Group{ + { + EmailAddress: "team-a@approver.com", + }, + { + EmailAddress: "team-b@approver.com", + }, + }, + DocumentType: DocumentType{ + Name: "DT1", + }, + Product: Product{ + Name: "Product1", + }, + } + err := d1.Create(db) + require.NoError(err) + assert.EqualValues(1, d1.ID) + }) + + t.Run("Create second document", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + d2 = Document{ + GoogleFileID: "fileID2", + ApproverGroups: []*Group{ + { + EmailAddress: "team-a@approver.com", + }, + }, + DocumentType: DocumentType{ + Name: "DT1", + }, + Product: Product{ + Name: "Product1", + }, + } + err := d2.Create(db) + require.NoError(err) + assert.EqualValues(2, d2.ID) + }) + + t.Run("Create third document", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + d3 = Document{ + GoogleFileID: "fileID3", + ApproverGroups: []*Group{ + { + EmailAddress: "team-b@approver.com", + }, + }, + DocumentType: DocumentType{ + Name: "DT1", + }, + Product: Product{ + Name: "Product1", + }, + } + err := d3.Create(db) + require.NoError(err) + assert.EqualValues(3, d3.ID) + }) + + t.Run("Find reviews without any search fields", func(t *testing.T) { + _, require := assert.New(t), require.New(t) + var revs DocumentGroupReviews + err := revs.Find(db, DocumentGroupReview{}) + require.Error(err) + }) + + t.Run("Find all reviews for a document", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + var revs DocumentGroupReviews + err := revs.Find(db, DocumentGroupReview{ + Document: Document{ + GoogleFileID: "fileID1", + }, + }) + require.NoError(err) + require.Len(revs, 2) + assert.Equal("team-a@approver.com", revs[0].Group.EmailAddress) + assert.Equal("team-b@approver.com", revs[1].Group.EmailAddress) + }) + + t.Run("Find all reviews for a group", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + var revs DocumentGroupReviews + err := revs.Find(db, DocumentGroupReview{ + Group: Group{ + EmailAddress: "team-b@approver.com", + }, + }) + require.NoError(err) + require.Len(revs, 2) + assert.Equal("fileID1", revs[0].Document.GoogleFileID) + assert.Equal("fileID3", revs[1].Document.GoogleFileID) + assert.Equal("team-b@approver.com", revs[0].Group.EmailAddress) + assert.Equal("team-b@approver.com", revs[1].Group.EmailAddress) + }) + }) +} diff --git a/pkg/models/gorm.go b/pkg/models/gorm.go index c65e67a0f..9de28da49 100644 --- a/pkg/models/gorm.go +++ b/pkg/models/gorm.go @@ -6,11 +6,13 @@ func ModelsToAutoMigrate() []interface{} { &Document{}, &DocumentCustomField{}, &DocumentFileRevision{}, + DocumentGroupReview{}, &DocumentRelatedResource{}, &DocumentRelatedResourceExternalLink{}, &DocumentRelatedResourceHermesDocument{}, &DocumentReview{}, &DocumentTypeCustomField{}, + &Group{}, &IndexerFolder{}, &IndexerMetadata{}, &Product{}, diff --git a/pkg/models/group.go b/pkg/models/group.go new file mode 100644 index 000000000..ea5eb5ec3 --- /dev/null +++ b/pkg/models/group.go @@ -0,0 +1,65 @@ +package models + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Group is a model for an application group. +type Group struct { + gorm.Model + + // EmailAddress is the email address of the group. + EmailAddress string `gorm:"default:null;index;not null;type:citext;unique"` +} + +// FirstOrCreate finds the first group by email address or creates a group +// record if it does not exist in database db. The result is saved back to the +// receiver. +func (g *Group) FirstOrCreate(db *gorm.DB) error { + if err := validation.ValidateStruct(g, + validation.Field( + &g.EmailAddress, validation.Required), + ); err != nil { + return err + } + + return db.Transaction(func(tx *gorm.DB) error { + if err := tx. + Where(Group{EmailAddress: g.EmailAddress}). + Omit(clause.Associations). + Clauses(clause.OnConflict{DoNothing: true}). + FirstOrCreate(&g). + Error; err != nil { + return err + } + + return nil + }) +} + +// Get gets a group from database db by email address, and assigns it to the +// receiver. +func (g *Group) Get(db *gorm.DB) error { + return db. + Where(Group{EmailAddress: g.EmailAddress}). + Preload(clause.Associations). + First(&g).Error +} + +// Upsert updates or inserts the receiver group into database db. +func (g *Group) Upsert(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx. + Where(Group{EmailAddress: g.EmailAddress}). + Omit(clause.Associations). + Assign(*g). + FirstOrCreate(&g). + Error; err != nil { + return err + } + + return nil + }) +} diff --git a/pkg/models/group_test.go b/pkg/models/group_test.go new file mode 100644 index 000000000..379827dab --- /dev/null +++ b/pkg/models/group_test.go @@ -0,0 +1,65 @@ +package models + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGroupModel(t *testing.T) { + dsn := os.Getenv("HERMES_TEST_POSTGRESQL_DSN") + if dsn == "" { + t.Skip("HERMES_TEST_POSTGRESQL_DSN environment variable isn't set") + } + + t.Run("FirstOrCreate", func(t *testing.T) { + db, tearDownTest := setupTest(t, dsn) + defer tearDownTest(t) + + t.Run("Create first group", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + u := Group{ + EmailAddress: "a@a.com", + } + err := u.FirstOrCreate(db) + require.NoError(err) + assert.EqualValues(1, u.ID) + assert.Equal("a@a.com", u.EmailAddress) + }) + + t.Run("Get first group using FirstOrCreate", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + u := Group{ + EmailAddress: "a@a.com", + } + err := u.FirstOrCreate(db) + require.NoError(err) + assert.EqualValues(1, u.ID) + assert.Equal("a@a.com", u.EmailAddress) + }) + + t.Run("Create second group", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + u := Group{ + EmailAddress: "b@b.com", + } + err := u.FirstOrCreate(db) + require.NoError(err) + assert.EqualValues(2, u.ID) + assert.Equal("b@b.com", u.EmailAddress) + }) + + t.Run("Get second group using FirstOrCreate", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + u := Group{ + EmailAddress: "b@b.com", + } + err := u.FirstOrCreate(db) + require.NoError(err) + assert.EqualValues(2, u.ID) + assert.Equal("b@b.com", u.EmailAddress) + }) + }) +}