diff --git a/src/components/error-response-presenter.ts b/src/components/error-response-presenter.ts index 4c679783..68b23d08 100644 --- a/src/components/error-response-presenter.ts +++ b/src/components/error-response-presenter.ts @@ -454,6 +454,14 @@ export default (error: AppError | DBError) => { }, }; } + case US_ERRORS.WORKBOOK_IS_ALREADY_RESTORED: { + return { + code: 400, + response: { + message: 'The workbook is alredy restored', + }, + }; + } case US_ERRORS.WORKBOOK_ENTITY_ERROR: { return { code: 500, diff --git a/src/const/us-error-constants.ts b/src/const/us-error-constants.ts index 50ebbbe2..0498481d 100644 --- a/src/const/us-error-constants.ts +++ b/src/const/us-error-constants.ts @@ -64,6 +64,7 @@ const US_ERRORS = { COLLECTION_NOT_EXISTS: 'COLLECTION_NOT_EXISTS', COLLECTION_CIRCULAR_REFERENCE_ERROR: 'COLLECTION_CIRCULAR_REFERENCE_ERROR', WORKBOOK_NOT_EXISTS: 'WORKBOOK_NOT_EXISTS', + WORKBOOK_IS_ALREADY_RESTORED: 'WORKBOOK_IS_ALREADY_RESTORED', WORKBOOK_ENTITY_ERROR: 'WORKBOOK_ENTITY_ERROR', WORKBOOK_COPY_FILE_CONNECTION_ERROR: 'WORKBOOK_COPY_FILE_CONNECTION_ERROR', WORKBOOK_OPERATION_FORBIDDEN: 'WORKBOOK_OPERATION_FORBIDDEN', diff --git a/src/controllers/workbooks.ts b/src/controllers/workbooks.ts index faecf41c..8815fe87 100644 --- a/src/controllers/workbooks.ts +++ b/src/controllers/workbooks.ts @@ -16,6 +16,7 @@ import { getAllWorkbooks, OrderField, OrderDirection, + restoreWorkbook, } from '../services/new/workbook'; import { formatWorkbookModel, @@ -25,6 +26,7 @@ import { formatWorkbookModelWithOperation, formatWorkbooksList, formatSetWorkbookIsTemplate, + formatRestoreWorkbook, } from '../services/new/workbook/formatters'; export default { @@ -260,4 +262,21 @@ export default { const {code, response} = prepareResponse({data: formattedResponse}); res.status(code).send(response); }, + + restore: async (req: Request, res: Response) => { + const {params} = req; + + const result = await restoreWorkbook( + { + ctx: req.ctx, + }, + { + workbookId: params.workbookId, + }, + ); + + const formattedResponse = formatRestoreWorkbook(result); + const {code, response} = prepareResponse({data: formattedResponse}); + res.status(code).send(response); + }, }; diff --git a/src/routes.ts b/src/routes.ts index 2ec5e3d9..4e987f0a 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -338,6 +338,14 @@ export function getRoutes(nodekit: NodeKit, options: GetRoutesOptions) { write: true, }), + privateRestoreWorkbook: makeRoute({ + route: 'POST /private/v2/workbooks/:workbookId/restore', + handler: workbooksController.restore, + authPolicy: AuthPolicy.disabled, + private: true, + write: true, + }), + privateGetAllWorkbooks: makeRoute({ route: 'GET /private/all-workbooks', handler: workbooksController.getAll, diff --git a/src/services/new/workbook/formatters/format-restore-workbook.ts b/src/services/new/workbook/formatters/format-restore-workbook.ts new file mode 100644 index 00000000..d5de9bed --- /dev/null +++ b/src/services/new/workbook/formatters/format-restore-workbook.ts @@ -0,0 +1,7 @@ +import {WorkbookModel} from '../../../../db/models/new/workbook'; + +export const formatRestoreWorkbook = (workbookModel: WorkbookModel) => { + return { + workbookId: workbookModel.workbookId, + }; +}; diff --git a/src/services/new/workbook/formatters/index.ts b/src/services/new/workbook/formatters/index.ts index 9af0d06f..3a0ffea0 100644 --- a/src/services/new/workbook/formatters/index.ts +++ b/src/services/new/workbook/formatters/index.ts @@ -5,3 +5,4 @@ export * from './format-workbook-model'; export * from './format-workbook-model-with-operation'; export * from './format-workbook-models-list'; export * from './format-set-workbook-is-template'; +export * from './format-restore-workbook'; diff --git a/src/services/new/workbook/index.ts b/src/services/new/workbook/index.ts index 21222f7d..ca0629b8 100644 --- a/src/services/new/workbook/index.ts +++ b/src/services/new/workbook/index.ts @@ -11,3 +11,4 @@ export * from './set-workbook-is-template'; export * from './copy-workbook'; export * from './copy-workbook-template'; export * from './get-all-workbooks'; +export * from './restore-workbook'; diff --git a/src/services/new/workbook/restore-workbook.ts b/src/services/new/workbook/restore-workbook.ts new file mode 100644 index 00000000..109e47b2 --- /dev/null +++ b/src/services/new/workbook/restore-workbook.ts @@ -0,0 +1,112 @@ +import {transaction, raw} from 'objection'; +import {AppError} from '@gravity-ui/nodekit'; +import {ServiceArgs} from '../types'; +import {getReplica, getPrimary} from '../utils'; +import {makeSchemaValidator} from '../../../components/validation-schema-compiler'; +import {WorkbookModel, WorkbookModelColumn} from '../../../db/models/new/workbook'; +import Utils, {logInfo} from '../../../utils'; +import {Entry, EntryColumn} from '../../../db/models/new/entry'; + +import {US_ERRORS, CURRENT_TIMESTAMP, DEFAULT_QUERY_TIMEOUT, TRASH_FOLDER} from '../../../const'; + +const validateArgs = makeSchemaValidator({ + type: 'object', + required: ['workbookId'], + properties: { + workbookId: { + type: 'string', + }, + }, +}); + +export interface RestoreWorkbookArgs { + workbookId: string; +} + +export const restoreWorkbook = async ( + {ctx, trx, skipValidation = false}: ServiceArgs, + args: RestoreWorkbookArgs, +) => { + const {workbookId} = args; + + logInfo(ctx, 'RESTORE_WORKBOOK_START', { + workbookId: Utils.encodeId(workbookId), + }); + + if (!skipValidation) { + validateArgs(args); + } + + const {tenantId} = ctx.get('info'); + + const targetTrx = getReplica(trx); + + const model = await WorkbookModel.query(targetTrx) + .select() + .where({ + [WorkbookModelColumn.TenantId]: tenantId, + [WorkbookModelColumn.WorkbookId]: workbookId, + }) + .first() + .timeout(WorkbookModel.DEFAULT_QUERY_TIMEOUT); + + if (!model) { + throw new AppError(US_ERRORS.WORKBOOK_NOT_EXISTS, { + code: US_ERRORS.WORKBOOK_NOT_EXISTS, + }); + } + + if (model.deletedAt === null) { + throw new AppError(US_ERRORS.WORKBOOK_IS_ALREADY_RESTORED, { + code: US_ERRORS.WORKBOOK_IS_ALREADY_RESTORED, + }); + } + + const primaryTrx = getPrimary(trx); + + const result = await transaction(primaryTrx, async (transactionTrx) => { + const restoredWorkbook = await WorkbookModel.query(transactionTrx) + .patch({ + [WorkbookModelColumn.DeletedBy]: null, + [WorkbookModelColumn.DeletedAt]: null, + [WorkbookModelColumn.UpdatedAt]: raw(CURRENT_TIMESTAMP), + }) + .where({ + [WorkbookModelColumn.WorkbookId]: model.workbookId, + [WorkbookModelColumn.TenantId]: tenantId, + }) + .returning('*') + .first() + .timeout(WorkbookModel.DEFAULT_QUERY_TIMEOUT); + + await Entry.query(transactionTrx) + .patch({ + key: raw(`regexp_replace(key, '${TRASH_FOLDER}/', '')`), + displayKey: raw(`regexp_replace(display_key, '${TRASH_FOLDER}/', '')`), + innerMeta: raw(`inner_meta - 'oldKey' - 'oldDisplayKey'`), + isDeleted: false, + deletedAt: null, + updatedAt: raw(CURRENT_TIMESTAMP), + }) + .where({ + [EntryColumn.WorkbookId]: workbookId, + [EntryColumn.TenantId]: tenantId, + }) + .andWhere(EntryColumn.DeletedAt, '>=', model.deletedAt) + .timeout(DEFAULT_QUERY_TIMEOUT); + + return restoredWorkbook; + }); + + if (!result) { + throw new AppError(US_ERRORS.WORKBOOK_NOT_EXISTS, { + code: US_ERRORS.WORKBOOK_NOT_EXISTS, + }); + } + + ctx.log('RESTORE_WORKBOOK_FINISH', { + workbookId: Utils.encodeId(workbookId), + }); + + return result; +}; diff --git a/src/tests/int/common/workbooks.private.test.ts b/src/tests/int/common/workbooks.private.test.ts index 28ccd58b..6c976c22 100644 --- a/src/tests/int/common/workbooks.private.test.ts +++ b/src/tests/int/common/workbooks.private.test.ts @@ -140,3 +140,84 @@ describe('Private Entries in workboooks managment', () => { await withScopeHeaders(request(app).delete(`/v2/workbooks/${testWorkbookId}`)).expect(200); }); }); + +describe('Private for one workboook managment', () => { + test('Create workbook with entries – [POST /private/v2/workbooks]', async () => { + const testTitle = 'Test private workbook with entries title'; + const testDescription = 'Test private workbook with entries description'; + + const entry1Name = 'Entry in test workbook 1'; + + const responseWorkbook = await withScopeHeaders(request(app).post('/private/v2/workbooks')) + .set({[US_MASTER_TOKEN_HEADER]: usApp.config.masterToken}) + .send({ + title: testTitle, + description: testDescription, + }) + .expect(200); + + const {body: bodyWorkbook} = responseWorkbook; + + testWorkbookId = bodyWorkbook.workbookId; + + await withScopeHeaders(request(app).post('/v1/entries')) + .send({ + scope: 'dataset', + type: 'graph', + meta: {}, + data: {}, + name: entry1Name, + workbookId: testWorkbookId, + }) + .expect(200); + }); + + test('Restore workbook with entries – [POST /private/v2/workbooks/:workbookId/restore]', async () => { + await withScopeHeaders(request(app).delete(`/v2/workbooks/${testWorkbookId}`)).expect(200); + + await request(app).post(`/private/v2/workbooks/${testWorkbookId}/restore`).expect(403); + + const response = await withScopeHeaders( + request(app) + .post(`/private/v2/workbooks/${testWorkbookId}/restore`) + .set({[US_MASTER_TOKEN_HEADER]: usApp.config.masterToken}), + ).expect(200); + + const {body} = response; + + expect(body).toStrictEqual({ + workbookId: testWorkbookId, + }); + + const responseEntries = await withScopeHeaders( + request(app) + .get(`/private/v2/workbooks/${testWorkbookId}/entries`) + .set({[US_MASTER_TOKEN_HEADER]: usApp.config.masterToken}), + ).expect(200); + + const {body: bodyEntries} = responseEntries; + + expect(bodyEntries).toStrictEqual({ + entries: expect.arrayContaining([ + { + createdAt: expect.any(String), + createdBy: expect.any(String), + entryId: expect.any(String), + hidden: false, + isLocked: false, + key: expect.any(String), + meta: {}, + publishedId: null, + savedId: expect.any(String), + scope: 'dataset', + type: 'graph', + updatedAt: expect.any(String), + updatedBy: expect.any(String), + workbookId: testWorkbookId, + }, + ]), + }); + + await withScopeHeaders(request(app).delete(`/v2/workbooks/${testWorkbookId}`)).expect(200); + }); +});