diff --git a/.gitignore b/.gitignore
index 5c69b1f..2de8d20 100644
--- a/.gitignore
+++ b/.gitignore
@@ -398,3 +398,6 @@ Temporary Items
dist
.webpack
.serverless/**/*.zip
+
+# Zed editor workspace
+.zed/
diff --git a/API_CONTRACTS.md b/API_CONTRACTS.md
index d3f041d..8211e10 100644
--- a/API_CONTRACTS.md
+++ b/API_CONTRACTS.md
@@ -37,33 +37,35 @@ Retorna a lista de impressoras instaladas no servidor onde a API está rodando.
### 2. Imprimir HTML (Etiquetas/Layouts)
-Converte um código HTML (com suporte a Tailwind CSS) para PDF e imprime na impressora selecionada. Ideal para etiquetas.
+Converte um código HTML (com suporte a Tailwind CSS) para PDF e imprime na impressora selecionada via Fila de Processamento (BullMQ).
- **Método:** `POST`
-- **Rota:** `/printer/print-html`
+- **Rota:** `/printer/print-html` ou `/printer/:printerName/print-html`
- **Corpo da Requisição (JSON):**
```json
{
"html": "
Minha Etiqueta
...",
- "printerName": "POS-80", // Nome exato da impressora (conforme retornado na listagem)
+ "printerName": "POS-80", // Opcional se passado na URL
"width": "60mm", // Opcional. Largura do papel/etiqueta. Default: 80mm
- "height": "40mm" // Opcional. Altura da etiqueta. Default: auto
+ "height": "40mm", // Opcional. Altura da etiqueta. Default: auto
+ "jobId": "meu-id-unico-123" // Opcional. ID para idempotência na fila.
}
```
- **Detalhes:**
- O backend injeta automaticamente o script do Tailwind CSS.
- O HTML é renderizado via Puppeteer (Chrome headless).
+ - Tarefas são processadas de forma assíncrona via Redis/BullMQ.
---
### 3. Imprimir Texto Simples (ESC/POS)
-Envia comandos de texto diretamente para a impressora. Ideal para cupons simples e rápidos, sem formatação complexa.
+Envia comandos de texto diretamente para a impressora. Ideal para cupons simples e rápidos.
- **Método:** `POST`
-- **Rota:** `/printer/print`
+- **Rota:** `/printer/print` ou `/printer/:printerName/print`
- **Corpo da Requisição (JSON):**
```json
@@ -76,7 +78,7 @@ Envia comandos de texto diretamente para a impressora. Ideal para cupons simples
],
"alignment": "center", // "left", "center", "right". Default: left
"upsideDown": false, // Se true, imprime de cabeça para baixo
- "printerInterface": "POS-80" // Pode ser nome da impressora (USB) ou "tcp://192.168.1.100" (Rede)
+ "printerInterface": "POS-80" // Opcional se passado na URL. Suporta "tcp://192.168.1.100" ou nome da impressora.
}
```
@@ -98,6 +100,7 @@ export interface PrintHtmlPayload {
printerName: string;
width?: string;
height?: string;
+ jobId?: string;
}
export interface PrintTextPayload {
@@ -107,3 +110,7 @@ export interface PrintTextPayload {
printerInterface?: string;
}
```
+
+## Swagger
+
+A documentação interativa completa (OpenAPI) está disponível em `/api`. Lá você pode testar todos os endpoints e ver os esquemas detalhados de cada DTO.
diff --git a/src/app.module.ts b/src/app.module.ts
index 2b69590..df88a81 100644
--- a/src/app.module.ts
+++ b/src/app.module.ts
@@ -1,21 +1,14 @@
import { Module } from '@nestjs/common';
-import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
-import { TypeOrmModule } from '@nestjs/typeorm';
-import { typeOrmConfig } from './config/typeorm.config';
-import { ListPrinterController } from './controller/list-printer.controller';
-import { ListPrinterService } from './services/create-printer';
+import { PrinterController } from './controller/printer.controller';
+import { PrinterService } from './services/printer.service';
import { PrinterProcessor } from './services/printer.processor';
-import { BullQueuesModule } from './config/BullModule';
+import { InfrastructureModule } from './infrastructure/infrastructure.module';
@Module({
- imports: [
- ConfigModule.forRoot({ isGlobal: true }),
- TypeOrmModule.forRoot(typeOrmConfig),
- BullQueuesModule
- ],
- controllers: [AppController, ListPrinterController],
- providers: [AppService, ListPrinterService, PrinterProcessor],
+ imports: [InfrastructureModule],
+ controllers: [AppController, PrinterController],
+ providers: [AppService, PrinterService, PrinterProcessor],
})
export class AppModule {}
diff --git a/src/config/BullModule.ts b/src/config/BullModule.ts
index a5dedce..e360332 100644
--- a/src/config/BullModule.ts
+++ b/src/config/BullModule.ts
@@ -5,15 +5,23 @@ import { ExpressAdapter } from '@bull-board/express';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ConfigModule, ConfigService } from '@nestjs/config';
+function stripWrappingQuotes(value: string | undefined): string | undefined {
+ if (value == null) return undefined;
+ const trimmed = value.trim();
+ return trimmed.replace(/^['"](.*)['"]$/, '$1');
+}
+
@Module({
imports: [
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
connection: {
- host: configService.get('REDIS_HOST'),
- port: configService.get('REDIS_PORT') || 6379,
- password: configService.get('REDIS_PASSWORD'),
+ host: stripWrappingQuotes(configService.get('REDIS_HOST')),
+ port: parseInt(configService.get('REDIS_PORT') ?? '6379', 10),
+ password: stripWrappingQuotes(
+ configService.get('REDIS_PASSWORD'),
+ ),
},
}),
inject: [ConfigService],
@@ -32,7 +40,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
type: 'exponential',
delay: 1000,
},
- removeOnComplete: false,
+ removeOnComplete: false,
removeOnFail: { count: 500 },
},
}),
@@ -44,4 +52,4 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
],
exports: [BullModule],
})
-export class BullQueuesModule {}
\ No newline at end of file
+export class BullQueuesModule {}
diff --git a/src/config/typeorm.config.ts b/src/config/typeorm.config.ts
index 38d27ca..0d918ca 100644
--- a/src/config/typeorm.config.ts
+++ b/src/config/typeorm.config.ts
@@ -1,20 +1,52 @@
-import { DataSource, DataSourceOptions } from 'typeorm';
+import { Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { TypeOrmModuleOptions } from '@nestjs/typeorm';
+import { DataSourceOptions } from 'typeorm';
-export const typeOrmConfig: DataSourceOptions = {
- type: 'oracle',
- username: process.env.DB_USERNAME || 'teste',
- password: process.env.DB_PASSWORD || 'teste',
- connectString:
- process.env.DB_CONNECT_STRING ||
- '(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=10.1.1.241)(PORT=1521))(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=BDTESTE)))',
- synchronize: false,
- logging: true,
- entities: [__dirname + '/../**/*.{entity,view}.{js,ts}'],
- subscribers: [process.env.DB_SUBSCRIBERS_PATH || 'dist/subscriber/*.js'],
- extra: {
- poolSize: parseInt(process.env.DB_POOL_SIZE || '10', 10),
- maxRows: parseInt(process.env.DB_MAX_ROWS || '1000', 10),
- },
-};
+export function createTypeOrmOptions(
+ configService: ConfigService,
+): TypeOrmModuleOptions {
+ const baseOptions: DataSourceOptions = {
+ type: 'oracle',
+ username: configService.get('DB_USERNAME') || 'teste',
+ password: configService.get('DB_PASSWORD') || 'teste',
+ connectString:
+ configService.get('DB_CONNECT_STRING') ||
+ '(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=10.1.1.241)(PORT=1521))(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=BDTESTE)))',
+ synchronize: false,
+ logging: true,
+ entities: [__dirname + '/../**/*.{entity,view}.{js,ts}'],
+ subscribers: [
+ configService.get('DB_SUBSCRIBERS_PATH') ||
+ 'dist/subscriber/*.js',
+ ],
+ extra: {
+ poolSize: parseInt(configService.get('DB_POOL_SIZE') || '10', 10),
+ maxRows: parseInt(configService.get('DB_MAX_ROWS') || '1000', 10),
+ },
+ };
-export const AppDataSource = new DataSource(typeOrmConfig);
+ const retryAttempts = parseInt(
+ configService.get('DB_RETRY_ATTEMPTS') || '5',
+ 10,
+ );
+ const retryDelayMs = parseInt(
+ configService.get('DB_RETRY_DELAY_MS') || '2000',
+ 10,
+ );
+
+ const logger = new Logger('TypeORM');
+
+ logger.debug(
+ `TypeORM configured for manual initialization (retryAttempts=${retryAttempts}, retryDelayMs=${retryDelayMs}).`,
+ );
+
+ return {
+ ...baseOptions,
+
+ manualInitialization: true,
+
+ retryAttempts: 0,
+ retryDelay: 0,
+ };
+}
diff --git a/src/controller/list-printer.controller.ts b/src/controller/printer.controller.ts
similarity index 72%
rename from src/controller/list-printer.controller.ts
rename to src/controller/printer.controller.ts
index 590d7a9..3ba5e95 100644
--- a/src/controller/list-printer.controller.ts
+++ b/src/controller/printer.controller.ts
@@ -1,6 +1,14 @@
-import { Controller, Post, Body, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
+import {
+ Controller,
+ Post,
+ Body,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Param,
+} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
-import { ListPrinterService } from '../services/create-printer';
+import { PrinterService } from '../services/printer.service';
import { PrintDataDto } from '../dto/print-data.dto';
import { PrintHtmlDto } from '../dto/print-html.dto';
import { PrinterDto } from '../dto/printer.dto';
@@ -8,16 +16,18 @@ import { getPrinters } from '../services/get-printer';
@ApiTags('Printer')
@Controller('printer')
-export class ListPrinterController {
- constructor(private readonly printerService: ListPrinterService) {}
+export class PrinterController {
+ constructor(private readonly printerService: PrinterService) {}
@Post(':printerName/print')
@HttpCode(HttpStatus.OK)
- @ApiOperation({ summary: 'Envia um comando de impressão de texto simples (ESC/POS)' })
+ @ApiOperation({
+ summary: 'Envia um comando de impressão de texto simples (ESC/POS)',
+ })
@ApiResponse({ status: 200, description: 'Impressão enviada com sucesso' })
async print(
@Param('printerName') printerName: string,
- @Body() printData: PrintDataDto
+ @Body() printData: PrintDataDto,
) {
printData.printerInterface = printerName;
return this.printerService.printReceipt(printData);
@@ -32,11 +42,14 @@ export class ListPrinterController {
@Post(':printerName/print-html')
@HttpCode(HttpStatus.OK)
- @ApiOperation({ summary: 'Converte HTML para PDF e imprime na impressora especificada na URL' })
+ @ApiOperation({
+ summary:
+ 'Converte HTML para PDF e imprime na impressora especificada na URL',
+ })
@ApiResponse({ status: 200, description: 'HTML enviado para impressão' })
async printHtml(
@Param('printerName') printerName: string,
- @Body() printHtmlDto: PrintHtmlDto
+ @Body() printHtmlDto: PrintHtmlDto,
) {
printHtmlDto.printerName = printerName;
return this.printerService.printHtml(printHtmlDto);
@@ -51,10 +64,10 @@ export class ListPrinterController {
@Get('list')
@ApiOperation({ summary: 'Lista impressoras disponíveis no sistema' })
- @ApiResponse({
- status: 200,
+ @ApiResponse({
+ status: 200,
description: 'Lista de impressoras encontradas',
- type: [PrinterDto]
+ type: [PrinterDto],
})
async listPrinters() {
return getPrinters();
diff --git a/src/database/database.module.ts b/src/database/database.module.ts
new file mode 100644
index 0000000..b17a942
--- /dev/null
+++ b/src/database/database.module.ts
@@ -0,0 +1,31 @@
+import { DynamicModule, Module } from '@nestjs/common';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { createTypeOrmOptions } from '../config/typeorm.config';
+import { parseBooleanEnv } from '../helpers/env.helper';
+import { OracleDbInitializer } from './oracle-db.initializer';
+
+@Module({})
+export class DatabaseModule {
+ static register(): DynamicModule {
+ const dbEnabled = parseBooleanEnv(process.env.DB_ENABLED, true);
+
+ if (!dbEnabled) {
+ return { module: DatabaseModule };
+ }
+
+ return {
+ module: DatabaseModule,
+ imports: [
+ TypeOrmModule.forRootAsync({
+ imports: [ConfigModule],
+ inject: [ConfigService],
+ useFactory: (configService: ConfigService) =>
+ createTypeOrmOptions(configService),
+ }),
+ ],
+ providers: [OracleDbInitializer],
+ exports: [TypeOrmModule],
+ };
+ }
+}
diff --git a/src/database/oracle-db.initializer.ts b/src/database/oracle-db.initializer.ts
new file mode 100644
index 0000000..89da2d9
--- /dev/null
+++ b/src/database/oracle-db.initializer.ts
@@ -0,0 +1,64 @@
+import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { DataSource } from 'typeorm';
+import { parseBooleanEnv } from '../helpers/env.helper';
+
+@Injectable()
+export class OracleDbInitializer implements OnApplicationBootstrap {
+ private readonly logger = new Logger(OracleDbInitializer.name);
+
+ constructor(
+ private readonly dataSource: DataSource,
+ private readonly configService: ConfigService,
+ ) {}
+
+ onApplicationBootstrap(): void {
+ const enabled = parseBooleanEnv(
+ this.configService.get('DB_ENABLED'),
+ true,
+ );
+ if (!enabled) {
+ this.logger.log('DB initialization skipped (DB_ENABLED=false).');
+ return;
+ }
+
+ if (this.dataSource.isInitialized) return;
+
+ void this.initializeWithRetry();
+ }
+
+ private async initializeWithRetry(): Promise {
+ const attempts = parseInt(
+ this.configService.get('DB_RETRY_ATTEMPTS') || '5',
+ 10,
+ );
+ const baseDelayMs = parseInt(
+ this.configService.get('DB_RETRY_DELAY_MS') || '2000',
+ 10,
+ );
+
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ try {
+ await this.dataSource.initialize();
+ this.logger.log(
+ `Oracle DB connected (attempt ${attempt}/${attempts}).`,
+ );
+ return;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ this.logger.warn(
+ `Oracle DB connection failed (attempt ${attempt}/${attempts}): ${message}`,
+ );
+
+ if (attempt < attempts) {
+ const delay = baseDelayMs * attempt;
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ }
+ }
+
+ this.logger.error(
+ `Oracle DB unreachable after ${attempts} attempts; continuing without DB (degraded mode).`,
+ );
+ }
+}
diff --git a/src/helpers/env.helper.ts b/src/helpers/env.helper.ts
new file mode 100644
index 0000000..cbc9828
--- /dev/null
+++ b/src/helpers/env.helper.ts
@@ -0,0 +1,15 @@
+export function parseBooleanEnv(
+ value: string | undefined,
+ defaultValue: boolean,
+): boolean {
+ if (value == null) return defaultValue;
+
+ const normalized = value
+ .trim()
+ .replace(/^['"]|['"]$/g, '')
+ .toLowerCase();
+
+ if (normalized === 'true') return true;
+ if (normalized === 'false') return false;
+ return defaultValue;
+}
diff --git a/src/infrastructure/infrastructure.module.ts b/src/infrastructure/infrastructure.module.ts
new file mode 100644
index 0000000..ec2a30d
--- /dev/null
+++ b/src/infrastructure/infrastructure.module.ts
@@ -0,0 +1,15 @@
+import { Global, Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+import { BullQueuesModule } from '../config/BullModule';
+import { DatabaseModule } from '../database/database.module';
+
+@Global()
+@Module({
+ imports: [
+ ConfigModule.forRoot({ isGlobal: true }),
+ DatabaseModule.register(),
+ BullQueuesModule,
+ ],
+ exports: [BullQueuesModule, DatabaseModule],
+})
+export class InfrastructureModule {}
diff --git a/src/main.ts b/src/main.ts
index 5cf35a1..cdbf065 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,11 +1,21 @@
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
+import { json, urlencoded } from 'express';
import { AppModule } from './app.module';
import { initializeOracleClient } from './helpers/oracle.helper';
+function getBodyLimit(): string {
+ const raw = process.env.BODY_LIMIT || '2mb';
+ return raw.trim().replace(/^['"]|['"]$/g, '');
+}
+
async function bootstrap() {
initializeOracleClient();
- const app = await NestFactory.create(AppModule);
+ const app = await NestFactory.create(AppModule, { bodyParser: false });
+
+ const bodyLimit = getBodyLimit();
+ app.use(json({ limit: bodyLimit }));
+ app.use(urlencoded({ extended: true, limit: bodyLimit }));
const config = new DocumentBuilder()
.setTitle('API Documentation')
diff --git a/src/services/printer.processor.ts b/src/services/printer.processor.ts
index c235893..e02bc83 100644
--- a/src/services/printer.processor.ts
+++ b/src/services/printer.processor.ts
@@ -1,26 +1,113 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
+import { Logger, OnApplicationShutdown } from '@nestjs/common';
import { Job } from 'bullmq';
+import { performance } from 'node:perf_hooks';
import * as puppeteer from 'puppeteer';
import * as pdfPrinter from 'pdf-to-printer';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
+import { PrintHtmlDto } from '../dto/print-html.dto';
@Processor('printer')
-export class PrinterProcessor extends WorkerHost {
-
- async process(job: Job): Promise {
+export class PrinterProcessor
+ extends WorkerHost
+ implements OnApplicationShutdown
+{
+ private readonly logger = new Logger(PrinterProcessor.name);
+ private browser: puppeteer.Browser | undefined;
+ private browserLaunching: Promise | undefined;
+
+ private async getBrowser(): Promise {
+ if (this.browser?.isConnected()) return this.browser;
+ if (this.browserLaunching) return this.browserLaunching;
+
+ this.browserLaunching = puppeteer
+ .launch({
+ headless: true,
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
+ })
+ .then((browser) => {
+ this.browser = browser;
+ return browser;
+ })
+ .finally(() => {
+ this.browserLaunching = undefined;
+ });
+
+ return this.browserLaunching;
+ }
+
+ async onApplicationShutdown(): Promise {
+ try {
+ await this.browser?.close();
+ } catch (err) {
+ this.logger.warn(`Failed to close Puppeteer browser: ${String(err)}`);
+ } finally {
+ this.browser = undefined;
+ }
+ }
+
+ private logJob(
+ level: 'log' | 'warn' | 'error' | 'debug',
+ payload: Record,
+ error?: unknown,
+ ): void {
+ const message = JSON.stringify(payload);
+ if (level === 'error') {
+ const stack = error instanceof Error ? error.stack : undefined;
+ this.logger.error(message, stack);
+ return;
+ }
+
+ if (level === 'warn') {
+ this.logger.warn(message);
+ return;
+ }
+
+ if (level === 'debug') {
+ this.logger.debug(message);
+ return;
+ }
+
+ this.logger.log(message);
+ }
+
+ async process(job: Job): Promise {
const data = job.data;
- const tempFilePath = path.join(os.tmpdir(), `print-${job.id}-${Date.now()}.pdf`);
- let browser;
+ const printerName = data?.printerName;
+ const jobCtx = {
+ jobId: job.id,
+ jobName: job.name,
+ printerName,
+ };
+
+ const tempFilePath = path.join(
+ os.tmpdir(),
+ `print-${job.id}-${Date.now()}.pdf`,
+ );
+ let page: puppeteer.Page | undefined;
+ let phase: 'render' | 'pdf' | 'spool' | 'unknown' = 'unknown';
+
+ const durationsMs: {
+ render?: number;
+ pdf?: number;
+ spool?: number;
+ total?: number;
+ } = {};
+
+ const totalStart = performance.now();
+
+ this.logJob('log', {
+ event: 'print_job_start',
+ ...jobCtx,
+ width: data?.width,
+ height: data?.height,
+ });
try {
- browser = await puppeteer.launch({
- headless: true,
- args: ['--no-sandbox', '--disable-setuid-sandbox']
- });
-
- const page = await browser.newPage();
+ const browser = await this.getBrowser();
+ page = await browser.newPage();
const width = data.width || '80mm';
const height = data.height;
@@ -40,30 +127,97 @@ export class PrinterProcessor extends WorkerHost {