From 7c1a24e2a04d48c841430bc906bc8fda24991230 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Thu, 26 Mar 2026 21:53:02 +0100 Subject: [PATCH 1/3] feat(database): implement global transaction middleware with TypeORM for ACID integrity --- backend/src/app.module.ts | 18 +++++---- .../transaction/transaction.logger.ts | 14 +++++++ .../transaction/transaction.manager.ts | 36 ++++++++++++++++++ .../transaction/transaction.middleware.ts | 37 +++++++++++++++++++ 4 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 backend/src/middleware/transaction/transaction.logger.ts create mode 100644 backend/src/middleware/transaction/transaction.manager.ts create mode 100644 backend/src/middleware/transaction/transaction.middleware.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7ef8089..d4e6b17 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; @@ -16,10 +16,8 @@ import { PuzzlesModule } from './puzzles/puzzles.module'; import { QuestsModule } from './quests/quests.module'; import { StreakModule } from './streak/strerak.module'; import { CategoriesModule } from './categories/categories.module'; - -// const ENV = process.env.NODE_ENV; -// console.log('NODE_ENV:', process.env.NODE_ENV); -// console.log('ENV:', ENV); +import { TransactionMiddleware } from './middleware/transaction/transaction.middleware'; +import { TransactionLogger } from './middleware/transaction/transaction.logger'; @Module({ imports: [ @@ -81,10 +79,14 @@ import { CategoriesModule } from './categories/categories.module'; CommonModule, RedisModule, BlockchainModule, - ProgressModule, CategoriesModule, ], controllers: [AppController], - providers: [AppService], + providers: [AppService, TransactionLogger], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + // Apply transaction middleware globally + consumer.apply(TransactionMiddleware).forRoutes('*'); + } +} diff --git a/backend/src/middleware/transaction/transaction.logger.ts b/backend/src/middleware/transaction/transaction.logger.ts new file mode 100644 index 0000000..2e92309 --- /dev/null +++ b/backend/src/middleware/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/backend/src/middleware/transaction/transaction.manager.ts b/backend/src/middleware/transaction/transaction.manager.ts new file mode 100644 index 0000000..8175ec7 --- /dev/null +++ b/backend/src/middleware/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/backend/src/middleware/transaction/transaction.middleware.ts b/backend/src/middleware/transaction/transaction.middleware.ts new file mode 100644 index 0000000..11bb680 --- /dev/null +++ b/backend/src/middleware/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); + } + } +} From c55dd4be426dea1bd4a03e32fe5667fa36bc422e Mon Sep 17 00:00:00 2001 From: mijinummi Date: Thu, 26 Mar 2026 23:16:57 +0100 Subject: [PATCH 2/3] feat(middleware): implement API response compression with Brotli/Gzip/Deflate --- backend/src/app.module.ts | 4 + .../compression/compression.config.ts | 20 +++ .../compression/compression.middleware.ts | 63 ++++++++ package-lock.json | 141 +++++++++++------- package.json | 3 +- 5 files changed, 179 insertions(+), 52 deletions(-) create mode 100644 backend/src/middleware/compression/compression.config.ts create mode 100644 backend/src/middleware/compression/compression.middleware.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d4e6b17..8a24c35 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { StreakModule } from './streak/strerak.module'; import { CategoriesModule } from './categories/categories.module'; import { TransactionMiddleware } from './middleware/transaction/transaction.middleware'; import { TransactionLogger } from './middleware/transaction/transaction.logger'; +import { CompressionMiddleware } from './middleware/compression/compression.middleware'; @Module({ imports: [ @@ -88,5 +89,8 @@ export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { // Apply transaction middleware globally consumer.apply(TransactionMiddleware).forRoutes('*'); + + // Apply compression middleware globally + consumer.apply(CompressionMiddleware).forRoutes('*'); } } diff --git a/backend/src/middleware/compression/compression.config.ts b/backend/src/middleware/compression/compression.config.ts new file mode 100644 index 0000000..8dfa33c --- /dev/null +++ b/backend/src/middleware/compression/compression.config.ts @@ -0,0 +1,20 @@ +export const COMPRESSION_CONFIG = { + threshold: 1024, // minimum size in bytes + gzip: { level: 6 }, // balance speed vs size + brotli: { quality: 4 }, // modern browsers + skipTypes: [ + /^image\//, + /^video\//, + /^audio\//, + /^application\/zip/, + /^application\/gzip/, + ], + compressibleTypes: [ + 'application/json', + 'text/html', + 'text/plain', + 'application/javascript', + 'text/css', + 'text/xml', + ], +}; diff --git a/backend/src/middleware/compression/compression.middleware.ts b/backend/src/middleware/compression/compression.middleware.ts new file mode 100644 index 0000000..c2c5081 --- /dev/null +++ b/backend/src/middleware/compression/compression.middleware.ts @@ -0,0 +1,63 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import * as zlib from 'zlib'; +import { COMPRESSION_CONFIG } from './compression.config'; + +@Injectable() +export class CompressionMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction) { + const acceptEncoding = req.headers['accept-encoding'] || ''; + const chunks: Buffer[] = []; + const originalWrite = res.write; + const originalEnd = res.end; + + // Intercept response body + res.write = function (chunk: any) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return true; + }; + + res.end = function (chunk: any) { + if (chunk) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + const body = Buffer.concat(chunks); + + // Skip compression if too small + if (body.length < COMPRESSION_CONFIG.threshold) { + return originalEnd.call(res, body); + } + + const contentType = res.getHeader('Content-Type') as string; + if (COMPRESSION_CONFIG.skipTypes.some((regex) => regex.test(contentType))) { + return originalEnd.call(res, body); + } + + // Select algorithm + if (/\bbr\b/.test(acceptEncoding)) { + zlib.brotliCompress(body, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: COMPRESSION_CONFIG.brotli.quality } }, (err, compressed) => { + if (err) return originalEnd.call(res, body); + res.setHeader('Content-Encoding', 'br'); + originalEnd.call(res, compressed); + }); + } else if (/\bgzip\b/.test(acceptEncoding)) { + zlib.gzip(body, { level: COMPRESSION_CONFIG.gzip.level }, (err, compressed) => { + if (err) return originalEnd.call(res, body); + res.setHeader('Content-Encoding', 'gzip'); + originalEnd.call(res, compressed); + }); + } else if (/\bdeflate\b/.test(acceptEncoding)) { + zlib.deflate(body, (err, compressed) => { + if (err) return originalEnd.call(res, body); + res.setHeader('Content-Encoding', 'deflate'); + originalEnd.call(res, compressed); + }); + } else { + return originalEnd.call(res, body); + } + }; + + next(); + } +} diff --git a/package-lock.json b/package-lock.json index b8ad7f0..f697b0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,9 @@ "dependencies": { "@nestjs/common": "^11.1.14", "@nestjs/core": "^11.1.14", - "framer-motion": "^12.34.3", "@tanstack/react-query": "^5.90.21", + "compression": "^1.8.1", + "framer-motion": "^12.34.3", "minimatch": "^10.1.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", @@ -203,7 +204,6 @@ "version": "0.6.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@xhmikosr/bin-wrapper": "^13.0.5", @@ -243,10 +243,9 @@ }, "backend/node_modules/@swc/core": { "version": "1.13.5", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.24" @@ -283,7 +282,6 @@ "version": "22.18.0", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -294,7 +292,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -436,7 +433,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -483,7 +479,6 @@ "version": "10.9.2", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -525,7 +520,6 @@ "backend/node_modules/typeorm": { "version": "0.3.26", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^3.17.0", @@ -652,7 +646,6 @@ "version": "5.8.3", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1077,7 +1070,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -3454,7 +3446,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.14.tgz", "integrity": "sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -3502,7 +3493,6 @@ "integrity": "sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3599,7 +3589,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz", "integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.2.1", @@ -4637,6 +4626,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4653,6 +4643,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4669,6 +4660,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4685,6 +4677,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4701,6 +4694,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4717,6 +4711,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4733,6 +4728,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4749,6 +4745,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4765,6 +4762,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4781,6 +4779,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4794,7 +4793,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@swc/helpers": { @@ -4810,7 +4809,7 @@ "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -5241,7 +5240,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5499,7 +5497,6 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5510,7 +5507,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5639,7 +5635,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -6359,7 +6354,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6432,7 +6426,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6853,7 +6846,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -7232,7 +7224,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7564,7 +7555,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -7621,15 +7611,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7888,6 +7876,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -8821,7 +8863,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8910,7 +8951,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9012,7 +9052,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9487,7 +9526,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -10827,7 +10865,6 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", "license": "MIT", - "peer": true, "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", @@ -11494,7 +11531,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -14015,6 +14051,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -14239,7 +14284,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -14409,7 +14453,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -14726,7 +14769,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14986,7 +15028,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14996,7 +15037,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -15016,7 +15056,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -15088,8 +15127,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -15104,8 +15142,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -15408,7 +15445,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -16059,7 +16095,6 @@ "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", "hasInstallScript": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", @@ -16830,7 +16865,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -17035,7 +17069,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17457,7 +17490,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17905,6 +17937,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -17923,6 +17956,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -17935,7 +17969,8 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", @@ -17943,6 +17978,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -17957,6 +17993,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -17966,7 +18003,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -17974,6 +18012,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/package.json b/package.json index cbd4eee..de8590b 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,9 @@ "dependencies": { "@nestjs/common": "^11.1.14", "@nestjs/core": "^11.1.14", - "framer-motion": "^12.34.3", "@tanstack/react-query": "^5.90.21", + "compression": "^1.8.1", + "framer-motion": "^12.34.3", "minimatch": "^10.1.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", From b53c22f150fd2f194cc78ee835f4e0bb10fc3feb Mon Sep 17 00:00:00 2001 From: mijinummi Date: Fri, 27 Mar 2026 07:12:39 +0100 Subject: [PATCH 3/3] feat(middleware): implement request deduplication middleware with Redis for idempotency --- backend/src/app.module.ts | 3 + .../idempotency/idempotency.config.ts | 9 +++ .../idempotency/idempotency.middleware.ts | 56 +++++++++++++++++++ .../idempotency/idempotency.service.ts | 25 +++++++++ 4 files changed, 93 insertions(+) create mode 100644 backend/src/middleware/idempotency/idempotency.config.ts create mode 100644 backend/src/middleware/idempotency/idempotency.middleware.ts create mode 100644 backend/src/middleware/idempotency/idempotency.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8a24c35..1decd56 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -19,6 +19,8 @@ import { CategoriesModule } from './categories/categories.module'; import { TransactionMiddleware } from './middleware/transaction/transaction.middleware'; import { TransactionLogger } from './middleware/transaction/transaction.logger'; import { CompressionMiddleware } from './middleware/compression/compression.middleware'; +import { IdempotencyMiddleware } from './middleware/idempotency/idempotency.middleware'; +import { IdempotencyService } from './middleware/idempotency/idempotency.service'; @Module({ imports: [ @@ -92,5 +94,6 @@ export class AppModule implements NestModule { // Apply compression middleware globally consumer.apply(CompressionMiddleware).forRoutes('*'); + consumer.apply(IdempotencyMiddleware).forRoutes('*'); // new } } diff --git a/backend/src/middleware/idempotency/idempotency.config.ts b/backend/src/middleware/idempotency/idempotency.config.ts new file mode 100644 index 0000000..df590c1 --- /dev/null +++ b/backend/src/middleware/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/backend/src/middleware/idempotency/idempotency.middleware.ts b/backend/src/middleware/idempotency/idempotency.middleware.ts new file mode 100644 index 0000000..fa7e3cc --- /dev/null +++ b/backend/src/middleware/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/backend/src/middleware/idempotency/idempotency.service.ts b/backend/src/middleware/idempotency/idempotency.service.ts new file mode 100644 index 0000000..fc2d410 --- /dev/null +++ b/backend/src/middleware/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; + } +}