From 024702dd26936f47b42a643677289d053dcbee85 Mon Sep 17 00:00:00 2001 From: sauravdhakal12 Date: Fri, 27 Feb 2026 21:26:36 +0545 Subject: [PATCH] wip: added cache --- package.json | 3 + pnpm-lock.yaml | 101 ++++++++++++++++++ src/app.module.ts | 8 +- src/cache/cache.module.ts | 32 ++++++ src/cache/cache.service.spec.ts | 18 ++++ src/cache/cache.service.ts | 63 +++++++++++ .../organization-membership.service.ts | 6 +- src/organization/organization.module.ts | 2 + src/organization/organization.service.ts | 27 ++++- 9 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 src/cache/cache.module.ts create mode 100644 src/cache/cache.service.spec.ts create mode 100644 src/cache/cache.service.ts diff --git a/package.json b/package.json index 24714b9..cc5c20c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "prisma:generate": "prisma generate" }, "dependencies": { + "@keyv/redis": "^5.1.6", + "@nestjs/cache-manager": "^3.1.0", "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "@nestjs/jwt": "^11.0.2", @@ -32,6 +34,7 @@ "@prisma/adapter-pg": "^7.3.0", "@prisma/client": "^7.3.0", "bcrypt": "^6.0.0", + "cache-manager": "^7.2.8", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "pg": "^8.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0db4edd..b6d4c89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@keyv/redis': + specifier: ^5.1.6 + version: 5.1.6(keyv@5.6.0) + '@nestjs/cache-manager': + specifier: ^3.1.0 + version: 3.1.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2) '@nestjs/common': specifier: ^11.0.1 version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -32,6 +38,9 @@ importers: bcrypt: specifier: ^6.0.0 version: 6.0.0 + cache-manager: + specifier: ^7.2.8 + version: 7.2.8 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -337,6 +346,9 @@ packages: '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@cacheable/utils@2.3.4': + resolution: {integrity: sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==} + '@chevrotain/cst-dts-gen@10.5.0': resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} @@ -678,6 +690,15 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@keyv/redis@5.1.6': + resolution: {integrity: sha512-eKvW6pspvVaU5dxigaIDZr635/Uw6urTXL3gNbY9WTR8d3QigZQT+r8gxYSEOsw4+1cCBsC4s7T2ptR0WC9LfQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -795,6 +816,15 @@ packages: resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} engines: {node: '>= 10'} + '@nestjs/cache-manager@3.1.0': + resolution: {integrity: sha512-pEIqYZrBcE8UdkJmZRduurvoUfdU+3kRPeO1R2muiMbZnRuqlki5klFFNllO9LyYWzrx98bd1j0PSPKSJk1Wbw==} + peerDependencies: + '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0 + cache-manager: '>=6' + keyv: '>=5' + rxjs: ^7.8.1 + '@nestjs/cli@11.0.16': resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} engines: {node: '>= 20.11'} @@ -990,6 +1020,15 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + '@redis/client@5.11.0': + resolution: {integrity: sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==} + engines: {node: '>= 18'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} @@ -1615,6 +1654,9 @@ packages: magicast: optional: true + cache-manager@7.2.8: + resolution: {integrity: sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==} + cacheable-lookup@7.0.0: resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} engines: {node: '>=14.16'} @@ -1711,6 +1753,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -2333,6 +2379,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hashery@1.5.0: + resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} + engines: {node: '>=20'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -2341,6 +2391,9 @@ packages: resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} engines: {node: '>=16.9.0'} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2676,6 +2729,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -4084,6 +4140,11 @@ snapshots: '@borewit/text-codec@0.2.1': {} + '@cacheable/utils@2.3.4': + dependencies: + hashery: 1.5.0 + keyv: 5.6.0 + '@chevrotain/cst-dts-gen@10.5.0': dependencies: '@chevrotain/gast': 10.5.0 @@ -4524,6 +4585,17 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@keyv/redis@5.1.6(keyv@5.6.0)': + dependencies: + '@redis/client': 5.11.0 + cluster-key-slot: 1.1.2 + hookified: 1.15.1 + keyv: 5.6.0 + transitivePeerDependencies: + - '@node-rs/xxhash' + + '@keyv/serialize@1.1.1': {} + '@lukeed/csprng@1.1.0': {} '@microsoft/tsdoc@0.16.0': {} @@ -4605,6 +4677,14 @@ snapshots: '@napi-rs/nice-win32-x64-msvc': 1.1.1 optional: true + '@nestjs/cache-manager@3.1.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cache-manager: 7.2.8 + keyv: 5.6.0 + rxjs: 7.8.2 + '@nestjs/cli@11.0.16(@swc/cli@0.6.0(@swc/core@1.15.11)(chokidar@4.0.3))(@swc/core@1.15.11)(@types/node@22.19.10)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) @@ -4842,6 +4922,10 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@redis/client@5.11.0': + dependencies: + cluster-key-slot: 1.1.2 + '@scarf/scarf@1.4.0': {} '@sinclair/typebox@0.27.10': {} @@ -5621,6 +5705,11 @@ snapshots: pkg-types: 2.3.0 rc9: 2.1.2 + cache-manager@7.2.8: + dependencies: + '@cacheable/utils': 2.3.4 + keyv: 5.6.0 + cacheable-lookup@7.0.0: {} cacheable-request@10.2.14: @@ -5715,6 +5804,8 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.2: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -6366,12 +6457,18 @@ snapshots: dependencies: has-symbols: 1.1.0 + hashery@1.5.0: + dependencies: + hookified: 1.15.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 hono@4.11.4: {} + hookified@1.15.1: {} + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: {} @@ -6881,6 +6978,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + kind-of@6.0.3: {} kleur@3.0.3: {} diff --git a/src/app.module.ts b/src/app.module.ts index b18caf1..92b6c47 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,11 +9,11 @@ import { ConfigModule } from '@nestjs/config'; import { PrismaModule } from './prisma/prisma.module'; import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; import { ResponseInterceptor } from 'common/interceptors/response.interceptor'; -import { ExceptionsHandler } from '@nestjs/core/exceptions/exceptions-handler'; import { HttpExceptionFilter } from 'common/exceptions/exception-filter'; import { OrganizationModule } from './organization/organization.module'; import { OrganizationMembershipModule } from './organization-membership/organization-membership.module'; import { AuthorizationModule } from './authorization/authorization.module'; +import { CacheModule } from './cache/cache.module'; @Module({ imports: [ @@ -27,6 +27,7 @@ import { AuthorizationModule } from './authorization/authorization.module'; OrganizationModule, OrganizationMembershipModule, AuthorizationModule, + CacheModule, ], controllers: [AppController], providers: [ @@ -40,6 +41,11 @@ import { AuthorizationModule } from './authorization/authorization.module'; provide: APP_FILTER, useClass: HttpExceptionFilter, }, + // NOTE: Auto cache controller response + // { + // provide: APP_INTERCEPTOR, + // useClass: CacheInterceptor, + // }, ], }) export class AppModule implements NestModule { diff --git a/src/cache/cache.module.ts b/src/cache/cache.module.ts new file mode 100644 index 0000000..842f55c --- /dev/null +++ b/src/cache/cache.module.ts @@ -0,0 +1,32 @@ +import { Module } from '@nestjs/common'; +import { CacheModule as NestCacheManager } from '@nestjs/cache-manager'; +import KeyvRedis from '@keyv/redis'; +import { ConfigService } from '@nestjs/config'; +import { CacheService } from './cache.service'; + +@Module({ + imports: [ + NestCacheManager.registerAsync({ + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + const redisUrl = configService.get('REDIS_URL'); + const redisStore = new KeyvRedis(redisUrl, { + connectionTimeout: 1000, + }); + + redisStore.on('error', (err) => { + console.error('Redis error:', err.message); + }); + + return { + stores: [redisStore], + ttl: 120 * 1000, + }; + }, + isGlobal: true, + }), + ], + providers: [CacheService], + exports: [CacheService], +}) +export class CacheModule {} diff --git a/src/cache/cache.service.spec.ts b/src/cache/cache.service.spec.ts new file mode 100644 index 0000000..26276e0 --- /dev/null +++ b/src/cache/cache.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CacheService } from './cache.service'; + +describe('CacheService', () => { + let service: CacheService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CacheService], + }).compile(); + + service = module.get(CacheService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts new file mode 100644 index 0000000..fca333c --- /dev/null +++ b/src/cache/cache.service.ts @@ -0,0 +1,63 @@ +import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class CacheService { + private redisAvailable = true; + + constructor(@Inject(CACHE_MANAGER) private cache: Cache) { + const store = this.cache.stores[0]; + + if (store?.on) { + store.on('end', () => { + this.redisAvailable = false; + console.warn('Redis disconnected'); + }); + + store.on('ready', () => { + this.redisAvailable = true; + console.log('Redis ready'); + }); + + store.on('error', () => { + this.redisAvailable = false; + }); + } + } + + async getOrSet( + key: string, + factory: () => Promise, + ttl?: number, + ): Promise { + if (this.redisAvailable) { + try { + const cached = await this.cache.get(key); + if (cached) { + return cached; + } + } catch { + this.redisAvailable = false; + } + } + + // Fallback to DB + const fresh = await factory(); + + // Try setting cache only if Redis available + if (this.redisAvailable) { + try { + await this.cache.set(key, fresh, ttl); + } catch { + this.redisAvailable = false; + } + } + + return fresh; + } + + async deleteKey(key: string) { + const a = await this.cache.del(key); + console.log(a); + } +} diff --git a/src/organization-membership/organization-membership.service.ts b/src/organization-membership/organization-membership.service.ts index 2d745ec..9f098a2 100644 --- a/src/organization-membership/organization-membership.service.ts +++ b/src/organization-membership/organization-membership.service.ts @@ -30,7 +30,7 @@ export class OrganizationMembershipService { ) { const { invitedUserEmail, ...rest } = dto; const [orgExists, invitedUser] = await Promise.all([ - this.orgService.findById(dto.orgId), + this.orgService.organizationExists(dto.orgId), this.userService.findByEmail(invitedUserEmail), ]); @@ -81,7 +81,7 @@ export class OrganizationMembershipService { // TODO: reject, rejectReason async acceptInvitation(userId: string, orgId: string) { - const orgExists = await this.orgService.findById(orgId); + const orgExists = await this.orgService.organizationExists(orgId); if (!orgExists) throw new NotFoundException('Organization'); const [userAlreadyPart, isUserInvited] = await Promise.all([ @@ -154,7 +154,7 @@ export class OrganizationMembershipService { } async getMemebersOfOrganization(orgId: string) { - const orgExists = await this.orgService.findById(orgId); + const orgExists = await this.orgService.organizationExists(orgId); if (!orgExists) throw new NotFoundException('Organization'); return await this.prisma.organizationUserJoinTable.findMany({ diff --git a/src/organization/organization.module.ts b/src/organization/organization.module.ts index efb809c..ffcd477 100644 --- a/src/organization/organization.module.ts +++ b/src/organization/organization.module.ts @@ -5,6 +5,7 @@ import { PrismaModule } from 'src/prisma/prisma.module'; import { RequestContextModule } from 'core/als/request-context.module'; import { UserModule } from 'src/user/user.module'; import { AuthorizationModule } from 'src/authorization/authorization.module'; +import { CacheModule } from 'src/cache/cache.module'; @Module({ controllers: [OrganizationController], @@ -14,6 +15,7 @@ import { AuthorizationModule } from 'src/authorization/authorization.module'; RequestContextModule, UserModule, AuthorizationModule, + CacheModule, ], exports: [OrganizationService], }) diff --git a/src/organization/organization.service.ts b/src/organization/organization.service.ts index e2f6a6d..d57d191 100644 --- a/src/organization/organization.service.ts +++ b/src/organization/organization.service.ts @@ -1,5 +1,6 @@ import { ForbiddenException, + Inject, Injectable, NotFoundException, } from '@nestjs/common'; @@ -8,10 +9,10 @@ import { UpdateOrganizationRequestDTO, } from './dtos'; import { PrismaService } from 'src/prisma/prisma.service'; -import { RequestContextService } from 'core/als/request-context.service'; import { ORG_ROLE } from 'prisma/generated/prisma/enums'; import { AuthorizationService } from 'src/authorization/authorization.service'; import { USER_ORGANIZATION_OPERATIONS } from 'src/authorization/operations'; +import { CacheService } from 'src/cache/cache.service'; @Injectable() export class OrganizationService { @@ -19,6 +20,7 @@ export class OrganizationService { private readonly prisma: PrismaService, // private readonly reqContext: RequestContextService, private readonly authorization: AuthorizationService, + private readonly cacheService: CacheService, ) {} async createNewOrganization( userId: string, @@ -58,7 +60,7 @@ export class OrganizationService { orgId: string, dto: UpdateOrganizationRequestDTO, ) { - const orgExists = await this.findById(orgId); + const orgExists = await this.organizationExists(orgId); if (!orgExists) throw new NotFoundException('Organization'); const canUserUpdateOrganization = @@ -81,7 +83,7 @@ export class OrganizationService { // TODO: Either empty or choose someone to be owner async deleteAnOrganization(userId: string, orgId: string) { - const orgExists = await this.findById(orgId); + const orgExists = await this.organizationExists(orgId); if (!orgExists) throw new NotFoundException('Organization'); const canUserDeleteOrganization = @@ -110,11 +112,28 @@ export class OrganizationService { }, }); + await this.cacheService.deleteKey(orgId); + return deletedOrganization; }); } + async organizationExists(orgId: string) { + return await this.prisma.organization.findUnique({ + where: { id: orgId }, + select: { id: true }, + }); + } + async findById(orgId: string) { - return await this.prisma.organization.findUnique({ where: { id: orgId } }); + const organization = await this.cacheService.getOrSet( + orgId, + async () => + await this.prisma.organization.findUnique({ + where: { id: orgId }, + }), + ); + + return organization; } }