From 24be7e882e24ff6e170c2b826253a77fd9903687 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Tue, 21 May 2024 12:54:24 -0700 Subject: [PATCH] feat: script runner part 1 (#4092) * feat: script runner beginings * feat: adding tests and update to service * fix: updates per eric --- .../11_add_script_runs/migration.sql | 14 ++ api/prisma/schema.prisma | 11 ++ .../controllers/script-runner.controller.ts | 33 +++++ api/src/modules/app.module.ts | 3 + api/src/modules/script-runner.module.ts | 13 ++ api/src/services/script-runner.service.ts | 87 +++++++++++++ api/test/unit/services/app.service.spec.ts | 1 - .../services/script-runner.service.spec.ts | 123 ++++++++++++++++++ shared-helpers/src/types/backend-swagger.ts | 19 +++ 9 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 api/prisma/migrations/11_add_script_runs/migration.sql create mode 100644 api/src/controllers/script-runner.controller.ts create mode 100644 api/src/modules/script-runner.module.ts create mode 100644 api/src/services/script-runner.service.ts create mode 100644 api/test/unit/services/script-runner.service.spec.ts diff --git a/api/prisma/migrations/11_add_script_runs/migration.sql b/api/prisma/migrations/11_add_script_runs/migration.sql new file mode 100644 index 0000000000..eb544eb3d3 --- /dev/null +++ b/api/prisma/migrations/11_add_script_runs/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "script_runs" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL, + "script_name" TEXT NOT NULL, + "triggering_user" UUID NOT NULL, + "did_script_run" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "script_runs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "script_runs_script_name_key" ON "script_runs"("script_name"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 69e7d09164..3b71e30f72 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -880,6 +880,17 @@ model UserPreferences { // END DETROIT SPECIFIC +model ScriptRuns { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + scriptName String @unique() @map("script_name") + triggeringUser String @map("triggering_user") @db.Uuid + didScriptRun Boolean @default(false) @map("did_script_run") + + @@map("script_runs") +} + enum ApplicationMethodsTypeEnum { Internal FileDownload diff --git a/api/src/controllers/script-runner.controller.ts b/api/src/controllers/script-runner.controller.ts new file mode 100644 index 0000000000..78c4e0d5f3 --- /dev/null +++ b/api/src/controllers/script-runner.controller.ts @@ -0,0 +1,33 @@ +import { + Controller, + Put, + Request, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { Request as ExpressRequest } from 'express'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ScriptRunnerService } from '../services/script-runner.service'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { OptionalAuthGuard } from '../guards/optional.guard'; +import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; + +@Controller('scriptRunner') +@ApiTags('scriptRunner') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) +export class ScirptRunnerController { + constructor(private readonly scriptRunnerService: ScriptRunnerService) {} + + @Put('exampleScript') + @ApiOperation({ + summary: 'An example of how the script runner can work', + operationId: 'exampleScript', + }) + @ApiOkResponse({ type: SuccessDTO }) + async update(@Request() req: ExpressRequest): Promise { + return await this.scriptRunnerService.example(req); + } +} diff --git a/api/src/modules/app.module.ts b/api/src/modules/app.module.ts index 787f233909..fae6d6894b 100644 --- a/api/src/modules/app.module.ts +++ b/api/src/modules/app.module.ts @@ -20,6 +20,7 @@ import { MapLayerModule } from './map-layer.module'; import { APP_GUARD } from '@nestjs/core'; import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottleGuard } from '../guards/throttler.guard'; +import { ScirptRunnerModule } from './script-runner.module'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { ThrottleGuard } from '../guards/throttler.guard'; AuthModule, ApplicationFlaggedSetModule, MapLayerModule, + ScirptRunnerModule, ThrottlerModule.forRoot([ { ttl: Number(process.env.THROTTLE_TTL), @@ -71,6 +73,7 @@ import { ThrottleGuard } from '../guards/throttler.guard'; AuthModule, ApplicationFlaggedSetModule, MapLayerModule, + ScirptRunnerModule, ], }) export class AppModule {} diff --git a/api/src/modules/script-runner.module.ts b/api/src/modules/script-runner.module.ts new file mode 100644 index 0000000000..dee4a6e945 --- /dev/null +++ b/api/src/modules/script-runner.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ScirptRunnerController } from '../controllers/script-runner.controller'; +import { ScriptRunnerService } from '../services/script-runner.service'; +import { PrismaModule } from './prisma.module'; +import { PermissionModule } from './permission.module'; + +@Module({ + imports: [PrismaModule, PermissionModule], + controllers: [ScirptRunnerController], + providers: [ScriptRunnerService], + exports: [ScriptRunnerService], +}) +export class ScirptRunnerModule {} diff --git a/api/src/services/script-runner.service.ts b/api/src/services/script-runner.service.ts new file mode 100644 index 0000000000..f4ca94c4b8 --- /dev/null +++ b/api/src/services/script-runner.service.ts @@ -0,0 +1,87 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { Request as ExpressRequest } from 'express'; +import { PrismaService } from './prisma.service'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { User } from '../dtos/users/user.dto'; +import { mapTo } from '../utilities/mapTo'; + +/** + this is the service for running scripts + most functions in here will be unique, but each function should only be allowed to fire once +*/ +@Injectable() +export class ScriptRunnerService { + constructor(private prisma: PrismaService) {} + + /** + this is simply an example + */ + async example(req: ExpressRequest): Promise { + const requestingUser = mapTo(User, req['user']); + await this.markScriptAsRunStart('example', requestingUser); + const rawJurisdictions = await this.prisma.jurisdictions.findMany(); + await this.markScriptAsComplete('example', requestingUser); + return { success: !!rawJurisdictions.length }; + } + + // |------------------ HELPERS GO BELOW ------------------ | // + + /** + * + * @param scriptName the name of the script that is going to be run + * @param userTriggeringTheRun the user that is attempting to trigger the script run + * @description this checks to see if the script has already ran, if not marks the script in the db + */ + async markScriptAsRunStart( + scriptName: string, + userTriggeringTheRun: User, + ): Promise { + // check to see if script is already ran in db + const storedScriptRun = await this.prisma.scriptRuns.findUnique({ + where: { + scriptName, + }, + }); + + if (storedScriptRun?.didScriptRun) { + // if script run has already successfully completed throw already succeed error + throw new BadRequestException( + `${scriptName} has already been run and succeeded`, + ); + } else if (storedScriptRun?.didScriptRun === false) { + // if script run was attempted but failed, throw attempt already failed error + throw new BadRequestException( + `${scriptName} has an attempted run and it failed, or is in progress. If it failed, please delete the db entry and try again`, + ); + } else { + // if no script run has been attempted create script run entry + await this.prisma.scriptRuns.create({ + data: { + scriptName, + triggeringUser: userTriggeringTheRun.id, + }, + }); + } + } + + /** + * + * @param scriptName the name of the script that is going to be run + * @param userTriggeringTheRun the user that is setting the script run as successfully completed + * @description this marks the script run entry in the db as successfully completed + */ + async markScriptAsComplete( + scriptName: string, + userTriggeringTheRun: User, + ): Promise { + await this.prisma.scriptRuns.update({ + data: { + didScriptRun: true, + triggeringUser: userTriggeringTheRun.id, + }, + where: { + scriptName, + }, + }); + } +} diff --git a/api/test/unit/services/app.service.spec.ts b/api/test/unit/services/app.service.spec.ts index 769f698986..bea848f872 100644 --- a/api/test/unit/services/app.service.spec.ts +++ b/api/test/unit/services/app.service.spec.ts @@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'; import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; -import { randomUUID } from 'crypto'; import { AppService } from '../../../src/services/app.service'; import { PrismaService } from '../../../src/services/prisma.service'; diff --git a/api/test/unit/services/script-runner.service.spec.ts b/api/test/unit/services/script-runner.service.spec.ts new file mode 100644 index 0000000000..8d0b08f316 --- /dev/null +++ b/api/test/unit/services/script-runner.service.spec.ts @@ -0,0 +1,123 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { ScriptRunnerService } from '../../../src/services/script-runner.service'; +import { PrismaService } from '../../../src/services/prisma.service'; +import { User } from '../../../src/dtos/users/user.dto'; + +describe('Testing script runner service', () => { + let service: ScriptRunnerService; + let prisma: PrismaService; + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ScriptRunnerService, + PrismaService, + Logger, + SchedulerRegistry, + ], + }).compile(); + + service = module.get(ScriptRunnerService); + prisma = module.get(PrismaService); + }); + + // | ---------- HELPER TESTS BELOW ---------- | // + it('should mark script run as started if no script run present in db', async () => { + prisma.scriptRuns.findUnique = jest.fn().mockResolvedValue(null); + prisma.scriptRuns.create = jest.fn().mockResolvedValue(null); + + const id = randomUUID(); + const scriptName = 'new run attempt'; + + await service.markScriptAsRunStart(scriptName, { + id, + } as unknown as User); + + expect(prisma.scriptRuns.findUnique).toHaveBeenCalledWith({ + where: { + scriptName, + }, + }); + expect(prisma.scriptRuns.create).toHaveBeenCalledWith({ + data: { + scriptName, + triggeringUser: id, + }, + }); + }); + + it('should error if script run is in progress or failed', async () => { + prisma.scriptRuns.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + didScriptRun: false, + }); + prisma.scriptRuns.create = jest.fn().mockResolvedValue(null); + + const id = randomUUID(); + const scriptName = 'new run attempt 2'; + + await expect( + async () => + await service.markScriptAsRunStart(scriptName, { + id, + } as unknown as User), + ).rejects.toThrowError( + `${scriptName} has an attempted run and it failed, or is in progress. If it failed, please delete the db entry and try again`, + ); + + expect(prisma.scriptRuns.findUnique).toHaveBeenCalledWith({ + where: { + scriptName, + }, + }); + expect(prisma.scriptRuns.create).not.toHaveBeenCalled(); + }); + + it('should error if script run already succeeded', async () => { + prisma.scriptRuns.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + didScriptRun: true, + }); + prisma.scriptRuns.create = jest.fn().mockResolvedValue(null); + + const id = randomUUID(); + const scriptName = 'new run attempt 3'; + + await expect( + async () => + await service.markScriptAsRunStart(scriptName, { + id, + } as unknown as User), + ).rejects.toThrowError(`${scriptName} has already been run and succeeded`); + + expect(prisma.scriptRuns.findUnique).toHaveBeenCalledWith({ + where: { + scriptName, + }, + }); + expect(prisma.scriptRuns.create).not.toHaveBeenCalled(); + }); + + it('should mark script run as started if no script run present in db', async () => { + prisma.scriptRuns.update = jest.fn().mockResolvedValue(null); + + const id = randomUUID(); + const scriptName = 'new run attempt 4'; + + await service.markScriptAsComplete(scriptName, { + id, + } as unknown as User); + + expect(prisma.scriptRuns.update).toHaveBeenCalledWith({ + data: { + didScriptRun: true, + triggeringUser: id, + }, + where: { + scriptName, + }, + }); + }); +}); diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index ded7fe88b1..c2478fdf73 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1995,6 +1995,25 @@ export class MapLayersService { } } +export class ScriptRunnerService { + /** + * An example of how the script runner can work + */ + exampleScript(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/scriptRunner/exampleScript" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = null + + configs.data = data + + axios(configs, resolve, reject) + }) + } +} + export interface SuccessDTO { /** */ success: boolean