Skip to content

Commit

Permalink
feat(api-headless-cms-tasks): delete model and entries (#4429)
Browse files Browse the repository at this point in the history
  • Loading branch information
brunozoric authored Dec 11, 2024
1 parent abc82dd commit 7009bc7
Show file tree
Hide file tree
Showing 36 changed files with 1,521 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const onPageRevisionAfterUpdateHook = (context: AuditLogsContext) => {
try {
const createAuditLog = getAuditConfig(AUDIT.PAGE_BUILDER.PAGE_REVISION.UPDATE);

await await createAuditLog(
await createAuditLog(
"Page revision updated",
{ before: original, after: page },
page.id,
Expand Down
11 changes: 10 additions & 1 deletion packages/api-headless-cms-tasks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,17 @@
},
"license": "MIT",
"dependencies": {
"@webiny/api": "0.0.0",
"@webiny/api-aco": "0.0.0",
"@webiny/api-headless-cms": "0.0.0",
"@webiny/api-headless-cms-bulk-actions": "0.0.0",
"@webiny/api-headless-cms-import-export": "0.0.0"
"@webiny/api-headless-cms-import-export": "0.0.0",
"@webiny/db": "0.0.0",
"@webiny/error": "0.0.0",
"@webiny/handler-graphql": "0.0.0",
"@webiny/tasks": "0.0.0",
"@webiny/utils": "0.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@webiny/cli": "0.0.0",
Expand Down
4 changes: 3 additions & 1 deletion packages/api-headless-cms-tasks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import {
createHcmsBulkActions
} from "@webiny/api-headless-cms-bulk-actions";
import { createHeadlessCmsImportExport } from "@webiny/api-headless-cms-import-export";
import { createDeleteModelTask } from "~/tasks/deleteModel";

export const createHcmsTasks = () => [
createHcmsBulkActions(),
createBulkActionEntriesTasks(),
createEmptyTrashBinsTask(),
createHeadlessCmsImportExport()
createHeadlessCmsImportExport(),
createDeleteModelTask()
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { ITaskResponse, ITaskResponseResult, ITaskRunParams } from "@webiny/tasks";
import { HcmsTasksContext } from "~/types";
import { IDeleteModelTaskInput, IDeleteModelTaskOutput, IStoreValue } from "./types";
import { CmsEntryListWhere, CmsModel } from "@webiny/api-headless-cms/types";
import { createStoreKey, createStoreValue } from "~/tasks/deleteModel/helpers/store";

export interface IDeleteModelRunnerParams<
C extends HcmsTasksContext,
I extends IDeleteModelTaskInput,
O extends IDeleteModelTaskOutput
> {
taskId: string;
context: C;
response: ITaskResponse<I, O>;
}

export type IExecuteParams<
C extends HcmsTasksContext,
I extends IDeleteModelTaskInput,
O extends IDeleteModelTaskOutput
> = Omit<ITaskRunParams<C, I, O>, "context" | "response" | "store" | "timer" | "trigger">;

export class DeleteModelRunner<
C extends HcmsTasksContext,
I extends IDeleteModelTaskInput,
O extends IDeleteModelTaskOutput
> {
private readonly taskId: string;
private readonly context: C;
private readonly response: ITaskResponse<I, O>;

public constructor(params: IDeleteModelRunnerParams<C, I, O>) {
this.taskId = params.taskId;
this.context = params.context;
this.response = params.response;
}

public async execute(params: IExecuteParams<C, I, O>): Promise<ITaskResponseResult<I, O>> {
const { input, isCloseToTimeout, isAborted } = params;

const model = await this.getModel(input.modelId);
/**
* We need to mark model as getting deleted, so that we can prevent any further operations on it.
*/
const gettingDeleted = await this.getDeletingTag(model);

if (!gettingDeleted) {
await this.addDeletingTag(model);
}

let hasMoreItems = false;
let lastDeletedId: string | undefined = input.lastDeletedId;
do {
if (isAborted()) {
/**
* If the task was aborted, we need to remove the task tag from the model.
*/
await this.removeDeletingTag(model);
return this.response.aborted();
} else if (isCloseToTimeout()) {
return this.response.continue({
...input,
lastDeletedId
});
}
let where: CmsEntryListWhere | undefined = undefined;
if (lastDeletedId) {
where = {
entryId_gte: lastDeletedId
};
}
const [items, meta] = await this.context.cms.listLatestEntries(model, {
limit: 1000,
where,
sort: ["id_ASC"]
});
for (const item of items) {
try {
await this.context.cms.deleteEntry(model, item.id, {
permanently: true,
force: true
});
} catch (ex) {
console.error("Failed to delete entry.", {
model: model.modelId,
id: item.id
});
return this.response.error(
new Error(`Failed to delete entry "${item.id}". Cannot continue.`)
);
}
lastDeletedId = item.entryId;
}

hasMoreItems = meta.hasMoreItems;
} while (hasMoreItems);
/**
* Let's do one more check. If there are items, continue the task with 5 seconds delay.
*/
const [items] = await this.context.cms.listLatestEntries(model, {
limit: 1
});
if (items.length > 0) {
console.log("There are still items to be deleted. Continuing the task.");
return this.response.continue(
{
...input
},
{
seconds: 5
}
);
}

let hasMoreFolders = false;
do {
const [items, meta] = await this.context.aco.folder.list({
where: {
type: `cms:${model.modelId}`
},
limit: 1000
});
for (const item of items) {
try {
await this.context.aco.folder.delete(item.id);
} catch (ex) {
console.error(`Failed to delete folder "${item.id}".`, ex);
return this.response.error(ex);
}
}

hasMoreFolders = meta.hasMoreItems;
} while (hasMoreFolders);

/**
* When there is no more records to be deleted, let's delete the model, if it's not a plugin.
*/
if (model.isPlugin) {
return this.response.done();
}
try {
await this.context.cms.deleteModel(model.modelId);
} catch (ex) {
await this.removeDeletingTag(model);
const message = `Failed to delete model "${model.modelId}".`;
console.error(message);
return this.response.error(ex);
}

return this.response.done();
}

private async getModel(modelId: string): Promise<CmsModel> {
const model = await this.context.cms.getModel(modelId);
if (!model) {
throw new Error(`Model "${modelId}" not found.`);
}
return model;
}

private async getDeletingTag(model: Pick<CmsModel, "modelId">): Promise<IStoreValue | null> {
const key = createStoreKey(model);
const value = await this.context.db.store.getValue<IStoreValue>(key);

return value.data || null;
}

private async addDeletingTag(model: Pick<CmsModel, "modelId">): Promise<void> {
const key = createStoreKey(model);
const value = createStoreValue({
model,
identity: this.context.security.getIdentity(),
task: this.taskId
});
await this.context.db.store.storeValue(key, value);
}

private async removeDeletingTag(model: Pick<CmsModel, "modelId">): Promise<void> {
const key = createStoreKey(model);
await this.context.db.store.removeValue(key);
}
}

export const createDeleteModelRunner = <
C extends HcmsTasksContext,
I extends IDeleteModelTaskInput,
O extends IDeleteModelTaskOutput
>(
params: IDeleteModelRunnerParams<C, I, O>
) => {
return new DeleteModelRunner<C, I, O>(params);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DELETE_MODEL_TASK = "deleteModelAndEntries";
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { HcmsTasksContext } from "~/types";
import {
IDeleteCmsModelTask,
IDeleteModelTaskInput,
IDeleteModelTaskOutput,
IStoreValue
} from "~/tasks/deleteModel/types";
import { DELETE_MODEL_TASK } from "~/tasks/deleteModel/constants";
import { WebinyError } from "@webiny/error";
import { getStatus } from "~/tasks/deleteModel/graphql/status";
import { createStoreKey } from "~/tasks/deleteModel/helpers/store";

export interface IAbortDeleteModelParams {
readonly context: Pick<HcmsTasksContext, "cms" | "tasks" | "db">;
readonly modelId: string;
}

export const abortDeleteModel = async (
params: IAbortDeleteModelParams
): Promise<IDeleteCmsModelTask> => {
const { context, modelId } = params;

const model = await context.cms.getModel(modelId);

await context.cms.accessControl.ensureCanAccessModel({
model,
rwd: "d"
});

await context.cms.accessControl.ensureCanAccessEntry({
model,
rwd: "w"
});

const storeKey = createStoreKey(model);

const result = await context.db.store.getValue<IStoreValue>(storeKey);

const taskId = result.data?.task;
if (!taskId) {
if (result.error) {
throw result.error;
}
throw new Error(`Model "${modelId}" is not being deleted.`);
}

await context.db.store.removeValue(storeKey);

const task = await context.tasks.getTask<IDeleteModelTaskInput, IDeleteModelTaskOutput>(taskId);
if (task?.definitionId !== DELETE_MODEL_TASK) {
throw new WebinyError({
message: `The task which is deleting a model cannot be found. Please check Step Functions for more info. Task id: ${taskId}`,
code: "DELETE_MODEL_TASK_NOT_FOUND",
data: {
model: model.modelId,
task: taskId
}
});
}

const abortedTask = await context.tasks.abort<IDeleteModelTaskInput, IDeleteModelTaskOutput>({
id: task.id,
message: "User aborted the task."
});

return {
id: abortedTask.id,
status: getStatus(abortedTask.taskStatus),
total: abortedTask.output?.total || 0,
deleted: abortedTask.output?.deleted || 0
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { HcmsTasksContext } from "~/types";
import { DELETE_MODEL_TASK } from "~/tasks/deleteModel/constants";
import { IDeleteCmsModelTask, IDeleteModelTaskInput, IStoreValue } from "~/tasks/deleteModel/types";
import { getStatus } from "~/tasks/deleteModel/graphql/status";
import { createStoreKey, createStoreValue } from "~/tasks/deleteModel/helpers/store";

export interface IFullyDeleteModelParams {
readonly context: Pick<HcmsTasksContext, "cms" | "tasks" | "db" | "security">;
readonly modelId: string;
}

export const fullyDeleteModel = async (
params: IFullyDeleteModelParams
): Promise<IDeleteCmsModelTask> => {
const { context, modelId } = params;

const model = await context.cms.getModel(modelId);

if (model.isPrivate) {
throw new Error(`Cannot delete private model.`);
}

await context.cms.accessControl.ensureCanAccessModel({
model,
rwd: "d"
});

await context.cms.accessControl.ensureCanAccessEntry({
model,
rwd: "w"
});

if (!model) {
throw new Error(`Model "${modelId}" not found.`);
}
const storeKey = createStoreKey(model);
const result = await context.db.store.getValue<IStoreValue>(storeKey);
const taskId = result.data?.task;
if (taskId) {
throw new Error(`Model "${modelId}" is already getting deleted. Task id: ${taskId}.`);
}

const task = await context.tasks.trigger<IDeleteModelTaskInput>({
input: {
modelId
},
definition: DELETE_MODEL_TASK,
name: `Fully delete model: ${modelId}`
});

await context.db.store.storeValue(
storeKey,
createStoreValue({
model,
identity: context.security.getIdentity(),
task: task.id
})
);

return {
id: task.id,
status: getStatus(task.taskStatus),
total: 0,
deleted: 0
};
};
Loading

0 comments on commit 7009bc7

Please sign in to comment.