Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.agents
challenge-1/node_modules
challenge-1/dist
challenge-2/node_modules
challenge-2/dist
challenge-3/node_modules
challenge-3/dist
challenge-1/.env
3 changes: 3 additions & 0 deletions challenge-1/.env_sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
BREVO_API_KEY=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
NOTIFY_TARGET_EMAIL=barfrank2020@gmail.com
SENDER_EMAIL=barfrank2020@gmail.com
4 changes: 4 additions & 0 deletions challenge-1/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
42 changes: 42 additions & 0 deletions challenge-1/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Stage 1: Build
FROM node:20-alpine AS build

WORKDIR /usr/src/app

# Copiar archivos de definición de dependencias
COPY package*.json ./
COPY tsconfig*.json ./
COPY nest-cli.json ./

# Instalar todas las dependencias (incluyendo devDependencies para compilar)
RUN npm install

# Copiar el código fuente y las librerías
COPY apps/ ./apps/
COPY libs/ ./libs/

# Compilar las tres aplicaciones
RUN npm run build api
RUN npm run build relay
RUN npm run build consumers

# Stage 2: Production
FROM node:20-alpine

WORKDIR /usr/src/app

# Copiar package.json y lock para instalar solo dependencias de producción
COPY package*.json ./
RUN npm install --omit=dev

# Copiar los artefactos compilados desde la etapa de build
COPY --from=build /usr/src/app/dist ./dist

# Copiar las plantillas de correo necesarias para NotifyConsumer
COPY templates ./templates

# Exponer el puerto de la API (por defecto 3001)
EXPOSE 3001

# El comando de inicio se sobreescribirá en docker-compose para cada servicio
CMD ["node", "dist/apps/api/main"]
730 changes: 730 additions & 0 deletions challenge-1/README.md

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions challenge-1/apps/api/src/app.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';

describe('AppController', () => {
let appController: AppController;

beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();

appController = app.get<AppController>(AppController);
});

describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
40 changes: 40 additions & 0 deletions challenge-1/apps/api/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Controller, Get, Post, Body, Param, Query, UseInterceptors } from '@nestjs/common';
import { AppService } from './app.service';
import { CreatePaymentDto, PaginationDto } from '@app/shared';
import { CacheInterceptor } from '@nestjs/cache-manager';

@Controller('payments')
@UseInterceptors(CacheInterceptor)
export class AppController {
constructor(private readonly appService: AppService) {}

@Post()
async createPayment(@Body() createPaymentDto: CreatePaymentDto) {
const payment = await this.appService.createPayment(createPaymentDto);
return payment;
}

@Get()
async getPayments(@Query() paginationDto: PaginationDto) {
return this.appService.getPayments(paginationDto);
}

// GET /payments/:id
// CONSISTENCY GUARANTEE: This endpoint reflects eventual consistency.
@Get(':id')
async getPayment(@Param('id') id: string) {
const payment = await this.appService.getPayment(id);
return {
data: {
paymentId: payment.id,
status: payment.status,
amount: payment.amount,
currency: payment.currency,
},
meta: {
consistencyModel: 'eventual',
note: 'Status may be pending while downstream consumers are processing.',
},
};
}
}
34 changes: 34 additions & 0 deletions challenge-1/apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { DatabaseModule } from '@app/shared';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { CacheModule } from '@nestjs/cache-manager';
import { PaymentsRepository } from './payments/payments.repository';

@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DatabaseModule,
ThrottlerModule.forRoot([{
ttl: 60000,
limit: 100,
}]),
CacheModule.register({
ttl: 5000, // 5 seconds default
max: 100, // Maximum items in cache
}),
],
controllers: [AppController],
providers: [
AppService,
PaymentsRepository,
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}
71 changes: 71 additions & 0 deletions challenge-1/apps/api/src/app.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Payment, PaymentStatus, OutboxEvent, OutboxStatus, CreatePaymentDto } from '@app/shared';
import { v4 as uuidv4 } from 'uuid';
import { PaymentsRepository, PaginationOptions } from './payments/payments.repository';

