diff --git a/middleware/src/idempotency/idempotency.config.ts b/middleware/src/idempotency/idempotency.config.ts new file mode 100644 index 00000000..df590c17 --- /dev/null +++ b/middleware/src/idempotency/idempotency.config.ts @@ -0,0 +1,9 @@ +export const IDEMPOTENCY_CONFIG = { + ttl: { + puzzleSubmission: 300, // 5 minutes + pointClaim: 600, // 10 minutes + friendRequest: 3600, // 1 hour + profileUpdate: 60, // 1 minute + }, + headerKey: 'x-idempotency-key', +}; diff --git a/middleware/src/idempotency/idempotency.middleware.ts b/middleware/src/idempotency/idempotency.middleware.ts new file mode 100644 index 00000000..fa7e3cc4 --- /dev/null +++ b/middleware/src/idempotency/idempotency.middleware.ts @@ -0,0 +1,56 @@ +import { Injectable, NestMiddleware, BadRequestException } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { IdempotencyService } from './idempotency.service'; +import { IDEMPOTENCY_CONFIG } from './idempotency.config'; + +@Injectable() +export class IdempotencyMiddleware implements NestMiddleware { + constructor(private readonly idempotencyService: IdempotencyService) {} + + async use(req: Request, res: Response, next: NextFunction) { + // Skip GET requests + if (req.method === 'GET') return next(); + + const headerKey = IDEMPOTENCY_CONFIG.headerKey; + let idempotencyKey = req.headers[headerKey] as string; + + if (!idempotencyKey) { + // Auto-generate key if not provided + idempotencyKey = await this.idempotencyService.generateKey(req); + } + + if (typeof idempotencyKey !== 'string') { + throw new BadRequestException('Invalid idempotency key format'); + } + + const cachedResponse = await this.idempotencyService.getResponse(idempotencyKey); + if (cachedResponse) { + // Return cached response immediately + res.set(cachedResponse.headers); + return res.status(cachedResponse.statusCode).send(cachedResponse.body); + } + + // Intercept response to store it + const originalSend = res.send.bind(res); + res.send = async (body: any) => { + const ttl = this.resolveTTL(req.originalUrl); + const responsePayload = { + statusCode: res.statusCode, + headers: res.getHeaders(), + body, + }; + await this.idempotencyService.storeResponse(idempotencyKey, responsePayload, ttl); + return originalSend(body); + }; + + next(); + } + + private resolveTTL(url: string): number { + if (url.includes('/puzzles')) return IDEMPOTENCY_CONFIG.ttl.puzzleSubmission; + if (url.includes('/points')) return IDEMPOTENCY_CONFIG.ttl.pointClaim; + if (url.includes('/friends')) return IDEMPOTENCY_CONFIG.ttl.friendRequest; + if (url.includes('/profile')) return IDEMPOTENCY_CONFIG.ttl.profileUpdate; + return 300; // default + } +} diff --git a/middleware/src/idempotency/idempotency.service.ts b/middleware/src/idempotency/idempotency.service.ts new file mode 100644 index 00000000..fc2d410a --- /dev/null +++ b/middleware/src/idempotency/idempotency.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { RedisService } from '../../redis/redis.service'; +import * as crypto from 'crypto'; + +@Injectable() +export class IdempotencyService { + constructor(private readonly redisService: RedisService) {} + + async generateKey(req: any): Promise { + const userId = req.user?.id || 'anon'; + const bodyHash = crypto.createHash('sha256').update(JSON.stringify(req.body)).digest('hex'); + return `${userId}:${req.method}:${req.originalUrl}:${bodyHash}`; + } + + async storeResponse(key: string, response: any, ttl: number) { + const client = this.redisService.getClient(); + await client.set(key, JSON.stringify(response), 'EX', ttl, 'NX'); // SETNX for atomicity + } + + async getResponse(key: string): Promise { + const client = this.redisService.getClient(); + const data = await client.get(key); + return data ? JSON.parse(data) : null; + } +} diff --git a/middleware/src/transaction/transaction.logger.ts b/middleware/src/transaction/transaction.logger.ts new file mode 100644 index 00000000..2e923098 --- /dev/null +++ b/middleware/src/transaction/transaction.logger.ts @@ -0,0 +1,14 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class TransactionLogger { + private readonly logger = new Logger('Transaction'); + + log(message: string) { + this.logger.log(message); + } + + error(message: string, error: any) { + this.logger.error(`${message}: ${error.message}`, error.stack); + } +} diff --git a/middleware/src/transaction/transaction.manager.ts b/middleware/src/transaction/transaction.manager.ts new file mode 100644 index 00000000..8175ec7b --- /dev/null +++ b/middleware/src/transaction/transaction.manager.ts @@ -0,0 +1,36 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +export class TransactionManager { + private queryRunner: QueryRunner; + + constructor(private readonly dataSource: DataSource) { + this.queryRunner = this.dataSource.createQueryRunner(); + } + + async startTransaction(isolation: 'READ COMMITTED' | 'REPEATABLE READ' | 'SERIALIZABLE' = 'READ COMMITTED') { + await this.queryRunner.connect(); + await this.queryRunner.startTransaction(isolation); + } + + async commitTransaction() { + await this.queryRunner.commitTransaction(); + await this.queryRunner.release(); + } + + async rollbackTransaction() { + await this.queryRunner.rollbackTransaction(); + await this.queryRunner.release(); + } + + async createSavepoint(name: string) { + await this.queryRunner.query(`SAVEPOINT ${name}`); + } + + async rollbackToSavepoint(name: string) { + await this.queryRunner.query(`ROLLBACK TO SAVEPOINT ${name}`); + } + + getManager() { + return this.queryRunner.manager; + } +} diff --git a/middleware/src/transaction/transaction.middleware.ts b/middleware/src/transaction/transaction.middleware.ts new file mode 100644 index 00000000..11bb6803 --- /dev/null +++ b/middleware/src/transaction/transaction.middleware.ts @@ -0,0 +1,37 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { TransactionManager } from './transaction.manager'; +import { TransactionLogger } from './transaction.logger'; + +@Injectable() +export class TransactionMiddleware implements NestMiddleware { + constructor(private readonly dataSource: DataSource, private readonly logger: TransactionLogger) {} + + async use(req: Request, res: Response, next: NextFunction) { + const manager = new TransactionManager(this.dataSource); + + try { + await manager.startTransaction(); + + // Attach transaction manager to request for manual control if needed + (req as any).transactionManager = manager; + + res.on('finish', async () => { + if (res.statusCode >= 200 && res.statusCode < 400) { + await manager.commitTransaction(); + this.logger.log('Transaction committed successfully'); + } else { + await manager.rollbackTransaction(); + this.logger.log('Transaction rolled back due to error'); + } + }); + + next(); + } catch (error) { + await manager.rollbackTransaction(); + this.logger.error('Transaction failed', error); + next(error); + } + } +}