diff --git a/client/web/compose/src/components/Common/RecordToolbar.vue b/client/web/compose/src/components/Common/RecordToolbar.vue index 5309a385d..ec73a05bf 100644 --- a/client/web/compose/src/components/Common/RecordToolbar.vue +++ b/client/web/compose/src/components/Common/RecordToolbar.vue @@ -32,7 +32,7 @@ class="d-flex wrap-with-vertical-gutters align-items-center ml-auto" > + + + + + {{ $t('label.restore') }} + + + diff --git a/client/web/compose/src/components/PageBlocks/RecordListBase.vue b/client/web/compose/src/components/PageBlocks/RecordListBase.vue index 28593a097..031d8b4ad 100644 --- a/client/web/compose/src/components/PageBlocks/RecordListBase.vue +++ b/client/web/compose/src/components/PageBlocks/RecordListBase.vue @@ -125,17 +125,19 @@ v-bind="$props" @refresh="refresh()" /> - + + + @@ -173,7 +189,7 @@ class="border-top mh-100 h-100 mb-0" > - + @@ -482,6 +497,7 @@ ref="footer" fluid class="m-0 p-2" + :class="showingDeletedRecords ? 'bg-warning' : ''" > @@ -504,6 +520,7 @@ +
@@ -569,6 +586,16 @@
+ +
+ + {{ showingDeletedRecords ? $t('recordList.showRecords.existing') : $t('recordList.showRecords.deleted') }} + +
@@ -665,6 +692,7 @@ export default { items: [], idPrefix: `rl:${this.blockIndex}`, recordListFilter: [], + showingDeletedRecords: false, } }, @@ -807,6 +835,10 @@ export default { return this.items.filter(({ id, r }) => this.selected.includes(id) && r.canDeleteRecord).length }, + canUndeleteSelectedRecords () { + return this.items.filter(({ id, r }) => this.selected.includes(id) && r.canUndeleteRecord).length + }, + newRecordRoute () { const refRecord = this.options.linkToParent ? this.record : undefined const pageID = this.recordPageID @@ -921,6 +953,11 @@ export default { return isSorted ? { color: 'black' } : {} }, + handleShowDeleted () { + this.showingDeletedRecords = !this.showingDeletedRecords + this.refresh(true) + }, + // Grabs errors specific to this record item recordErrors (item, field) { if (field) { @@ -939,7 +976,6 @@ export default { return { r, id: id || (r.recordID !== NoID ? r.recordID : `${this.idPrefix}:${this.ctr++}`), - _rowVariant: r.deletedAt ? 'danger' : undefined, } }, @@ -1204,11 +1240,33 @@ export default { }, handleRestoreSelectedRecords () { - const sel = new Set(this.selected) - for (let i = 0; i < this.items.length; i++) { - if (sel.has(this.items[i].id)) { - this.handleRestoreInline(this.items[i], i) - } + if (this.inlineEditing) { + const sel = new Set(this.selected) + this.items.forEach((item, index) => { + if (sel.has(item.id)) { + this.handleRestoreInline(item, index) + } + }) + } else { + const { moduleID, namespaceID } = this.items[0].r + + // filter undeletable records from the selected list + const recordIDs = this.items + .filter(({ id, r }) => r.canUndeleteRecord && this.selected.includes(id)) + .map(({ id }) => id) + + this.processing = true + + this.$ComposeAPI + .recordBulkUndelete({ moduleID, namespaceID, recordIDs }) + .then(() => { + this.refresh(true) + this.toastSuccess(this.$t('notification:record.undeleteBulkSuccess')) + }) + .catch(this.toastErrorHandler(this.$t('notification:record.undeleteBulkFailed'))) + .finally(() => { + this.processing = false + }) } }, @@ -1291,6 +1349,9 @@ export default { } } + // Filter's out deleted records when filter.deleted is 2, and undeleted records when filter.deleted is 0 + this.showingDeletedRecords ? this.filter.deleted = 2 : this.filter.deleted = 0 + await this.$ComposeAPI.recordList({ ...this.filter, moduleID, namespaceID, query, ...paginationOptions }) .then(({ set, filter }) => { const records = set.map(r => new compose.Record(r, this.recordListModule)) diff --git a/client/web/compose/src/components/PageBlocks/RecordListConfigurator.vue b/client/web/compose/src/components/PageBlocks/RecordListConfigurator.vue index 3be80e5fa..14bcd4d3f 100644 --- a/client/web/compose/src/components/PageBlocks/RecordListConfigurator.vue +++ b/client/web/compose/src/components/PageBlocks/RecordListConfigurator.vue @@ -241,6 +241,9 @@ > {{ $t('recordList.record.showTotalCount') }} + + {{ $t('recordList.record.showDeletedRecordsOption') }} + this.dispatchUiEvent('afterDelete')) .then(() => this.updatePrompts()) + .then(this.loadRecord) .catch(this.toastErrorHandler(this.$t('notification:record.deleteFailed'))) .finally(() => { this.processingDelete = false @@ -245,6 +247,23 @@ export default { }) }, 500), + handleUndelete: throttle(function () { + this.processingUndelete = true + this.processing = true + + return this + .dispatchUiEvent('beforeUndelete') + .then(() => this.$ComposeAPI.recordUndelete(this.record)) + .then(() => this.dispatchUiEvent('afterUndelete')) + .then(() => this.updatePrompts()) + .then(this.loadRecord) + .catch(this.toastErrorHandler(this.$t('notification:record.undeleteFailed'))) + .finally(() => { + this.processingUndelete = false + this.processing = false + }) + }, 500), + /** * Validates record and dispatches onFormSubmitError * diff --git a/client/web/compose/src/plugins/eventbus-pairs.js b/client/web/compose/src/plugins/eventbus-pairs.js index febb29d2d..14042494c 100644 --- a/client/web/compose/src/plugins/eventbus-pairs.js +++ b/client/web/compose/src/plugins/eventbus-pairs.js @@ -8,6 +8,8 @@ const recordPageEventTypes = [ 'afterFormSubmit', 'beforeDelete', 'afterDelete', + 'beforeUndelete', + 'afterUndelete', ] export default { diff --git a/client/web/compose/src/views/Public/Pages/Records/View.vue b/client/web/compose/src/views/Public/Pages/Records/View.vue index 300907789..12c2eebbd 100644 --- a/client/web/compose/src/views/Public/Pages/Records/View.vue +++ b/client/web/compose/src/views/Public/Pages/Records/View.vue @@ -35,6 +35,7 @@ :processing="processing" :processing-submit="processingSubmit" :processing-delete="processingDelete" + :processing-undelete="processingUndelete" :is-deleted="isDeleted" :in-editing="inEditing" :hide-clone="inCreating" @@ -44,6 +45,7 @@ @clone="handleClone()" @edit="handleEdit()" @delete="handleDelete()" + @undelete="handleUndelete()" @back="handleBack()" @submit="handleFormSubmit('page.record')" /> @@ -69,7 +71,7 @@ export default { }, mixins: [ - // The record mixin contains all of the logic for creating/editing/deleting the record + // The record mixin contains all of the logic for creating/editing/deleting/undeleting the record record, ], diff --git a/lib/js/src/api-clients/compose.ts b/lib/js/src/api-clients/compose.ts index 72c2d15e9..d2921331e 100644 --- a/lib/js/src/api-clients/compose.ts +++ b/lib/js/src/api-clients/compose.ts @@ -1727,6 +1727,76 @@ export default class Compose { return `/namespace/${namespaceID}/module/${moduleID}/record/${recordID}` } + // Undelete soft-deleted record from module section + async recordUndelete (a: KV, extra: AxiosRequestConfig = {}): Promise { + const { + namespaceID, + moduleID, + recordID, + } = (a as KV) || {} + if (!namespaceID) { + throw Error('field namespaceID is empty') + } + if (!moduleID) { + throw Error('field moduleID is empty') + } + if (!recordID) { + throw Error('field recordID is empty') + } + const cfg: AxiosRequestConfig = { + ...extra, + method: 'post', + url: this.recordUndeleteEndpoint({ + namespaceID, moduleID, recordID, + }), + } + + return this.api().request(cfg).then(result => stdResolve(result)) + } + + recordUndeleteEndpoint (a: KV): string { + const { + namespaceID, + moduleID, + recordID, + } = a || {} + return `/namespace/${namespaceID}/module/${moduleID}/record/${recordID}/undelete` + } + + // Undelete soft-deleted records from module section + async recordBulkUndelete (a: KV, extra: AxiosRequestConfig = {}): Promise { + const { + namespaceID, + moduleID, + recordIDs, + } = (a as KV) || {} + if (!namespaceID) { + throw Error('field namespaceID is empty') + } + if (!moduleID) { + throw Error('field moduleID is empty') + } + const cfg: AxiosRequestConfig = { + ...extra, + method: 'patch', + url: this.recordBulkUndeleteEndpoint({ + namespaceID, moduleID, + }), + } + cfg.data = { + recordIDs, + } + return this.api().request(cfg).then(result => stdResolve(result)) + } + + recordBulkUndeleteEndpoint (a: KV): string { + const { + namespaceID, + moduleID, + } = a || {} + return `/namespace/${namespaceID}/module/${moduleID}/record/undelete` + } + // Uploads attachment and validates it against record field requirements async recordUpload (a: KV, extra: AxiosRequestConfig = {}): Promise { const { diff --git a/lib/js/src/compose/types/page-block/record-list.ts b/lib/js/src/compose/types/page-block/record-list.ts index 356d061eb..6add972c0 100644 --- a/lib/js/src/compose/types/page-block/record-list.ts +++ b/lib/js/src/compose/types/page-block/record-list.ts @@ -28,6 +28,7 @@ interface Options { fullPageNavigation: boolean; showTotalCount: boolean; + showDeletedRecordsOption: boolean; refreshRate: number; refreshEnabled: boolean; @@ -76,6 +77,7 @@ const defaults: Readonly = Object.freeze({ fullPageNavigation: true, showTotalCount: true, + showDeletedRecordsOption: false, editable: false, draggable: false, @@ -132,6 +134,7 @@ export class PageBlockRecordList extends PageBlock { 'hidePaging', 'fullPageNavigation', 'showTotalCount', + 'showDeletedRecordsOption', 'hideSorting', 'allowExport', 'selectable', diff --git a/lib/js/src/compose/types/record.ts b/lib/js/src/compose/types/record.ts index fc69477c6..ab9acd5f1 100644 --- a/lib/js/src/compose/types/record.ts +++ b/lib/js/src/compose/types/record.ts @@ -84,6 +84,7 @@ export class Record { public canUpdateRecord = false; public canReadRecord = false; public canDeleteRecord = false; + public canUndeleteRecord = false; public canManageOwnerOnRecord = false; public canSearchRevision = false; public canGrant = false; @@ -174,6 +175,7 @@ export class Record { 'canUpdateRecord', 'canReadRecord', 'canDeleteRecord', + 'canUndeleteRecord', 'canManageOwnerOnRecord', 'canGrant', ) diff --git a/lib/vue/src/components/input/CInputConfirm.vue b/lib/vue/src/components/input/CInputConfirm.vue index afbf90d4f..0ebd8c35c 100644 --- a/lib/vue/src/components/input/CInputConfirm.vue +++ b/lib/vue/src/components/input/CInputConfirm.vue @@ -6,6 +6,7 @@ :variant="variant" :size="size" :disabled="disabled" + :title="tooltip" :class="`${buttonClass} ${borderless ? 'border-0' : ''}`" @click.stop.prevent="onPrompt" > @@ -86,6 +87,10 @@ export default { type: String, default: 'sm', }, + tooltip: { + type: String, + default: '', + }, }, data () { diff --git a/locale/en/corteza-webapp-compose/block.yaml b/locale/en/corteza-webapp-compose/block.yaml index 616a01a5a..63c3ce4c0 100644 --- a/locale/en/corteza-webapp-compose/block.yaml +++ b/locale/en/corteza-webapp-compose/block.yaml @@ -219,6 +219,9 @@ record: recordList: addRecord: Add cancelSelection: Cancel + tooltip: + deleteSelected: Delete selected records + undeleteSelected: Undelete selected records editFields: Editable module fields export: all: Export all records @@ -371,6 +374,7 @@ recordList: presortLabel: Presort records presortPlaceholder: field1 DESC, field2 ASC showTotalCount: Show total record count + showDeletedRecordsOption: Show option to see deleted records openInSameTab: Open records in the same tab openInNewTab: Open records in a new tab openInModal: Open records in a modal @@ -390,6 +394,9 @@ recordList: selected: '{{count}} of {{total}} records selected' sort: tooltip: Sort column + showRecords: + deleted: Show deleted records + existing: Show existing records recordOrganizer: descriptionField: footnote: Field value will be used as record description diff --git a/locale/en/corteza-webapp-compose/general.yaml b/locale/en/corteza-webapp-compose/general.yaml index bac903dfc..952df253b 100644 --- a/locale/en/corteza-webapp-compose/general.yaml +++ b/locale/en/corteza-webapp-compose/general.yaml @@ -22,6 +22,7 @@ label: close: Close create: Create delete: Delete + restore: Restore descending: Descending description: Description download: Download diff --git a/locale/en/corteza-webapp-compose/notification.yaml b/locale/en/corteza-webapp-compose/notification.yaml index 69175edd0..74629277b 100644 --- a/locale/en/corteza-webapp-compose/notification.yaml +++ b/locale/en/corteza-webapp-compose/notification.yaml @@ -87,8 +87,11 @@ page: record: createFailed: Could not create record deleteFailed: Could not delete record + undeleteFailed: Could not undelete record deleteBulkFailed: Could not delete selected records deleteBulkSuccess: Successfully deleted selected records + undeleteBulkFailed: Could not restore selected records + undeleteBulkSuccess: Successfully restored selected records invalidOwnerVar: Can not use ${ownerID} variable in non-record pages invalidRecordVar: Can not use ${record...} variable in non-record pages listLoadFailed: Could not load record list diff --git a/server/automation/rest/eventTypes.gen.go b/server/automation/rest/eventTypes.gen.go index 620f8c9c7..b61ea7fab 100644 --- a/server/automation/rest/eventTypes.gen.go +++ b/server/automation/rest/eventTypes.gen.go @@ -1261,6 +1261,83 @@ func getEventTypeDefinitions() []eventTypeDef { }, }, + { + ResourceType: "compose:record", + EventType: "beforeUndelete", + Properties: []eventTypePropertyDef{ + + { + Name: "record", + Type: "ComposeRecord", + Immutable: false, + }, + + { + Name: "oldRecord", + Type: "ComposeRecord", + Immutable: true, + }, + + { + Name: "module", + Type: "ComposeModule", + Immutable: true, + }, + + { + Name: "namespace", + Type: "ComposeNamespace", + Immutable: true, + }, + + { + Name: "recordValueErrors", + Type: "ComposeRecordValueErrorSet", + Immutable: false, + }, + + { + Name: "selected", + Type: "", + Immutable: true, + }, + }, + Constraints: []eventTypeConstraintDef{ + + { + Name: "namespace.handle", + }, + + { + Name: "namespace.name", + }, + + { + Name: "module.handle", + }, + + { + Name: "module.name", + }, + + { + Name: "record.created-at", + }, + + { + Name: "record.updated-at", + }, + + { + Name: "record.deleted-at", + }, + + { + Name: "record.values.*", + }, + }, + }, + { ResourceType: "compose:record", EventType: "afterCreate", @@ -1492,6 +1569,83 @@ func getEventTypeDefinitions() []eventTypeDef { }, }, + { + ResourceType: "compose:record", + EventType: "afterUndelete", + Properties: []eventTypePropertyDef{ + + { + Name: "record", + Type: "ComposeRecord", + Immutable: false, + }, + + { + Name: "oldRecord", + Type: "ComposeRecord", + Immutable: true, + }, + + { + Name: "module", + Type: "ComposeModule", + Immutable: true, + }, + + { + Name: "namespace", + Type: "ComposeNamespace", + Immutable: true, + }, + + { + Name: "recordValueErrors", + Type: "ComposeRecordValueErrorSet", + Immutable: false, + }, + + { + Name: "selected", + Type: "", + Immutable: true, + }, + }, + Constraints: []eventTypeConstraintDef{ + + { + Name: "namespace.handle", + }, + + { + Name: "namespace.name", + }, + + { + Name: "module.handle", + }, + + { + Name: "module.name", + }, + + { + Name: "record.created-at", + }, + + { + Name: "record.updated-at", + }, + + { + Name: "record.deleted-at", + }, + + { + Name: "record.values.*", + }, + }, + }, + { ResourceType: "system", EventType: "onManual", diff --git a/server/compose/dalutils/records.go b/server/compose/dalutils/records.go index 4e043ef3b..f8c2c7d46 100644 --- a/server/compose/dalutils/records.go +++ b/server/compose/dalutils/records.go @@ -76,6 +76,10 @@ func ComposeRecordSoftDelete(ctx context.Context, u updater, mod *types.Module, return u.Update(ctx, mod.ModelRef(), recUpdateOperations(mod), recToGetters(records...)...) } +func ComposeRecordUndelete(ctx context.Context, u updater, mod *types.Module, records ...*types.Record) (err error) { + return u.Update(ctx, mod.ModelRef(), recUpdateOperations(mod), recToGetters(records...)...) +} + func ComposeRecordDelete(ctx context.Context, d deleter, mod *types.Module, records ...*types.Record) (err error) { return d.Delete(ctx, mod.ModelRef(), recDeleteOperations(mod), recToGetters(records...)...) } diff --git a/server/compose/record.cue b/server/compose/record.cue index 06cc7e77e..0df778c7b 100644 --- a/server/compose/record.cue +++ b/server/compose/record.cue @@ -78,6 +78,7 @@ record: { "read": {} "update": {} "delete": {} + "undelete": {} "owner.manage": {} "revisions.search": {} } diff --git a/server/compose/rest.yaml b/server/compose/rest.yaml index 453a820fc..07270908e 100644 --- a/server/compose/rest.yaml +++ b/server/compose/rest.yaml @@ -828,6 +828,26 @@ endpoints: name: recordID required: true title: Record ID + - name: undelete + method: POST + title: Undelete soft-deleted record from module section + path: "/{recordID}/undelete" + parameters: + path: + - type: uint64 + name: recordID + required: true + title: Record ID + - name: bulkUndelete + method: PATCH + title: Undelete soft-deleted records from module section + path: "/undelete" + parameters: + post: + - type: "[]string" + name: recordIDs + required: false + title: IDs of records to undelete - name: upload path: "/attachment" method: POST diff --git a/server/compose/rest/handlers/record.go b/server/compose/rest/handlers/record.go index c6fa0b55d..661c306e8 100644 --- a/server/compose/rest/handlers/record.go +++ b/server/compose/rest/handlers/record.go @@ -31,6 +31,8 @@ type ( Update(context.Context, *request.RecordUpdate) (interface{}, error) BulkDelete(context.Context, *request.RecordBulkDelete) (interface{}, error) Delete(context.Context, *request.RecordDelete) (interface{}, error) + Undelete(context.Context, *request.RecordUndelete) (interface{}, error) + BulkUndelete(context.Context, *request.RecordBulkUndelete) (interface{}, error) Upload(context.Context, *request.RecordUpload) (interface{}, error) TriggerScript(context.Context, *request.RecordTriggerScript) (interface{}, error) TriggerScriptOnList(context.Context, *request.RecordTriggerScriptOnList) (interface{}, error) @@ -51,6 +53,8 @@ type ( Update func(http.ResponseWriter, *http.Request) BulkDelete func(http.ResponseWriter, *http.Request) Delete func(http.ResponseWriter, *http.Request) + Undelete func(http.ResponseWriter, *http.Request) + BulkUndelete func(http.ResponseWriter, *http.Request) Upload func(http.ResponseWriter, *http.Request) TriggerScript func(http.ResponseWriter, *http.Request) TriggerScriptOnList func(http.ResponseWriter, *http.Request) @@ -252,6 +256,38 @@ func NewRecord(h RecordAPI) *Record { api.Send(w, r, value) }, + Undelete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewRecordUndelete() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Undelete(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + BulkUndelete: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewRecordBulkUndelete() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.BulkUndelete(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, Upload: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() params := request.NewRecordUpload() @@ -334,6 +370,8 @@ func (h Record) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http 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/{recordID}/undelete", h.Undelete) + r.Patch("/namespace/{namespaceID}/module/{moduleID}/record/undelete", h.BulkUndelete) r.Post("/namespace/{namespaceID}/module/{moduleID}/record/attachment", h.Upload) r.Post("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}/trigger", h.TriggerScript) r.Post("/namespace/{namespaceID}/module/{moduleID}/record/trigger", h.TriggerScriptOnList) diff --git a/server/compose/rest/record.go b/server/compose/rest/record.go index 95128241b..99d81d144 100644 --- a/server/compose/rest/record.go +++ b/server/compose/rest/record.go @@ -40,6 +40,7 @@ type ( CanUpdateRecord bool `json:"canUpdateRecord"` CanReadRecord bool `json:"canReadRecord"` CanDeleteRecord bool `json:"canDeleteRecord"` + CanUndeleteRecord bool `json:"canUndeleteRecord"` CanSearchRevisions bool `json:"canSearchRevisions"` CanGrant bool `json:"canGrant"` @@ -65,6 +66,7 @@ type ( CanUpdateRecord(context.Context, *types.Record) bool CanReadRecord(context.Context, *types.Record) bool CanDeleteRecord(context.Context, *types.Record) bool + CanUndeleteRecord(context.Context, *types.Record) bool CanManageOwnerOnRecord(context.Context, *types.Record) bool CanSearchRevisionsOnRecord(context.Context, *types.Record) bool } @@ -277,6 +279,18 @@ func (ctrl *Record) BulkDelete(ctx context.Context, r *request.RecordBulkDelete) ) } +func (ctrl *Record) Undelete(ctx context.Context, r *request.RecordUndelete) (interface{}, error) { + return api.OK(), ctrl.record.UndeleteByID(ctx, r.NamespaceID, r.ModuleID, r.RecordID) +} + +func (ctrl *Record) BulkUndelete(ctx context.Context, r *request.RecordBulkUndelete) (interface{}, error) { + return api.OK(), ctrl.record.UndeleteByID(ctx, + r.NamespaceID, + r.ModuleID, + payload.ParseUint64s(r.RecordIDs)..., + ) +} + func (ctrl *Record) Upload(ctx context.Context, r *request.RecordUpload) (interface{}, error) { file, err := r.Upload.Open() if err != nil { @@ -618,6 +632,7 @@ func (ctrl Record) makeBulkPayload(ctx context.Context, m *types.Module, dd *typ CanUpdateRecord: ctrl.ac.CanUpdateRecord(ctx, rr[0]), CanReadRecord: ctrl.ac.CanReadRecord(ctx, rr[0]), CanDeleteRecord: ctrl.ac.CanDeleteRecord(ctx, rr[0]), + CanUndeleteRecord: ctrl.ac.CanUndeleteRecord(ctx, rr[0]), CanSearchRevisions: ctrl.ac.CanSearchRevisionsOnRecord(ctx, rr[0]), }, nil } @@ -637,6 +652,7 @@ func (ctrl Record) makePayload(ctx context.Context, m *types.Module, r *types.Re CanUpdateRecord: ctrl.ac.CanUpdateRecord(ctx, r), CanReadRecord: ctrl.ac.CanReadRecord(ctx, r), CanDeleteRecord: ctrl.ac.CanDeleteRecord(ctx, r), + CanUndeleteRecord: ctrl.ac.CanUndeleteRecord(ctx, r), CanSearchRevisions: ctrl.ac.CanSearchRevisionsOnRecord(ctx, r), }, nil } diff --git a/server/compose/rest/request/record.go b/server/compose/rest/request/record.go index bd8eadfeb..74d5cc3f7 100644 --- a/server/compose/rest/request/record.go +++ b/server/compose/rest/request/record.go @@ -358,6 +358,40 @@ type ( RecordID uint64 `json:",string"` } + RecordUndelete struct { + // NamespaceID PATH parameter + // + // Namespace ID + NamespaceID uint64 `json:",string"` + + // ModuleID PATH parameter + // + // Module ID + ModuleID uint64 `json:",string"` + + // RecordID PATH parameter + // + // Record ID + RecordID uint64 `json:",string"` + } + + RecordBulkUndelete struct { + // NamespaceID PATH parameter + // + // Namespace ID + NamespaceID uint64 `json:",string"` + + // ModuleID PATH parameter + // + // Module ID + ModuleID uint64 `json:",string"` + + // RecordIDs POST parameter + // + // IDs of records to undelete + RecordIDs []string + } + RecordUpload struct { // NamespaceID PATH parameter // @@ -1729,6 +1763,154 @@ func (r *RecordDelete) Fill(req *http.Request) (err error) { return err } +// NewRecordUndelete request +func NewRecordUndelete() *RecordUndelete { + return &RecordUndelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r RecordUndelete) Auditable() map[string]interface{} { + return map[string]interface{}{ + "namespaceID": r.NamespaceID, + "moduleID": r.ModuleID, + "recordID": r.RecordID, + } +} + +// Auditable returns all auditable/loggable parameters +func (r RecordUndelete) GetNamespaceID() uint64 { + return r.NamespaceID +} + +// Auditable returns all auditable/loggable parameters +func (r RecordUndelete) GetModuleID() uint64 { + return r.ModuleID +} + +// Auditable returns all auditable/loggable parameters +func (r RecordUndelete) GetRecordID() uint64 { + return r.RecordID +} + +// Fill processes request and fills internal variables +func (r *RecordUndelete) Fill(req *http.Request) (err error) { + + { + var val string + // path params + + val = chi.URLParam(req, "namespaceID") + r.NamespaceID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + val = chi.URLParam(req, "moduleID") + r.ModuleID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + val = chi.URLParam(req, "recordID") + r.RecordID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewRecordBulkUndelete request +func NewRecordBulkUndelete() *RecordBulkUndelete { + return &RecordBulkUndelete{} +} + +// Auditable returns all auditable/loggable parameters +func (r RecordBulkUndelete) Auditable() map[string]interface{} { + return map[string]interface{}{ + "namespaceID": r.NamespaceID, + "moduleID": r.ModuleID, + "recordIDs": r.RecordIDs, + } +} + +// Auditable returns all auditable/loggable parameters +func (r RecordBulkUndelete) GetNamespaceID() uint64 { + return r.NamespaceID +} + +// Auditable returns all auditable/loggable parameters +func (r RecordBulkUndelete) GetModuleID() uint64 { + return r.ModuleID +} + +// Auditable returns all auditable/loggable parameters +func (r RecordBulkUndelete) GetRecordIDs() []string { + return r.RecordIDs +} + +// Fill processes request and fills internal variables +func (r *RecordBulkUndelete) Fill(req *http.Request) (err error) { + + if strings.HasPrefix(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 fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + // Caching 32MB to memory, the rest to disk + if err = req.ParseMultipartForm(32 << 20); err != nil && err != http.ErrNotMultipart { + return err + } else if err == nil { + // Multipart params + + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + //if val, ok := req.Form["recordIDs[]"]; ok && len(val) > 0 { + // r.RecordIDs, err = val, nil + // if err != nil { + // return err + // } + //} + } + + { + var val string + // path params + + val = chi.URLParam(req, "namespaceID") + r.NamespaceID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + val = chi.URLParam(req, "moduleID") + r.ModuleID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + // NewRecordUpload request func NewRecordUpload() *RecordUpload { return &RecordUpload{} diff --git a/server/compose/service/access_control.gen.go b/server/compose/service/access_control.gen.go index 3a1dcf2a6..056989caa 100644 --- a/server/compose/service/access_control.gen.go +++ b/server/compose/service/access_control.gen.go @@ -286,6 +286,11 @@ func (svc accessControl) List() (out []map[string]string) { "any": types.RecordRbacResource(0, 0, 0), "op": "delete", }, + { + "type": types.RecordResourceType, + "any": types.RecordRbacResource(0, 0, 0), + "op": "undelete", + }, { "type": types.RecordResourceType, "any": types.RecordRbacResource(0, 0, 0), @@ -607,6 +612,13 @@ func (svc accessControl) CanDeleteRecord(ctx context.Context, r *types.Record) b return svc.can(ctx, "delete", r) } +// CanUndeleteRecord checks if current user can undelete +// +// This function is auto-generated +func (svc accessControl) CanUndeleteRecord(ctx context.Context, r *types.Record) bool { + return svc.can(ctx, "undelete", r) +} + // CanManageOwnerOnRecord checks if current user can owner.manage // // This function is auto-generated @@ -806,6 +818,7 @@ func rbacResourceOperations(r string) map[string]bool { "read": true, "update": true, "delete": true, + "undelete": true, "owner.manage": true, "revisions.search": true, } diff --git a/server/compose/service/event/events.gen.go b/server/compose/service/event/events.gen.go index 7d24008e6..c4d5ebe8f 100644 --- a/server/compose/service/event/events.gen.go +++ b/server/compose/service/event/events.gen.go @@ -279,6 +279,13 @@ type ( *recordBase } + // recordBeforeUndelete + // + // This type is auto-generated. + recordBeforeUndelete struct { + *recordBase + } + // recordAfterCreate // // This type is auto-generated. @@ -299,6 +306,13 @@ type ( recordAfterDelete struct { *recordBase } + + // recordAfterUndelete + // + // This type is auto-generated. + recordAfterUndelete struct { + *recordBase + } ) // ResourceType returns "compose" @@ -1916,6 +1930,13 @@ func (recordBeforeDelete) EventType() string { return "beforeDelete" } +// EventType on recordBeforeUndelete returns "beforeUndelete" +// +// This function is auto-generated. +func (recordBeforeUndelete) EventType() string { + return "beforeUndelete" +} + // EventType on recordAfterCreate returns "afterCreate" // // This function is auto-generated. @@ -1937,6 +1958,13 @@ func (recordAfterDelete) EventType() string { return "afterDelete" } +// EventType on recordAfterUndelete returns "afterUndelete" +// +// This function is auto-generated. +func (recordAfterUndelete) EventType() string { + return "afterUndelete" +} + // RecordOnManual creates onManual for compose:record resource // // This function is auto-generated. @@ -2187,6 +2215,56 @@ func RecordBeforeDeleteImmutable( } } +// RecordBeforeUndelete creates beforeUndelete for compose:record resource +// +// This function is auto-generated. +func RecordBeforeUndelete( + argRecord *types.Record, + argOldRecord *types.Record, + argModule *types.Module, + argNamespace *types.Namespace, + argRecordValueErrors *types.RecordValueErrorSet, + argSelected []interface{}, +) *recordBeforeUndelete { + return &recordBeforeUndelete{ + recordBase: &recordBase{ + immutable: false, + record: argRecord, + oldRecord: argOldRecord, + module: argModule, + namespace: argNamespace, + recordValueErrors: argRecordValueErrors, + selected: argSelected, + }, + } +} + +// RecordBeforeUndeleteImmutable creates beforeUndelete for compose:record resource +// +// None of the arguments will be mutable! +// +// This function is auto-generated. +func RecordBeforeUndeleteImmutable( + argRecord *types.Record, + argOldRecord *types.Record, + argModule *types.Module, + argNamespace *types.Namespace, + argRecordValueErrors *types.RecordValueErrorSet, + argSelected []interface{}, +) *recordBeforeUndelete { + return &recordBeforeUndelete{ + recordBase: &recordBase{ + immutable: true, + record: argRecord, + oldRecord: argOldRecord, + module: argModule, + namespace: argNamespace, + recordValueErrors: argRecordValueErrors, + selected: argSelected, + }, + } +} + // RecordAfterCreate creates afterCreate for compose:record resource // // This function is auto-generated. @@ -2337,6 +2415,56 @@ func RecordAfterDeleteImmutable( } } +// RecordAfterUndelete creates afterUndelete for compose:record resource +// +// This function is auto-generated. +func RecordAfterUndelete( + argRecord *types.Record, + argOldRecord *types.Record, + argModule *types.Module, + argNamespace *types.Namespace, + argRecordValueErrors *types.RecordValueErrorSet, + argSelected []interface{}, +) *recordAfterUndelete { + return &recordAfterUndelete{ + recordBase: &recordBase{ + immutable: false, + record: argRecord, + oldRecord: argOldRecord, + module: argModule, + namespace: argNamespace, + recordValueErrors: argRecordValueErrors, + selected: argSelected, + }, + } +} + +// RecordAfterUndeleteImmutable creates afterUndelete for compose:record resource +// +// None of the arguments will be mutable! +// +// This function is auto-generated. +func RecordAfterUndeleteImmutable( + argRecord *types.Record, + argOldRecord *types.Record, + argModule *types.Module, + argNamespace *types.Namespace, + argRecordValueErrors *types.RecordValueErrorSet, + argSelected []interface{}, +) *recordAfterUndelete { + return &recordAfterUndelete{ + recordBase: &recordBase{ + immutable: true, + record: argRecord, + oldRecord: argOldRecord, + module: argModule, + namespace: argNamespace, + recordValueErrors: argRecordValueErrors, + selected: argSelected, + }, + } +} + // SetRecord sets new record value // // This function is auto-generated. diff --git a/server/compose/service/event/events.yaml b/server/compose/service/event/events.yaml index 418cc5beb..391d2c76a 100644 --- a/server/compose/service/event/events.yaml +++ b/server/compose/service/event/events.yaml @@ -55,7 +55,7 @@ compose:module: compose:record: on: ['manual', 'iteration'] - ba: ['create', 'update', 'delete'] + ba: ['create', 'update', 'delete', 'undelete'] props: - name: 'record' type: '*types.Record' diff --git a/server/compose/service/record.go b/server/compose/service/record.go index bf09da85e..3a6d54669 100644 --- a/server/compose/service/record.go +++ b/server/compose/service/record.go @@ -97,6 +97,7 @@ type ( CanReadRecord(context.Context, *types.Record) bool CanUpdateRecord(context.Context, *types.Record) bool CanDeleteRecord(context.Context, *types.Record) bool + CanUndeleteRecord(context.Context, *types.Record) bool CanSearchRevisionsOnRecord(context.Context, *types.Record) bool recordManageOwnerAccessController @@ -128,6 +129,7 @@ type ( Validate(ctx context.Context, rec *types.Record) error DeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordID ...uint64) error + UndeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordID ...uint64) error Organize(ctx context.Context, namespaceID, moduleID, recordID uint64, sortingField, sortingValue, sortingFilter, valueField, value string) error @@ -497,7 +499,6 @@ func (svc record) searchSensitive(ctx context.Context, userID uint64, namespace } // SearchRevisions returns iterator for revisions of a record -// func (svc record) SearchRevisions(ctx context.Context, namespaceID, moduleID, recordID uint64) (dal.Iterator, error) { var ( aProps = &recordActionProps{record: &types.Record{NamespaceID: namespaceID, ModuleID: moduleID, ID: recordID}} @@ -1265,6 +1266,59 @@ func (svc record) delete(ctx context.Context, namespaceID, moduleID, recordID ui return del, nil } +func (svc record) undelete(ctx context.Context, namespaceID, moduleID, recordID uint64) (undel *types.Record, err error) { + var ( + ns *types.Namespace + m *types.Module + ) + + ns, m, undel, err = loadRecordCombo(ctx, svc.store, svc.dal, namespaceID, moduleID, recordID) + if err != nil { + return nil, err + } + + if !svc.ac.CanUndeleteRecord(ctx, undel) { + return nil, RecordErrNotAllowedToUndelete() + } + + undel.DeletedAt = nil + undel.DeletedBy = 0 + + // ensure module ref is set before running through records workflows and scripts + undel.SetModule(m) + + undel.DeletedAt = nil + undel.DeletedBy = 0 + undel.Revision = undel.Revision + 1 + + { + // Calling before-record-undelete scripts + if err = svc.eventbus.WaitFor(ctx, event.RecordBeforeUndelete(nil, undel, m, ns, nil, nil)); err != nil { + return nil, err + } + } + + if m.Config.RecordRevisions.Enabled { + // Prepare record revision for update + if err = svc.revisions.undeleted(ctx, undel); err != nil { + return + } + } + + if err = dalutils.ComposeRecordUndelete(ctx, svc.dal, m, undel); err != nil { + return nil, err + } + + // ensure module ref is set before running through records workflows and scripts + undel.SetModule(m) + + { + _ = svc.eventbus.WaitFor(ctx, event.RecordAfterUndeleteImmutable(nil, undel, m, ns, nil, nil)) + } + + return undel, nil +} + // 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 @@ -1329,6 +1383,66 @@ func (svc record) DeleteByID(ctx context.Context, namespaceID, moduleID uint64, return nil } +func (svc record) UndeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordIDs ...uint64) (err error) { + var ( + aProps = &recordActionProps{ + namespace: &types.Namespace{ID: namespaceID}, + module: &types.Module{ID: moduleID}, + } + + isBulkUndelete = len(recordIDs) > 1 + + ns *types.Namespace + m *types.Module + r *types.Record + ) + + err = func() error { + if namespaceID == 0 { + return RecordErrInvalidNamespaceID() + } + if moduleID == 0 { + return RecordErrInvalidModuleID() + } + + ns, m, err = loadModuleCombo(ctx, svc.store, namespaceID, moduleID) + if err != nil { + return err + } + + aProps.setNamespace(ns) + aProps.setModule(m) + + return nil + }() + + if err != nil { + return svc.recordAction(ctx, aProps, RecordActionUndelete, err) + } + + for _, recordID := range recordIDs { + err := func() (err error) { + r, err = svc.undelete(ctx, namespaceID, moduleID, recordID) + if err != nil { + return svc.recordAction(ctx, aProps, RecordActionUndelete, err) + } + aProps.setRecord(r) + + if err = dalutils.ComposeRecordUpdate(ctx, svc.dal, m, r); err != nil { + return err + } + + return svc.recordAction(ctx, aProps, RecordActionUndelete, err) + }() + + if err != nil && !isBulkUndelete { + return err + } + } + + return nil +} + func (svc record) Organize(ctx context.Context, namespaceID, moduleID, recordID uint64, posField, position, filter, grpField, group string) (err error) { var ( ns *types.Namespace @@ -1542,30 +1656,29 @@ func (svc record) TriggerScript(ctx context.Context, namespaceID, moduleID, reco // - delete: delete records (unless aborted) // - default: only iterates over records, records are not changed, return value is ignored // -// // Iterator can be invoked only when defined in corredor script: // -// return default { -// iterator (each) { -// return each({ -// resourceType: 'compose:record', -// // action: 'update', -// filter: { -// namespace: '122709101053521922', -// module: '122709116471783426', -// query: 'Status = "foo"', -// sort: 'Status DESC', -// limit: 3, -// }, -// }) -// }, +// return default { +// iterator (each) { +// return each({ +// resourceType: 'compose:record', +// // action: 'update', +// filter: { +// namespace: '122709101053521922', +// module: '122709116471783426', +// query: 'Status = "foo"', +// sort: 'Status DESC', +// limit: 3, +// }, +// }) +// }, // -// // this is required in case of a deferred iterator -// // security: { runAs: .... } } +// // this is required in case of a deferred iterator +// // security: { runAs: .... } } // -// // exec gets called for every record found by iterator -// exec () { ... } -// } +// // exec gets called for every record found by iterator +// exec () { ... } +// } func (svc record) Iterator(ctx context.Context, f types.RecordFilter, fn eventbus.HandlerFn, action string) (err error) { var ( invokerID = auth.GetIdentityFromContext(ctx).Identity() @@ -1604,6 +1717,10 @@ func (svc record) Iterator(ctx context.Context, f types.RecordFilter, fn eventbu if !svc.ac.CanDeleteRecord(ctx, rec) { return RecordErrNotAllowedToDelete() } + case "undelete": + if !svc.ac.CanUndeleteRecord(ctx, rec) { + return RecordErrNotAllowedToUndelete() + } } recordableAction := RecordActionIteratorIteration @@ -1655,6 +1772,14 @@ func (svc record) Iterator(ctx context.Context, f types.RecordFilter, fn eventbu rec.DeletedBy = invokerID return dalutils.ComposeRecordSoftDelete(ctx, svc.dal, m, rec) }) + case "undelete": + recordableAction = RecordActionIteratorUndelete + + return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error { + rec.DeletedAt = nil + rec.DeletedBy = 0 + return dalutils.ComposeRecordUndelete(ctx, svc.dal, m, rec) + }) } return nil diff --git a/server/compose/service/record_actions.gen.go b/server/compose/service/record_actions.gen.go index 9b17f11c5..f1f15ad3f 100644 --- a/server/compose/service/record_actions.gen.go +++ b/server/compose/service/record_actions.gen.go @@ -738,6 +738,25 @@ func RecordActionIteratorDelete(props ...*recordActionProps) *recordAction { return a } +// RecordActionIteratorUndelete returns "compose:record.iteratorUndelete" action +// +// This function is auto-generated. +func RecordActionIteratorUndelete(props ...*recordActionProps) *recordAction { + a := &recordAction{ + timestamp: time.Now(), + resource: "compose:record", + action: "iteratorUndelete", + log: "undeleted record in iteration", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + // ********************************************************************************************************************* // ********************************************************************************************************************* // Error constructors diff --git a/server/compose/service/record_actions.yaml b/server/compose/service/record_actions.yaml index 9b6817f80..3f010f8e2 100644 --- a/server/compose/service/record_actions.yaml +++ b/server/compose/service/record_actions.yaml @@ -100,6 +100,9 @@ actions: - action: iteratorDelete log: "deleted record in iteration" + - action: iteratorUndelete + log: "undeleted record in iteration" + errors: - error: notFound message: "record not found" diff --git a/server/compose/service/record_revisions.go b/server/compose/service/record_revisions.go index 1143fe74f..e617de3c6 100644 --- a/server/compose/service/record_revisions.go +++ b/server/compose/service/record_revisions.go @@ -91,6 +91,20 @@ func (svc *recordRevisions) softDeleted(ctx context.Context, del *types.Record) return svc.r.Create(ctx, svc.modelRef(del.GetModule()), rev) } +func (svc *recordRevisions) undeleted(ctx context.Context, undel *types.Record) (err error) { + var ( + invokerID = auth.GetIdentityFromContext(ctx).Identity() + rev *revisions.Revision + ) + + rev = revisions.Make(revisions.Undeleted, undel.Revision, undel.ID, invokerID) + if err != nil { + return + } + + return svc.r.Create(ctx, svc.modelRef(undel.GetModule()), rev) +} + func (svc *recordRevisions) skippedField(mod *types.Module) []string { list := []string{ "ID", @@ -115,21 +129,6 @@ func (svc *recordRevisions) skippedField(mod *types.Module) []string { return list } -// @todo uncomment when supported -//func (svc *recordRevisions) restored(ctx context.Context, del *types.Record) (err error) { -// var ( -// invokerID = auth.GetIdentityFromContext(ctx).Identity() -// rev *revisions.Revision -// ) -// -// rev, err = revisions.Make(revisions.Restored, del.Revision, del.ID, invokerID, nil, nil) -// if err != nil { -// return -// } -// -// return svc.r.Create(ctx, svc.modelRef(del.GetModule()), rev) -//} - // @todo uncomment when supported //func (svc *recordRevisions) hardDeleted(ctx context.Context, del *types.Record) (err error) { // var ( diff --git a/server/pkg/revisions/operations.go b/server/pkg/revisions/operations.go index bb83d1f3b..6990b2387 100644 --- a/server/pkg/revisions/operations.go +++ b/server/pkg/revisions/operations.go @@ -9,6 +9,6 @@ const ( Created = "created" Updated = "updated" SoftDeleted = "soft-deleted" - Restored = "restored" + Undeleted = "undeleted" HardDeleted = "hard-deleted" ) diff --git a/server/store/adapters/rdbms/dal/model.go b/server/store/adapters/rdbms/dal/model.go index 18534343c..e71f28522 100644 --- a/server/store/adapters/rdbms/dal/model.go +++ b/server/store/adapters/rdbms/dal/model.go @@ -97,7 +97,6 @@ func Model(m *dal.Model, c queryRunner, d drivers.Dialect) *model { // // Alternative solution would introduce a mutes on the internal model but that // is probably the same or worse as this. -// func (d *model) parseQuery(q string) (out exp.Expression, err error) { return d.QueryParser().Parse(q) } @@ -108,7 +107,8 @@ func (d *model) convertQuery(n *ql.ASTNode) (out exp.Expression, err error) { // QueryParser returns ql struct that allows parsing query strings or converting AST into expression // @todo benchmark to see if this re-init is a bad idea; I don't think it should be -// since we're just initializing fairly light structs. +// +// since we're just initializing fairly light structs. func (d *model) QueryParser() queryParser { return ql.Converter( ql.SymHandler(d.qlConverterGenericSymHandler()), @@ -453,7 +453,7 @@ func (d *model) searchSql(f filter.Filter) *goqu.SelectDataset { switch state { case filter.StateExclusive: // only not-null values - cnd = append(cnd, exp.NewLiteralExpression("? IS NULL", attrExpr)) + cnd = append(cnd, exp.NewLiteralExpression("? IS NOT NULL", attrExpr)) case filter.StateExcluded: // exclude all non-null values