Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add private route for restore workbook #31

Merged
merged 13 commits into from
Dec 7, 2023
19 changes: 19 additions & 0 deletions src/controllers/workbooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getAllWorkbooks,
OrderField,
OrderDirection,
restoreWorkbook,
} from '../services/new/workbook';
import {
formatWorkbookModel,
Expand All @@ -25,6 +26,7 @@ import {
formatWorkbookModelWithOperation,
formatWorkbooksList,
formatSetWorkbookIsTemplate,
formatRestoreWorkbook,
} from '../services/new/workbook/formatters';

export default {
Expand Down Expand Up @@ -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);
},
};
8 changes: 8 additions & 0 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {WorkbookModel} from '../../../../db/models/new/workbook';

export const formatRestoreWorkbook = (workbookModel: WorkbookModel) => {
return {
workbookId: workbookModel.workbookId,
};
};
1 change: 1 addition & 0 deletions src/services/new/workbook/formatters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions src/services/new/workbook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
130 changes: 130 additions & 0 deletions src/services/new/workbook/restore-workbook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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: Optional<WorkbookModel> = await WorkbookModel.query(targetTrx)
.select()
.skipUndefined()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it needed here and below?

Copy link
Collaborator Author

@Sergey-weber Sergey-weber Dec 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnessesary, fixed it, was error when don't have tenantId

.where({
[WorkbookModelColumn.TenantId]: tenantId,
[WorkbookModelColumn.WorkbookId]: workbookId,
})
.andWhereNot(WorkbookModelColumn.DeletedAt, null)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's check this after the query, like:

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, { // new error with 400 status
        code: US_ERRORS.WORKBOOK_IS_ALREADY_RESTORED,
    });
}

.first()
.timeout(WorkbookModel.DEFAULT_QUERY_TIMEOUT);

if (!model) {
throw new AppError(US_ERRORS.WORKBOOK_NOT_EXISTS, {
code: US_ERRORS.WORKBOOK_NOT_EXISTS,
});
}

const entries = await Entry.query(targetTrx)
.select()
.skipUndefined()
.where({
[EntryColumn.WorkbookId]: workbookId,
[EntryColumn.TenantId]: tenantId,
[EntryColumn.IsDeleted]: true,
})
.timeout(Entry.DEFAULT_QUERY_TIMEOUT);

const primaryTrx = getPrimary(trx);

const result = await transaction(primaryTrx, async (transactionTrx) => {
const restoredWorkbook = await WorkbookModel.query(transactionTrx)
.skipUndefined()
.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 Promise.all(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can restore all the entries by one patch query:

UPDATE entries SET
	key = regexp_replace(key, '__trash/', ''),
	display_key = regexp_replace(display_key, '__trash/', ''),
	inner_meta = inner_meta - 'oldKey' - 'oldDisplayKey',
	is_deleted = FALSE,
	deleted_at = NULL,
	updated_at = NOW()
WHERE
	tenant_id = '{TENANT_ID}' AND workbook_id = {WORKBOOK_ID} AND deleted_at > '{WORKBOOK_DELETED_AT}';

And getting all the entries above will be unnecessary.

entries.map(async (entry) => {
const {entryId, displayKey, key} = entry;

const newInnerMeta = {
...entry.innerMeta,
oldKey: key as string,
oldDisplayKey: displayKey as string,
};

return await Entry.query(transactionTrx)
.patch({
key: key?.replace(TRASH_FOLDER + '/', ''),
displayKey: displayKey?.replace(TRASH_FOLDER + '/', ''),
innerMeta: newInnerMeta,
isDeleted: false,
deletedAt: null,
updatedAt: raw(CURRENT_TIMESTAMP),
})
.where({
entryId,
})
.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;
};
81 changes: 81 additions & 0 deletions src/tests/int/common/workbooks.private.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading