diff --git a/api/compose/spec.json b/api/compose/spec.json index 64f26f8ac..c8b7d49cb 100644 --- a/api/compose/spec.json +++ b/api/compose/spec.json @@ -972,6 +972,28 @@ ] } }, + { + "name": "bulkDelete", + "method": "DELETE", + "title": "Delete record row from module section", + "path": "/", + "parameters": { + "post": [ + { + "type": "[]string", + "name": "recordIDs", + "required": false, + "title": "IDs of records to delete" + }, + { + "type": "bool", + "name": "truncate", + "required": false, + "title": "Remove ALL records of a specified module (pending implementation)" + } + ] + } + }, { "name": "delete", "method": "DELETE", diff --git a/api/compose/spec/record.json b/api/compose/spec/record.json index 32256fd28..abace7375 100644 --- a/api/compose/spec/record.json +++ b/api/compose/spec/record.json @@ -269,6 +269,28 @@ ] } }, + { + "Name": "bulkDelete", + "Method": "DELETE", + "Title": "Delete record row from module section", + "Path": "/", + "Parameters": { + "post": [ + { + "name": "recordIDs", + "required": false, + "title": "IDs of records to delete", + "type": "[]string" + }, + { + "name": "truncate", + "required": false, + "title": "Remove ALL records of a specified module (pending implementation)", + "type": "bool" + } + ] + } + }, { "Name": "delete", "Method": "DELETE", diff --git a/compose/rest/handlers/record.go b/compose/rest/handlers/record.go index 3fd302949..62b4b3961 100644 --- a/compose/rest/handlers/record.go +++ b/compose/rest/handlers/record.go @@ -39,6 +39,7 @@ type RecordAPI interface { Create(context.Context, *request.RecordCreate) (interface{}, error) Read(context.Context, *request.RecordRead) (interface{}, error) Update(context.Context, *request.RecordUpdate) (interface{}, error) + BulkDelete(context.Context, *request.RecordBulkDelete) (interface{}, error) Delete(context.Context, *request.RecordDelete) (interface{}, error) Upload(context.Context, *request.RecordUpload) (interface{}, error) TriggerScript(context.Context, *request.RecordTriggerScript) (interface{}, error) @@ -56,6 +57,7 @@ type Record struct { Create func(http.ResponseWriter, *http.Request) Read func(http.ResponseWriter, *http.Request) Update func(http.ResponseWriter, *http.Request) + BulkDelete func(http.ResponseWriter, *http.Request) Delete func(http.ResponseWriter, *http.Request) Upload func(http.ResponseWriter, *http.Request) TriggerScript func(http.ResponseWriter, *http.Request) @@ -263,6 +265,26 @@ func NewRecord(h RecordAPI) *Record { resputil.JSON(w, value) } }, + BulkDelete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewRecordBulkDelete() + if err := params.Fill(r); err != nil { + logger.LogParamError("Record.BulkDelete", r, err) + resputil.JSON(w, err) + return + } + + value, err := h.BulkDelete(r.Context(), params) + if err != nil { + logger.LogControllerError("Record.BulkDelete", r, err, params.Auditable()) + resputil.JSON(w, err) + return + } + logger.LogControllerCall("Record.BulkDelete", r, params.Auditable()) + if !serveHTTP(value, w, r) { + resputil.JSON(w, value) + } + }, Delete: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() params := request.NewRecordDelete() @@ -339,6 +361,7 @@ func (h Record) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http r.Post("/namespace/{namespaceID}/module/{moduleID}/record/", h.Create) r.Get("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}", h.Read) r.Post("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}", h.Update) + r.Delete("/namespace/{namespaceID}/module/{moduleID}/record/", h.BulkDelete) r.Delete("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}", h.Delete) r.Post("/namespace/{namespaceID}/module/{moduleID}/record/attachment", h.Upload) r.Post("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}/trigger", h.TriggerScript) diff --git a/compose/rest/record.go b/compose/rest/record.go index 7d429149c..4a049aeb8 100644 --- a/compose/rest/record.go +++ b/compose/rest/record.go @@ -5,6 +5,7 @@ import ( "encoding/csv" "encoding/json" "fmt" + "github.com/cortezaproject/corteza-server/pkg/payload" "net/http" "strings" @@ -151,7 +152,19 @@ func (ctrl *Record) Update(ctx context.Context, r *request.RecordUpdate) (interf } func (ctrl *Record) Delete(ctx context.Context, r *request.RecordDelete) (interface{}, error) { - return resputil.OK(), ctrl.record.With(ctx).DeleteByID(r.NamespaceID, r.RecordID) + return resputil.OK(), ctrl.record.With(ctx).DeleteByID(r.NamespaceID, r.ModuleID, r.RecordID) +} + +func (ctrl *Record) BulkDelete(ctx context.Context, r *request.RecordBulkDelete) (interface{}, error) { + if r.Truncate { + return nil, errors.New("pending implementation") + } + + return resputil.OK(), ctrl.record.With(ctx).DeleteByID( + r.NamespaceID, + r.ModuleID, + payload.ParseUInt64s(r.RecordIDs)..., + ) } func (ctrl *Record) Upload(ctx context.Context, r *request.RecordUpload) (interface{}, error) { diff --git a/compose/rest/request/record.go b/compose/rest/request/record.go index 35d63bda0..c75ad0d48 100644 --- a/compose/rest/request/record.go +++ b/compose/rest/request/record.go @@ -666,6 +666,71 @@ func (r *RecordUpdate) Fill(req *http.Request) (err error) { var _ RequestFiller = NewRecordUpdate() +// Record bulkDelete request parameters +type RecordBulkDelete struct { + RecordIDs []string + Truncate bool + NamespaceID uint64 `json:",string"` + ModuleID uint64 `json:",string"` +} + +func NewRecordBulkDelete() *RecordBulkDelete { + return &RecordBulkDelete{} +} + +func (r RecordBulkDelete) Auditable() map[string]interface{} { + var out = map[string]interface{}{} + + out["recordIDs"] = r.RecordIDs + out["truncate"] = r.Truncate + out["namespaceID"] = r.NamespaceID + out["moduleID"] = r.ModuleID + + return out +} + +func (r *RecordBulkDelete) Fill(req *http.Request) (err error) { + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = req.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := req.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := req.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + if val, ok := req.Form["recordIDs"]; ok { + r.RecordIDs = parseStrings(val) + } + + if val, ok := post["truncate"]; ok { + r.Truncate = parseBool(val) + } + r.NamespaceID = parseUInt64(chi.URLParam(req, "namespaceID")) + r.ModuleID = parseUInt64(chi.URLParam(req, "moduleID")) + + return err +} + +var _ RequestFiller = NewRecordBulkDelete() + // Record delete request parameters type RecordDelete struct { RecordID uint64 `json:",string"` diff --git a/compose/service/error.go b/compose/service/error.go index f2d967c29..17a19d0b4 100644 --- a/compose/service/error.go +++ b/compose/service/error.go @@ -20,6 +20,7 @@ const ( ErrNoDeletePermissions serviceError = "NoDeletePermissions" ErrNoTriggerManagementPermissions serviceError = "NoTriggerManagementPermissions" ErrNamespaceRequired serviceError = "NamespaceRequired" + ErrInvalidModuleID serviceError = "InvalidModuleID" ErrModulePageExists serviceError = "ModulePageExists" ErrNotImplemented serviceError = "NotImplemented" ErrRecordImportSessionNotFound serviceError = "RecordImportSessionNotFound" diff --git a/compose/service/record.go b/compose/service/record.go index 468e9d77c..6cbf9056c 100644 --- a/compose/service/record.go +++ b/compose/service/record.go @@ -64,7 +64,7 @@ type ( Create(record *types.Record) (*types.Record, error) Update(record *types.Record) (*types.Record, error) - DeleteByID(namespaceID, recordID uint64) error + DeleteByID(namespaceID, moduleID uint64, recordID ...uint64) error Organize(namespaceID, moduleID, recordID uint64, sortingField, sortingValue, sortingFilter, valueField, value string) error } @@ -395,49 +395,92 @@ func (svc record) recordInfoUpdate(r *types.Record) { r.UpdatedBy = auth.GetIdentityFromContext(svc.ctx).Identity() } -func (svc record) DeleteByID(namespaceID, recordID uint64) (err error) { - if recordID == 0 { +// DeleteByID removes one or more records (all from the same module and namespace) +// +// Before and after each record is deleted beforeDelete and afterDelete events are emitted +// If beforeRecord aborts the action it does so for that specific record only + +func (svc record) DeleteByID(namespaceID, moduleID uint64, recordIDs ...uint64) error { + if namespaceID == 0 { return ErrInvalidID.withStack() } - ns, m, del, err := svc.loadCombo(namespaceID, 0, recordID) + if moduleID == 0 { + return ErrInvalidID.withStack() + } + + var ( + isBulkDelete = len(recordIDs) > 0 + now = time.Now() + + ns *types.Namespace + m *types.Module + + err error + ) + + ns, m, _, err = svc.loadCombo(namespaceID, moduleID, 0) if err != nil { - return + return err } if !svc.ac.CanDeleteRecord(svc.ctx, m) { return ErrNoDeletePermissions.withStack() } - // Preload old record values so we can send it together with event - if err = svc.preloadValues(m, del); err != nil { - return - } - - // Calling before-record-delete scripts - if err = svc.eventbus.WaitFor(svc.ctx, event.RecordBeforeDelete(nil, del, m, ns)); err != nil { - return - } - - err = svc.db.Transaction(func() (err error) { - now := time.Now() - del.DeletedAt = &now - del.DeletedBy = auth.GetIdentityFromContext(svc.ctx).Identity() - - if err = svc.recordRepo.Delete(del); err != nil { - return + for _, recordID := range recordIDs { + if recordID == 0 { + return ErrInvalidID.withStack() } - if err = svc.recordRepo.DeleteValues(del); err != nil { - return + err := svc.db.Transaction(func() (err error) { + var ( + del *types.Record + ) + + del, err = svc.FindByID(namespaceID, recordID) + if err != nil { + return err + } + + // Preload old record values so we can send it together with event + if err = svc.preloadValues(m, del); err != nil { + return err + } + + // Calling before-record-delete scripts + if err = svc.eventbus.WaitFor(svc.ctx, event.RecordBeforeDelete(nil, del, m, ns)); err != nil { + if isBulkDelete { + // Not considered fatal, + // continue with next record + return nil + } else { + return err + } + } + + del.DeletedAt = &now + del.DeletedBy = auth.GetIdentityFromContext(svc.ctx).Identity() + + if err = svc.recordRepo.Delete(del); err != nil { + return err + } + + if err = svc.recordRepo.DeleteValues(del); err != nil { + return err + } + + defer svc.eventbus.Dispatch(svc.ctx, event.RecordAfterDelete(nil, del, m, ns)) + + return err + }) + + if err != nil { + return errors.Wrap(err, "failed to delete record") } + } - defer svc.eventbus.Dispatch(svc.ctx, event.RecordAfterDelete(nil, del, m, ns)) - - return - }) - - return errors.Wrap(err, "unable to delete record") + return nil } // Organize - Record organizer @@ -595,7 +638,7 @@ func (svc record) Organize(namespaceID, moduleID, recordID uint64, posField, pos // Loads namespace, module, record and set of triggers. func (svc record) loadCombo(namespaceID, moduleID, recordID uint64) (ns *types.Namespace, m *types.Module, r *types.Record, err error) { if namespaceID == 0 { - err = ErrNamespaceRequired + err = ErrNamespaceRequired.withStack() return } if ns, err = svc.loadNamespace(namespaceID); err != nil { @@ -607,11 +650,15 @@ func (svc record) loadCombo(namespaceID, moduleID, recordID uint64) (ns *types.N return } - moduleID = r.ModuleID + if r.ModuleID != moduleID && moduleID > 0 { + return nil, nil, nil, ErrInvalidModuleID.withStack() + } } - if m, err = svc.loadModule(ns.ID, moduleID); err != nil { - return + if moduleID > 0 { + if m, err = svc.loadModule(ns.ID, moduleID); err != nil { + return + } } return diff --git a/docs/compose/README.md b/docs/compose/README.md index 6f027c197..05b11da7d 100644 --- a/docs/compose/README.md +++ b/docs/compose/README.md @@ -823,6 +823,7 @@ Compose records | `POST` | `/namespace/{namespaceID}/module/{moduleID}/record/` | Create record in module section | | `GET` | `/namespace/{namespaceID}/module/{moduleID}/record/{recordID}` | Read records by ID from module section | | `POST` | `/namespace/{namespaceID}/module/{moduleID}/record/{recordID}` | Update records in module section | +| `DELETE` | `/namespace/{namespaceID}/module/{moduleID}/record/` | Delete record row from module section | | `DELETE` | `/namespace/{namespaceID}/module/{moduleID}/record/{recordID}` | Delete record row from module section | | `POST` | `/namespace/{namespaceID}/module/{moduleID}/record/attachment` | Uploads attachment and validates it against record field requirements | | `POST` | `/namespace/{namespaceID}/module/{moduleID}/record/{recordID}/trigger` | Fire compose:record trigger | @@ -1003,6 +1004,23 @@ Compose records #### Method +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/namespace/{namespaceID}/module/{moduleID}/record/` | HTTP/S | DELETE | | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| recordIDs | []string | POST | IDs of records to delete | N/A | NO | +| truncate | bool | POST | Remove ALL records of a specified module (pending implementation) | N/A | NO | +| namespaceID | uint64 | PATH | Namespace ID | N/A | YES | +| moduleID | uint64 | PATH | Module ID | N/A | YES | + +## Delete record row from module section + +#### Method + | URI | Protocol | Method | Authentication | | --- | -------- | ------ | -------------- | | `/namespace/{namespaceID}/module/{moduleID}/record/{recordID}` | HTTP/S | DELETE | |