diff --git a/README.md b/README.md index 5464277..6c42796 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,18 @@ Nao e necessario rodar o projeto localmente para validar o fluxo principal do de - Upstash Redis como Redis gerenciado para armazenar a fila e o estado dos jobs. - JSONPlaceholder como endpoint externo de simulacao da Meta API. +Documentacao interativa da API: + +```text +https://api-fury.onrender.com/docs +``` + +OpenAPI JSON: + +```text +https://api-fury.onrender.com/openapi.json +``` + ### Como testar o deploy Crie um job: @@ -58,6 +70,12 @@ Exemplo de retorno esperado apos o worker processar: Tambem e possivel testar validacao enviando um payload invalido. A API retorna `400` com os erros detalhados do Zod. +### Documentacao com Scalar + +Foi adicionado Scalar para expor uma documentacao interativa baseada em OpenAPI. A especificacao e gerada pelo `@nestjs/swagger`, a partir dos controllers e dos DTOs de documentacao, e renderizada pelo `@scalar/nestjs-api-reference` em `/docs`. + +Essa decisao deixa a avaliacao mais direta: quem revisar o projeto consegue ver os endpoints, payloads, enums, respostas esperadas e erros sem precisar ler todo o codigo primeiro. Como o desafio nao pede front-end, a documentacao interativa entrega uma experiencia de exploracao leve sem adicionar uma interface desnecessaria ao produto. + ### Decisao de deploy Foram consideradas algumas opcoes para deploy gratuito ou de baixo custo: @@ -87,6 +105,7 @@ O uso dessas ferramentas nao substituiu as decisoes tecnicas: a arquitetura, as - Redis local via Docker para desenvolvimento e Upstash Redis com TLS para deploy. - Testes unitarios cobrindo regra de idempotencia, validacao do webhook e cenarios de sucesso/falha/timeout da chamada externa. - CI no GitHub Actions rodando typecheck, lint, testes e build a cada push/PR na `main`. +- Documentacao Scalar com contrato OpenAPI para facilitar avaliacao, teste manual e entendimento dos payloads. - Cuidados de seguranca: `.env` fora do Git, `.env.example` documentado, `helmet`, logs sem payload completo e sem chaves sensiveis versionadas. ## Stack @@ -96,6 +115,7 @@ O uso dessas ferramentas nao substituiu as decisoes tecnicas: a arquitetura, as - NestJS - Zod - BullMQ +- Scalar API Reference - Redis local via Docker Compose ou Upstash Redis no deploy - Jest - ESLint @@ -150,6 +170,8 @@ Referencias oficiais consultadas: - `POST /webhook/violation` recebe o webhook. - `GET /` e `GET /health` retornam status operacional simples. +- `GET /docs` exibe a documentacao interativa Scalar. +- `GET /openapi.json` expoe a especificacao OpenAPI. - Payload validado com Zod e erro `400` detalhado em caso invalido. - BullMQ enfileira job `meta-ad-takedown`. - Redis roda localmente via Docker Compose. diff --git a/package-lock.json b/package-lock.json index 839ebc5..6f2a9bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.4.4", + "@scalar/nestjs-api-reference": "^1.1.16", "bullmq": "^5.0.0", "helmet": "^8.0.0", "ioredis": "^5.0.0", @@ -2062,6 +2064,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -2453,6 +2461,26 @@ } } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.22", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.22.tgz", @@ -2497,6 +2525,39 @@ } } }, + "node_modules/@nestjs/swagger": { + "version": "11.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.4.tgz", + "integrity": "sha512-VaIo1ruV2G7b+f2zPzkBSUNy9a/WQ9sg8TLKhWlrTfg4O6U10M/PA7Xi6XMXadOVhwOqoesijba8jH3i/3adrA==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.1", + "js-yaml": "4.1.1", + "lodash": "4.18.1", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.32.6" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "11.1.22", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.22.tgz", @@ -2565,6 +2626,99 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@scalar/client-side-rendering": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.1.9.tgz", + "integrity": "sha512-Gv3CK0X+BqXYkVJLTQXNM8YgdquhuGV4hKlsM8S9k5GGonj8LBDSAiuU3W48JGX1pY7hRotBGcIIU+1a1X1mcw==", + "license": "MIT", + "dependencies": { + "@scalar/schemas": "0.2.0", + "@scalar/types": "0.11.0", + "@scalar/validation": "0.5.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.8.0.tgz", + "integrity": "sha512-gmOC6VravNB9VDl6wnt/GOj4K/hn48tj5bpW4AM4MhH8Ubil6uu7g1DSoKHwltu8Ks79KEtR6JmOrROi9R7jaQ==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/nestjs-api-reference": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@scalar/nestjs-api-reference/-/nestjs-api-reference-1.1.16.tgz", + "integrity": "sha512-L1c5eZfFuiWu2ldfcF81xULEcEMk8OpRH61rAq9VkWDZFF4pcMXIuQcsPPHhSpmOzl66kmhUWj84Yt4xE6XwRA==", + "license": "MIT", + "dependencies": { + "@scalar/client-side-rendering": "0.1.9" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/schemas": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.2.0.tgz", + "integrity": "sha512-FOpNecNoEKo8SogHEsdWlVRN4Q4PH3dzuOBWMA5tGt1xLZu5iFnPG2X/h6+Z/mOR34c7iW+hiKTqdUZoDgT9CA==", + "license": "MIT", + "dependencies": { + "@scalar/validation": "0.5.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.11.0.tgz", + "integrity": "sha512-TGZR8sys1jRlaWxLYfBo8y6D1W4UClYuDmkJ6Hmsb7oNuqokEAAK+AVYIzascVdBmaly0D1fqoqK7CzuGYHgyg==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.8.0", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@scalar/validation": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.5.0.tgz", + "integrity": "sha512-48CS1B0C7im57RQCHhS0GKt+qDLxB34IX8rO91jZj9D+Y/OosQZG3Gy57gJdHqZoD7l0sPUZtF8tVYry3BxRtw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", @@ -3830,7 +3984,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-timsort": { @@ -6931,7 +7084,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7469,6 +7621,24 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -8730,6 +8900,15 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.6", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz", + "integrity": "sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -8756,6 +8935,18 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", diff --git a/package.json b/package.json index 52c741f..807f535 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.4.4", + "@scalar/nestjs-api-reference": "^1.1.16", "bullmq": "^5.0.0", "helmet": "^8.0.0", "ioredis": "^5.0.0", diff --git a/src/docs/openapi.ts b/src/docs/openapi.ts new file mode 100644 index 0000000..55a3f06 --- /dev/null +++ b/src/docs/openapi.ts @@ -0,0 +1,33 @@ +import type { INestApplication } from "@nestjs/common"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import { apiReference } from "@scalar/nestjs-api-reference"; + +export function setupOpenApiDocs(app: INestApplication): void { + const config = new DocumentBuilder() + .setTitle("FURY Click Hero API") + .setDescription( + "Mini-API para receber violacoes de anuncios, validar payloads, enfileirar jobs BullMQ e processar takedowns com uma chamada HTTP externa simulando a Meta Ads API." + ) + .setVersion("0.1.0") + .addTag("Health", "Status operacional da API") + .addTag("Takedown jobs", "Webhook de violacao e consulta de jobs") + .build(); + + const document = SwaggerModule.createDocument(app, config, { + operationIdFactory: (_controllerKey: string, methodKey: string) => methodKey + }); + + SwaggerModule.setup("openapi", app, document, { + ui: false, + raw: ["json"], + jsonDocumentUrl: "openapi.json" + }); + + app.use( + "/docs", + apiReference({ + theme: "purple", + url: "/openapi.json" + }) + ); +} diff --git a/src/interfaces/http/health.controller.ts b/src/interfaces/http/health.controller.ts index f22dcda..8933752 100644 --- a/src/interfaces/http/health.controller.ts +++ b/src/interfaces/http/health.controller.ts @@ -1,8 +1,26 @@ import { Controller, Get } from "@nestjs/common"; +import { ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger"; +@ApiTags("Health") @Controller() export class HealthController { @Get() + @ApiOperation({ summary: "Retorna informacoes basicas da API" }) + @ApiOkResponse({ + schema: { + example: { + name: "FURY Click Hero API", + status: "ok", + endpoints: { + webhook: "POST /webhook/violation", + jobStatus: "GET /jobs/:id", + health: "GET /health", + docs: "GET /docs", + openapi: "GET /openapi.json" + } + } + } + }) getRoot() { return { name: "FURY Click Hero API", @@ -10,12 +28,22 @@ export class HealthController { endpoints: { webhook: "POST /webhook/violation", jobStatus: "GET /jobs/:id", - health: "GET /health" + health: "GET /health", + docs: "GET /docs", + openapi: "GET /openapi.json" } }; } @Get("health") + @ApiOperation({ summary: "Health check simples" }) + @ApiOkResponse({ + schema: { + example: { + status: "ok" + } + } + }) getHealth() { return { status: "ok" diff --git a/src/main.ts b/src/main.ts index 054516d..7ed6717 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,13 +6,28 @@ import helmet from "helmet"; import { AppModule } from "./app.module"; import { AppConfigService } from "./config/app-config.service"; +import { setupOpenApiDocs } from "./docs/openapi"; async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); const config = app.get(AppConfigService); - app.use(helmet()); + app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + connectSrc: ["'self'", "https:"], + fontSrc: ["'self'", "data:", "https:"], + imgSrc: ["'self'", "data:", "https:"], + scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"], + styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"] + } + } + }) + ); app.enableShutdownHooks(); + setupOpenApiDocs(app); await app.listen(config.port, config.host); Logger.log(`API listening on ${config.host}:${config.port}`, "Bootstrap"); diff --git a/src/takedown/interfaces/http/takedown-docs.dto.ts b/src/takedown/interfaces/http/takedown-docs.dto.ts new file mode 100644 index 0000000..b3aaa47 --- /dev/null +++ b/src/takedown/interfaces/http/takedown-docs.dto.ts @@ -0,0 +1,103 @@ +import { ApiProperty } from "@nestjs/swagger"; + +import { severityLevels, violationTypes } from "../../domain/violation"; + +export class ViolationWebhookRequestDoc { + @ApiProperty({ example: "ad_123" }) + adId!: string; + + @ApiProperty({ example: "tenant_456" }) + tenantId!: string; + + @ApiProperty({ + enum: violationTypes, + example: "BRAND_VIOLATION" + }) + violationType!: string; + + @ApiProperty({ + enum: severityLevels, + example: "HIGH" + }) + severity!: string; + + @ApiProperty({ + example: "2026-05-21T14:00:00.000Z", + format: "date-time" + }) + detectedAt!: string; +} + +export class EnqueueTakedownResponseDoc { + @ApiProperty({ + example: + "takedown-7f4f372517f794c215145c728940cf6d48837708065410f168200edebed84435" + }) + jobId!: string; + + @ApiProperty({ example: "waiting" }) + status!: string; + + @ApiProperty({ example: false }) + deduplicated!: boolean; +} + +export class TakedownJobResultDoc { + @ApiProperty({ example: true }) + ok!: true; + + @ApiProperty({ example: 200 }) + externalStatus!: number; + + @ApiProperty({ + example: "2026-05-21T15:41:14.027Z", + format: "date-time" + }) + processedAt!: string; +} + +export class JobStatusResponseDoc { + @ApiProperty({ + example: + "takedown-7f4f372517f794c215145c728940cf6d48837708065410f168200edebed84435" + }) + jobId!: string; + + @ApiProperty({ example: "completed" }) + status!: string; + + @ApiProperty({ example: 1 }) + attempts!: number; + + @ApiProperty({ nullable: true, type: TakedownJobResultDoc }) + result!: TakedownJobResultDoc | null; + + @ApiProperty({ example: null, nullable: true }) + error!: string | null; +} + +export class ValidationIssueDoc { + @ApiProperty({ example: "adId" }) + path!: string; + + @ApiProperty({ example: "too_small" }) + code!: string; + + @ApiProperty({ + example: "Too small: expected string to have >=1 characters" + }) + message!: string; +} + +export class ValidationErrorResponseDoc { + @ApiProperty({ example: "Validation failed" }) + message!: string; + + @ApiProperty({ type: [ValidationIssueDoc] }) + errors!: ValidationIssueDoc[]; +} + +export class NotFoundResponseDoc { + @ApiProperty({ example: "Job not found" }) + message!: string; +} diff --git a/src/takedown/interfaces/http/takedown.controller.ts b/src/takedown/interfaces/http/takedown.controller.ts index a4e5e6f..d74e83c 100644 --- a/src/takedown/interfaces/http/takedown.controller.ts +++ b/src/takedown/interfaces/http/takedown.controller.ts @@ -8,9 +8,30 @@ import { Param, Post } from "@nestjs/common"; +import { + ApiAcceptedResponse, + ApiBadRequestResponse, + ApiBody, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags +} from "@nestjs/swagger"; +import type { + EnqueueTakedownJobResult, + JobStatusView +} from "../../application/ports/takedown-queue.port"; import { GetJobStatusUseCase } from "../../application/use-cases/get-job-status.use-case"; import { ReportViolationUseCase } from "../../application/use-cases/report-violation.use-case"; +import { + EnqueueTakedownResponseDoc, + JobStatusResponseDoc, + NotFoundResponseDoc, + ValidationErrorResponseDoc, + ViolationWebhookRequestDoc +} from "./takedown-docs.dto"; import { jobIdParamSchema, type JobIdParam, @@ -19,6 +40,7 @@ import { } from "./violation-webhook.schema"; import { ZodValidationPipe } from "./zod-validation.pipe"; +@ApiTags("Takedown jobs") @Controller() export class TakedownController { constructor( @@ -28,17 +50,47 @@ export class TakedownController { @Post("webhook/violation") @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ + summary: "Recebe uma violacao de anuncio e enfileira um takedown" + }) + @ApiBody({ type: ViolationWebhookRequestDoc }) + @ApiAcceptedResponse({ + type: EnqueueTakedownResponseDoc, + description: "Job aceito pela fila BullMQ." + }) + @ApiBadRequestResponse({ + type: ValidationErrorResponseDoc, + description: "Payload invalido, com erros detalhados por campo." + }) async reportViolation( @Body(new ZodValidationPipe(violationWebhookSchema)) payload: ViolationWebhookPayload - ) { + ): Promise { return this.reportViolationUseCase.execute(payload); } @Get("jobs/:id") + @ApiOperation({ summary: "Consulta o status atual de um job de takedown" }) + @ApiParam({ + name: "id", + example: + "takedown-7f4f372517f794c215145c728940cf6d48837708065410f168200edebed84435" + }) + @ApiOkResponse({ + type: JobStatusResponseDoc, + description: "Status atual do job na fila." + }) + @ApiBadRequestResponse({ + type: ValidationErrorResponseDoc, + description: "Formato de jobId invalido." + }) + @ApiNotFoundResponse({ + type: NotFoundResponseDoc, + description: "Job nao encontrado." + }) async getJobStatus( @Param(new ZodValidationPipe(jobIdParamSchema)) params: JobIdParam - ) { + ): Promise { const job = await this.getJobStatusUseCase.execute(params.id); if (!job) {