@Injectable()
export class AppService {
constructor(
private dataSource: DataSource,
private paymentsRepository: PaymentsRepository,
) {}

async createPayment(dto: CreatePaymentDto) {
const { amount, currency, country } = dto;
const paymentId = uuidv4();
const eventId = uuidv4();

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

let payment: Payment;

try {
payment = queryRunner.manager.create(Payment, {
id: paymentId,
amount,
currency,
country,
status: PaymentStatus.PENDING,
});

await queryRunner.manager.save(payment);

const outboxEvent = queryRunner.manager.create(OutboxEvent, {
eventId,
aggregateId: paymentId,
eventType: 'payment.created.v1',
payload: {
id: paymentId,
amount,
currency,
country,
},
status: OutboxStatus.PENDING,
});

await queryRunner.manager.save(outboxEvent);

await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}

return payment;
}

async getPayment(id: string) {
const payment = await this.paymentsRepository.findOne({ where: { id } });
if (!payment) throw new NotFoundException('Payment not found');
return payment;
}

async getPayments(options: PaginationOptions) {
return this.paymentsRepository.findPaginated(options);
}
}
49 changes: 49 additions & 0 deletions challenge-1/apps/api/src/common/filters/all-exceptions.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);

constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();

const httpStatus =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;

const message =
exception instanceof HttpException
? exception.getResponse()
: 'Internal Server Error';

// Log the detailed error internally
this.logger.error(
`Exception: ${exception instanceof Error ? exception.message : JSON.stringify(exception)}`,
exception instanceof Error ? exception.stack : '',
);

const responseBody = {
statusCode: httpStatus,
timestamp: new Date().toISOString(),
path: httpAdapter.getRequestUrl(ctx.getRequest()),
// In production, we only reveal the message if it's an HttpException
message: httpStatus === HttpStatus.INTERNAL_SERVER_ERROR
? 'Un fallo interno ha ocurrido. Por favor contacte al administrador.'
: message,
};

httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
}
}
22 changes: 22 additions & 0 deletions challenge-1/apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NestFactory, HttpAdapterHost } from '@nestjs/core';
import { AppModule } from './app.module';
import { ZodValidationPipe } from 'nestjs-zod';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { Logger } from '@nestjs/common';

async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);

// Use Zod for validation
app.useGlobalPipes(new ZodValidationPipe());

// Use Global Exception Filter
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new AllExceptionsFilter(httpAdapterHost));

const port = process.env.PORT ?? 3001;
await app.listen(port);
logger.log(`API is running on: http://localhost:${port}`);
}
bootstrap();
44 changes: 44 additions & 0 deletions challenge-1/apps/api/src/payments/payments.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, Repository, LessThan, MoreThan } from 'typeorm';
import { Payment } from '@app/shared';

export interface PaginationOptions {
limit: number;
offset?: number;
cursor?: string; // payment ID
}

@Injectable()
export class PaymentsRepository extends Repository<Payment> {
private readonly logger = new Logger(PaymentsRepository.name);

constructor(private dataSource: DataSource) {
super(Payment, dataSource.createEntityManager());
}

async findPaginated(options: PaginationOptions) {
const { limit, offset, cursor } = options;
const queryBuilder = this.createQueryBuilder('payment');

queryBuilder.take(limit);

if (cursor) {
// Cursor-based pagination (using ID as simple cursor)
// For more complex feeds, use a composite key or a sortable field like createdAt
queryBuilder.where('payment.id > :cursor', { cursor });
} else if (offset !== undefined) {
// Offset-based pagination
queryBuilder.skip(offset);
}

queryBuilder.orderBy('payment.createdAt', 'DESC');

const [items, total] = await queryBuilder.getManyAndCount();

return {
items,
total,
nextCursor: items.length === limit ? items[items.length - 1].id : null,
};
}
}
29 changes: 29 additions & 0 deletions challenge-1/apps/api/test/app.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
let app: INestApplication<App>;

beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();

app = moduleFixture.createNestApplication();
await app.init();
});

it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});

afterEach(async () => {
await app.close();
});
});
9 changes: 9 additions & 0 deletions challenge-1/apps/api/test/jest-e2e.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
9 changes: 9 additions & 0 deletions challenge-1/apps/api/tsconfig.app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": false,
"outDir": "../../dist/apps/api"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}
Loading