diff --git a/API_CACHING_RECOMMENDATIONS.md b/API_CACHING_RECOMMENDATIONS.md new file mode 100644 index 00000000..10c70fb5 --- /dev/null +++ b/API_CACHING_RECOMMENDATIONS.md @@ -0,0 +1,236 @@ +# API Caching Recommendations for User Microservice + +## Executive Summary + +This document provides a comprehensive analysis of all API endpoints in the User Microservice and identifies opportunities for implementing caching to improve performance, reduce database load, and enhance user experience. The recommendations are categorized by priority level and include specific caching strategies for each endpoint. + +--- + +## High Priority Caching Opportunities + +### 1. **User Management Endpoints** + +#### **GET `/read/:userId`** - Get User Details +- **Why Cache**: Frequently accessed user profile data that rarely changes +- **Cache Strategy**: Redis with 15-30 minute TTL +- **Cache Key**: `user:profile:{userId}:{tenantId}` +- **Benefits**: Reduces database load for profile views, improves response time +- **Invalidation**: On user updates, role changes, custom field modifications + +#### **POST `/list`** - Search/List Users +- **Why Cache**: Search results are expensive queries with filters, pagination +- **Cache Strategy**: Redis with 5-10 minute TTL +- **Cache Key**: `users:search:{hash(searchParams)}:{tenantId}` +- **Benefits**: Improves search performance, reduces complex query load +- **Invalidation**: Time-based expiry, user creation/updates + +#### **GET `/presigned-url`** - File Upload URLs +- **Why Cache**: S3 presigned URL generation can be cached temporarily +- **Cache Strategy**: In-memory cache with 5-10 minute TTL +- **Cache Key**: `presigned:{filename}:{foldername}:{fileType}` +- **Benefits**: Reduces AWS API calls +- **Invalidation**: Short TTL-based expiry + +### 2. **Cohort Management Endpoints** + +#### **GET `/cohort/cohortHierarchy/:cohortId`** - Cohort Hierarchy +- **Why Cache**: Complex hierarchical data structures, expensive joins +- **Cache Strategy**: Redis with 30-60 minute TTL +- **Cache Key**: `cohort:hierarchy:{cohortId}:{academicYearId}` +- **Benefits**: Significant performance improvement for nested data retrieval +- **Invalidation**: Cohort updates, member changes, hierarchy modifications + +#### **POST `/cohort/search`** - Search Cohorts +- **Why Cache**: Filtered cohort lists accessed frequently by admins/teachers +- **Cache Strategy**: Redis with 15-20 minute TTL +- **Cache Key**: `cohorts:search:{hash(searchParams)}:{tenantId}:{academicYearId}` +- **Benefits**: Faster dashboard loading, improved admin experience +- **Invalidation**: Cohort creation/updates, time-based expiry + +#### **GET `/cohort/mycohorts/:userId`** - User's Cohorts +- **Why Cache**: Frequently accessed for navigation, role-based content +- **Cache Strategy**: Redis with 20-30 minute TTL +- **Cache Key**: `user:cohorts:{userId}:{tenantId}:{academicYearId}` +- **Benefits**: Faster app navigation, improved user experience +- **Invalidation**: Cohort membership changes, role updates + +### 3. **Fields and Forms Configuration** + +#### **POST `/fields/search`** - Search Fields +- **Why Cache**: Metadata that changes infrequently, used for form generation +- **Cache Strategy**: Redis with 60-120 minute TTL +- **Cache Key**: `fields:search:{hash(searchParams)}:{tenantId}` +- **Benefits**: Faster form rendering, reduced metadata queries +- **Invalidation**: Field configuration changes + +#### **GET `/fields/formFields`** - Form Custom Fields +- **Why Cache**: Form configurations rarely change, used repeatedly +- **Cache Strategy**: Redis with 2-4 hour TTL +- **Cache Key**: `form:fields:{context}:{contextType}:{tenantId}` +- **Benefits**: Significantly faster form loading +- **Invalidation**: Field configuration updates + +#### **POST `/fields/options/read`** - Field Options +- **Why Cache**: Dropdown/select options that are relatively static +- **Cache Strategy**: Redis with 1-2 hour TTL +- **Cache Key**: `field:options:{fieldName}:{controllingfieldfk}:{context}` +- **Benefits**: Faster form field population +- **Invalidation**: Option updates, dependency changes + + +## Medium Priority Caching Opportunities + +### 5. **Cohort Members Management** + +#### **GET `/cohortmember/read/:cohortId`** - Cohort Members List +- **Why Cache**: Member lists accessed frequently by instructors +- **Cache Strategy**: Redis with 15-20 minute TTL +- **Cache Key**: `cohort:members:{cohortId}:{tenantId}:{academicYearId}` +- **Benefits**: Faster class roster loading +- **Invalidation**: Member additions/removals, enrollment changes + +#### **POST `/cohortmember/list`** - Search Cohort Members +- **Why Cache**: Filtered member searches for attendance, grading +- **Cache Strategy**: Redis with 10-15 minute TTL +- **Cache Key**: `cohort:members:search:{hash(searchParams)}:{tenantId}` +- **Benefits**: Improved search performance for large cohorts +- **Invalidation**: Member updates, enrollment changes + +### 6. **Tenant and Academic Year Data** + +#### **GET `/tenant/read`** - All Tenants +- **Why Cache**: Tenant list rarely changes, used for configuration +- **Cache Strategy**: Redis with 2-4 hour TTL +- **Cache Key**: `tenants:all` +- **Benefits**: Faster admin interface loading +- **Invalidation**: Tenant creation/updates + +#### **POST `/tenant/search`** - Search Tenants +- **Why Cache**: Administrative searches that are relatively static +- **Cache Strategy**: Redis with 60-90 minute TTL +- **Cache Key**: `tenants:search:{hash(searchParams)}` +- **Benefits**: Improved admin dashboard performance +- **Invalidation**: Tenant updates + +#### **GET `/academicyears/:id`** - Academic Year Details +- **Why Cache**: Academic year data is stable once set +- **Cache Strategy**: Redis with 4-6 hour TTL +- **Cache Key**: `academicyear:{id}` +- **Benefits**: Faster context loading across the application +- **Invalidation**: Academic year updates (rare) + +#### **POST `/academicyears/list`** - Academic Years List +- **Why Cache**: List of academic years changes infrequently +- **Cache Strategy**: Redis with 2-3 hour TTL +- **Cache Key**: `academicyears:list:{tenantId}` +- **Benefits**: Faster dropdown/selection loading +- **Invalidation**: Academic year creation/updates + + +## Low Priority Caching Opportunities + +### 8. **Authentication and Session Management** + +#### **GET `/auth/`** - Get User by Auth Token +- **Why Cache**: User session data validation +- **Cache Strategy**: Redis with 10-15 minute TTL +- **Cache Key**: `auth:user:{tokenHash}:{tenantId}` +- **Benefits**: Reduced database calls for token validation +- **Invalidation**: Token refresh, user updates + +### 9. **Form and Automatic Member Configuration** + +#### **GET `/form/read`** - Form Data +- **Why Cache**: Form configurations are relatively stable +- **Cache Strategy**: Redis with 1-2 hour TTL +- **Cache Key**: `form:data:{context}:{contextType}:{tenantId}` +- **Benefits**: Faster form rendering +- **Invalidation**: Form configuration updates + +#### **GET `/automaticMember`** - All Automatic Members +- **Why Cache**: Configuration data that changes infrequently +- **Cache Strategy**: Redis with 60-90 minute TTL +- **Cache Key**: `automatic:members:all` +- **Benefits**: Faster configuration loading +- **Invalidation**: Configuration updates + +--- + +## Implementation Strategy + +### Cache Technologies Recommended: + +1. **Redis** - Primary caching layer for most endpoints + - Supports complex data structures + - Excellent for multi-server deployments + - Built-in TTL and eviction policies + +2. **In-Memory Cache** - For temporary, high-frequency data + - File upload URLs + - Session tokens + - Quick lookup data + +3. **Browser/CDN Caching** - For static responses + - Static form configurations + - Public metadata + - File downloads + +### Cache Invalidation Strategies: + +1. **Time-based (TTL)** - For data with predictable change patterns +2. **Event-based** - For data that changes based on user actions +3. **Manual invalidation** - For critical data requiring immediate consistency +4. **Cache-aside pattern** - For complex, expensive queries + +### Monitoring and Metrics: + +- Cache hit/miss ratios +- Response time improvements +- Database load reduction +- Memory usage patterns +- Cache invalidation frequency + +--- + +## Expected Performance Benefits + +### Database Load Reduction: +- **High Priority implementations**: 40-60% reduction in database queries +- **Medium Priority implementations**: 20-30% additional reduction +- **Overall**: Up to 70-80% reduction in repetitive database calls + +### Response Time Improvements: +- **User profile queries**: 200-500ms → 10-50ms +- **Search operations**: 500-2000ms → 50-200ms +- **Form loading**: 300-800ms → 20-100ms +- **Authorization checks**: 100-300ms → 5-20ms + +### Scalability Benefits: +- Support for 3-5x more concurrent users +- Reduced database connection pool pressure +- Better handling of traffic spikes +- Improved overall system responsiveness + +--- + +## Implementation Priority + +### Phase 1 (Immediate - High Impact): +1. User profile caching (`/read/:userId`) +2. User roles and privileges caching +3. Form fields configuration caching +4. Cohort hierarchy caching + +### Phase 2 (Short-term - Medium Impact): +1. Search result caching +2. Cohort members caching +3. Tenant and academic year data +4. Field options caching + +### Phase 3 (Long-term - System Optimization): +1. Authentication token caching +2. Location and configuration data +3. Fine-tuning and monitoring improvements +4. Advanced cache warming strategies + +This comprehensive caching strategy will significantly improve the User Microservice's performance, reduce infrastructure costs, and provide a better user experience across all user types (students, teachers, administrators). \ No newline at end of file diff --git a/caching.md b/caching.md new file mode 100644 index 00000000..2abee61d --- /dev/null +++ b/caching.md @@ -0,0 +1,86 @@ +# API Caching Recommendations for User Microservice + +## Executive Summary + +This document provides a comprehensive analysis of all API endpoints in the User Microservice and identifies opportunities for implementing caching to improve performance, reduce database load, and enhance user experience. The recommendations are categorized by priority level and include specific caching strategies for each endpoint. + +--- + +## Technical Context for the User Microservice + +### 1. Service Architecture & Caching Layer + +The User Microservice is built with **NestJS 10** and deployed as a containerised workload in Kubernetes. A custom, _pluggable_ caching layer lives under `src/cache` and is registered **globally** during application bootstrap. All controllers, services, guards, and pipes can therefore inject `CacheService` directly without additional module wiring. + +| File / Dir | Responsibility | +|------------|----------------| +| `src/cache/cache.module.ts` | Detects environment variables and selects an appropriate strategy at runtime. | +| `src/cache/cache.service.ts` | Thin wrapper that exposes a unified API—`get`, `set`, `del`, `reset`—regardless of the underlying store. | +| `src/cache/strategies/` | Strategy implementations: **In-Memory**, **Redis**, **File-system**, **Multi-layer**, and **NoCache**. | + +The **MultiCacheStrategy** can fan-out writes to Redis _and_ hold a short-lived in-memory replica, delivering sub-millisecond reads for hot keys while still providing cross-pod consistency. + +### 2. Environment Variables + +The behaviour of the cache layer is driven entirely by configuration so that the same container image can run in development, staging, and production: + +| Variable | Allowed Values | Default | Purpose | +|----------|----------------|---------|---------| +| `CACHE_STRATEGY` | `redis`, `inmemory`, `file`, `multi`, `none` | `inmemory` | Selects strategy; `none` or `CACHE_ENABLED=false` disables caching altogether. | +| `CACHE_ENABLED` | `true` \| `false` | `true` | Master switch that forces **NoCacheStrategy** when `false`. | +| `CACHE_TTL` | Integer seconds | `3600` | Global fallback TTL when none is supplied programmatically. | +| `REDIS_HOST` / `REDIS_PORT` | host / port | `localhost` / `6379` | Connection details for Redis strategy. | +| `FILE_CACHE_PATH` | Path | `./cache/files` | Storage path used by File strategy. | + +**Example `.env` for production** +```ini +CACHE_STRATEGY=redis +REDIS_HOST=redis-master +REDIS_PORT=6379 +CACHE_TTL=900 +``` + +### 3. Using `CacheService` in Business Logic + +```ts +import { Injectable } from "@nestjs/common"; +import { CacheService } from "src/cache/cache.service"; +import { UserRepository } from "src/user/user.repository"; +import { UserDto } from "src/user/dto/user-response.dto"; + +@Injectable() +export class UserService { + constructor( + private readonly cache: CacheService, + private readonly repo: UserRepository, + ) {} + + async getUserProfile(userId: string, tenantId: string): Promise { + const cacheKey = `user:profile:${userId}:${tenantId}`; + + // 1. Fast path — return from cache if present + const cached = await this.cache.get(cacheKey); + if (cached) return cached; + + // 2. Fallback to database + const entity = await this.repo.findById(userId); + const dto = this.mapToDto(entity); + + // 3. Store in cache for 30 minutes (1 800 seconds) + await this.cache.set(cacheKey, dto, 1800); + return dto; + } + + private mapToDto(/* … */) /* … */ {} +} +``` + +### 4. Invalidation Guidelines + +1. **Write-through**: Any service that mutates state must immediately `del` the affected keys or overwrite them with fresh values. +2. **Bulk changes** (e.g. role assignment, CSV imports) should publish a **Kafka** event that downstream consumers use to clear or rebuild their portion of the key-space. +3. **Scheduled flushes**: Use `CacheService.reset()` only during maintenance windows; routine operations should rely on TTL and targeted invalidation. + +These practices ensure the recommendations in the next sections can be implemented safely without risking stale data. + +--- diff --git a/create-user-api-doc.md b/create-user-api-doc.md new file mode 100644 index 00000000..b060bbce --- /dev/null +++ b/create-user-api-doc.md @@ -0,0 +1,315 @@ +# Create User API - Field Reference and Behavior + +## Overview + +The `POST /create` endpoint creates a new user in the system, handling both Keycloak authentication service integration and database persistence. The API supports complex user creation scenarios including tenant-role-cohort mappings, automatic member assignments, and custom field validations. + +## API Endpoint Details + +- **Method**: `POST` +- **Path**: `/create` +- **Content-Type**: `application/json` +- **Authentication**: Optional (JWT Guard commented out) +- **API ID**: `api.user.create` + +## Headers + +| Header | Required | Type | Description | Example | +|--------|----------|------|-------------|---------| +| `academicyearid` | Optional | UUID | Academic Year ID for cohort assignments | `123e4567-e89b-12d3-a456-426614174000` | +| `Authorization` | Optional | String | JWT Bearer token for user context | `Bearer eyJhbGciOiJIUzI1NiIs...` | + +## Request Body Structure + +### Core User Fields + +#### 🔒 **Mandatory Fields** + +| Field | Type | Validation | Description | +|-------|------|------------|-------------| +| `username` | String | `@IsNotEmpty()` | Unique username (converted to lowercase) | +| `firstName` | String | `@IsString()`, `@Length(1, 50)` | User's first name | +| `lastName` | String | `@IsString()`, `@Length(1, 50)` | User's last name | +| `gender` | Enum | `@IsEnum(["male", "female", "transgender"])` | User's gender | +| `password` | String | `@IsNotEmpty()` | Password for Keycloak authentication | +| `tenantCohortRoleMapping` | Array | `@ValidateNested()` | Array of tenant-role-cohort mappings | + +#### ✅ **Optional Fields** + +| Field | Type | Validation | Description | Default | +|-------|------|------------|-------------|---------| +| `middleName` | String | `@IsOptional()`, `@Length(0, 50)` | User's middle name | `null` | +| `dob` | String | `@IsDateString()`, `@NotInFuture()` | Date of birth (YYYY-MM-DD) | `null` | +| `mobile` | String | Custom validation (10 digits) | Mobile number | `null` | +| `email` | String | Email format validation | Email address | `null` | +| `district` | String | No validation | District information | `null` | +| `state` | String | No validation | State information | `null` | +| `address` | String | No validation | Address information | `null` | +| `pincode` | String | No validation | Postal code | `null` | + +#### 🧪 **Auto-Generated/System Fields** + +| Field | Type | Description | Generated By | +|-------|------|-------------|--------------| +| `userId` | UUID | Primary key | Keycloak response | +| `enrollmentId` | String | Auto-generated enrollment ID | System | +| `createdAt` | Timestamp | Creation timestamp | Database | +| `updatedAt` | Timestamp | Last update timestamp | Database | +| `createdBy` | UUID | Creator user ID | JWT token or self | +| `updatedBy` | UUID | Last updater user ID | JWT token or self | +| `status` | Enum | User status | `ACTIVE` (default) | +| `temporaryPassword` | Boolean | Password type flag | `true` (default) | + +### Complex Object Fields + +#### Tenant-Cohort-Role Mapping (`tenantCohortRoleMapping`) + +```json +{ + "tenantCohortRoleMapping": [ + { + "tenantId": "uuid", // Optional: Tenant identifier + "cohortIds": ["uuid1", "uuid2"], // Optional: Array of cohort UUIDs + "roleId": "uuid" // Optional: Role identifier + } + ] +} +``` + +**Validation Rules:** +- If `cohortIds` provided, `academicyearid` header is required +- Tenant must exist in database +- Role must exist and belong to the specified tenant +- Cohorts must exist in the specified academic year +- No duplicate `tenantId` values allowed + +#### Automatic Member (`automaticMember`) + +```json +{ + "automaticMember": { + "value": true, // Boolean: Enable automatic membership + "fieldId": "uuid", // UUID: Field identifier + "fieldName": "string" // String: Field name + } +} +``` + +**Business Rule:** Cannot be `true` when `cohortIds` are specified in `tenantCohortRoleMapping` + +#### Custom Fields (`customFields`) + +```json +{ + "customFields": [ + { + "fieldId": "uuid", // UUID: Field identifier + "value": "string" // String: Field value + } + ] +} +``` + +**Validation Rules:** +- Field must exist in the system +- Field must be valid for the user's role/context +- No duplicate `fieldId` values allowed +- Values must pass field-specific validation + +## Validation Rules + +### Email Validation +- **Pattern**: `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` +- **Error**: "Invalid email address" + +### Mobile Validation +- **Pattern**: Exactly 10 digits +- **Error**: "Mobile number must be 10 digits long" + +### Date of Birth Validation +- **Pattern**: `YYYY-MM-DD` format +- **Rule**: Cannot be in the future +- **Error**: "Date of birth must be in the format yyyy-mm-dd" or "The birth date cannot be in the future" + +### UUID Validation +- All ID fields must be valid UUIDs +- **Error**: "Please enter valid UUID" + +## Workflow Steps + +### 1. JWT Token Extraction +- Extracts `sub` (subject) from Authorization header +- Sets `createdBy` and `updatedBy` fields + +### 2. Custom Fields Validation +- Validates field existence +- Checks for duplicate field IDs +- Validates field values against field definitions +- Ensures fields are valid for user's role + +### 3. Request Body Validation +- Validates core field formats (email, mobile, dob) +- Checks tenant existence +- Validates academic year for tenant +- Verifies cohort existence in academic year +- Validates role existence and tenant association + +### 4. Business Logic Validation +- Prevents automatic member + cohort assignment combination +- Checks for duplicate tenant IDs + +### 5. User Existence Check +- Checks Keycloak for existing username/email +- Prevents duplicate user creation + +### 6. Keycloak User Creation +- Creates user in Keycloak authentication service +- Handles various error scenarios (409 for duplicates, etc.) + +### 7. Database User Creation +- Saves user to PostgreSQL database +- Generates enrollment ID automatically + +### 8. Mapping Creation +- Creates tenant-cohort-role mappings OR +- Creates automatic member mappings + +### 9. Custom Fields Processing +- Saves custom field values +- Links to user record + +### 10. Event Publishing +- Publishes user creation event to Kafka (asynchronous) + +## Response Formats + +### Success Response (201 Created) +```json +{ + "id": "api.user.create", + "ver": "1.0", + "ts": "2024-01-15T10:30:00.000Z", + "params": { + "resmsgid": "uuid", + "status": "successful", + "err": null, + "errmsg": null, + "successmessage": "User created successfully" + }, + "responseCode": 201, + "result": { + "userData": { + "userId": "uuid", + "username": "john.doe", + "firstName": "John", + "lastName": "Doe", + "email": "john@example.com", + "createFailures": [] + } + } +} +``` + +### Error Response Examples + +#### 400 Bad Request - Validation Error +```json +{ + "id": "api.user.create", + "ver": "1.0", + "ts": "2024-01-15T10:30:00.000Z", + "params": { + "resmsgid": "uuid", + "status": "failed", + "err": "Invalid email address", + "errmsg": "Bad Request" + }, + "responseCode": 400, + "result": {} +} +``` + +#### 409 Conflict - User Exists +```json +{ + "id": "api.user.create", + "ver": "1.0", + "ts": "2024-01-15T10:30:00.000Z", + "params": { + "resmsgid": "uuid", + "status": "failed", + "err": "User already exist.", + "errmsg": "Bad Request" + }, + "responseCode": 400, + "result": {} +} +``` + +## Edge Cases and Error Scenarios + +### 1. Missing Password +- **Error**: "User cannot be created, Password missing" +- **Status**: 500 Internal Server Error + +### 2. Invalid UUID Fields +- **Error**: "Please enter valid UUID" +- **Status**: 400 Bad Request + +### 3. Automatic Member + Cohort Assignment +- **Error**: "Invalid operation: A user cannot be assigned as an automatic member while also being assigned to a center simultaneously" +- **Status**: 400 Bad Request + +### 4. Duplicate Tenant ID +- **Error**: "Duplicate tenantId detected. Please ensure each tenantId is unique and correct your data." +- **Status**: 400 Bad Request + +### 5. Academic Year Required +- **Error**: "Academic Year ID is required when a Cohort ID is provided." +- **Status**: 400 Bad Request + +### 6. Keycloak Service Unavailable +- **Error**: "No response received from Keycloak" +- **Status**: 500 Internal Server Error + +### 7. Email Already Exists in Keycloak +- **Error**: "Email already exists {email}" +- **Status**: 409 Conflict + +### 8. Invalid Custom Fields for Role +- **Error**: "The following fields are not valid for this user: {fieldIds}" +- **Status**: 400 Bad Request + +## Performance Considerations + +The API includes performance monitoring with step-by-step timing: +- JWT Extraction +- Custom Fields Validation +- Request Validation +- Business Logic Validation +- Keycloak User Check +- Keycloak User Creation +- Database User Creation +- Custom Fields Processing + +Total execution time and breakdown are logged for monitoring purposes. + +## Security Features + +1. **Password Handling**: Passwords are securely transmitted to Keycloak +2. **JWT Integration**: Supports JWT token context for audit trails +3. **Input Sanitization**: All inputs are validated and sanitized +4. **UUID Validation**: Prevents injection attacks through ID fields +5. **Business Rule Enforcement**: Prevents invalid state combinations + +## Integration Points + +1. **Keycloak**: User authentication and authorization +2. **PostgreSQL**: User data persistence +3. **Kafka**: Event publishing for downstream services +4. **Notification Service**: Email/SMS notifications (if configured) +5. **Academic Year Service**: Academic year validation +6. **Tenant Service**: Tenant validation +7. **Role Service**: Role validation and assignment +8. **Cohort Service**: Cohort validation and membership +9. **Fields Service**: Custom field validation and storage \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8d64846f..b5d5ed2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.11.3", + "redis": "^4.6.7", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.8.1", @@ -3150,6 +3151,71 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -5731,6 +5797,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -7464,6 +7539,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -10643,6 +10727,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, "node_modules/reflect-metadata": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", diff --git a/package.json b/package.json index 933737a4..d88e517f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "benchmark:cache": "ts-node scripts/cache-benchmark.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.688.0", @@ -64,7 +65,8 @@ "templates.js": "^0.3.11", "typeorm": "^0.3.20", "winston": "^3.11.0", - "kafkajs": "^2.2.4" + "kafkajs": "^2.2.4", + "redis": "^4.6.7" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/src/adapters/postgres/user-adapter.ts b/src/adapters/postgres/user-adapter.ts index 3c12d51d..8e416bf9 100644 --- a/src/adapters/postgres/user-adapter.ts +++ b/src/adapters/postgres/user-adapter.ts @@ -55,6 +55,8 @@ import { randomInt } from "crypto"; import { UUID } from "aws-sdk/clients/cloudtrail"; import { AutomaticMemberService } from "src/automatic-member/automatic-member.service"; import { KafkaService } from "src/kafka/kafka.service"; +import { CacheService } from "src/cache/cache.service"; +import { createHash } from "crypto"; interface UpdateField { userId: string; // Required @@ -102,6 +104,7 @@ export class PostgresUserService implements IServicelocator { private readonly authUtils: AuthUtils, private readonly automaticMemberService: AutomaticMemberService, private readonly kafkaService: KafkaService, + private readonly cacheService: CacheService, dataSource: DataSource, ) { this.jwt_secret = this.configService.get("RBAC_JWT_SECRET"); @@ -364,7 +367,22 @@ export class PostgresUserService implements IServicelocator { userSearchDto: UserSearchDto, ) { const apiId = APIID.USER_LIST; + // Build cache key from tenantId + hash of search DTO + const searchHash = createHash("md5") + .update(JSON.stringify(userSearchDto)) + .digest("hex"); + const cacheKey = `users:search:${searchHash}:${tenantId}`; try { + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return APIResponse.success( + response, + apiId, + cached, + HttpStatus.OK, + API_RESPONSES.USER_GET_SUCCESSFULLY, + ); + } const findData = await this.findAllUserDetails(userSearchDto); if (findData === false) { @@ -382,13 +400,18 @@ export class PostgresUserService implements IServicelocator { ); } LoggerUtil.log(API_RESPONSES.USER_GET_SUCCESSFULLY, apiId); - return await APIResponse.success( + const successResp = await APIResponse.success( response, apiId, findData, HttpStatus.OK, API_RESPONSES.USER_GET_SUCCESSFULLY, ); + + // Cache for 10 minutes + await this.cacheService.set(cacheKey, findData, 10 * 60); + + return successResp; } catch (e) { LoggerUtil.error( `${API_RESPONSES.SERVER_ERROR}: ${request.url}`, @@ -612,7 +635,22 @@ export class PostgresUserService implements IServicelocator { async getUsersDetailsById(userData: UserData, response: any) { const apiId = APIID.USER_GET; + // Generate cache key based on userId and tenantId (if any) + const cacheKey = `user:profile:${userData.userId}:${userData?.tenantId || "global"}`; try { + // Check cache only when custom field data is not requested, because that can be large and dynamic + if (!userData.fieldValue) { + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return APIResponse.success( + response, + apiId, + { userData: cached }, + HttpStatus.OK, + API_RESPONSES.USER_GET_SUCCESSFULLY, + ); + } + } if (!isUUID(userData.userId)) { return APIResponse.error( response, @@ -665,14 +703,27 @@ export class PostgresUserService implements IServicelocator { ); } if (!userData.fieldValue) { - LoggerUtil.log(API_RESPONSES.USER_GET_SUCCESSFULLY, apiId); - return await APIResponse.success( + // Populate result object for uniformity + result.userData = userDetails; + + LoggerUtil.log( + API_RESPONSES.USER_GET_SUCCESSFULLY, + apiId, + userData?.userId, + ); + + const successResponse = await APIResponse.success( response, apiId, - { userData: userDetails }, + { ...result }, HttpStatus.OK, API_RESPONSES.USER_GET_SUCCESSFULLY, ); + + // Cache the user profile (without custom fields to keep payload lean) + await this.cacheService.set(cacheKey, result.userData, 30 * 60); // 30-minute TTL + + return successResponse; } let customFields; diff --git a/src/cache/cache.interface.ts b/src/cache/cache.interface.ts new file mode 100644 index 00000000..bbf7a91d --- /dev/null +++ b/src/cache/cache.interface.ts @@ -0,0 +1,13 @@ +export interface CacheStrategy { + // Retrieve a value from cache. Returns undefined if key is not found or expired. + get(key: string): Promise; + + // Store a value in cache. Optionally specify TTL in seconds. + set(key: string, value: T, ttl?: number): Promise; + + // Delete a specific key from cache. + del(key: string): Promise; + + // Clear entire cache. + reset(): Promise; +} \ No newline at end of file diff --git a/src/cache/cache.module.ts b/src/cache/cache.module.ts new file mode 100644 index 00000000..d00a3b3e --- /dev/null +++ b/src/cache/cache.module.ts @@ -0,0 +1,56 @@ +import { Global, Module } from "@nestjs/common"; +import { ConfigService, ConfigModule } from "@nestjs/config"; +import { CACHE_STRATEGY_TOKEN, CacheService } from "./cache.service"; +import { InMemoryCacheStrategy } from "./strategies/inmemory.strategy"; +import { RedisCacheStrategy } from "./strategies/redis.strategy"; +import { FileCacheStrategy } from "./strategies/file.strategy"; +import { MultiCacheStrategy } from "./strategies/multi.strategy"; +import { NoCacheStrategy } from "./strategies/nocache.strategy"; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [ + { + provide: CACHE_STRATEGY_TOKEN, + useFactory: async (configService: ConfigService) => { + const strategy = (configService.get("CACHE_STRATEGY") || "inmemory").toLowerCase(); + const cacheEnabledRaw = configService.get("CACHE_ENABLED", "true").toLowerCase(); + const enabled = !["0", "false", "no", "off"].includes(cacheEnabledRaw); + if (!enabled || strategy === "none" || strategy === "disabled") { + return new NoCacheStrategy(); + } + + const defaultTTL = parseInt(configService.get("CACHE_TTL", "3600")); + switch (strategy) { + case "redis": { + const host = configService.get("REDIS_HOST", "localhost"); + const port = parseInt(configService.get("REDIS_PORT", "6379")); + return await RedisCacheStrategy.create({ host, port, defaultTTL }); + } + case "file": { + const filePath = configService.get( + "FILE_CACHE_PATH", + "./cache/files", + ); + return new FileCacheStrategy(filePath, defaultTTL); + } + case "multi": { + const inMemory = new InMemoryCacheStrategy(defaultTTL); + const host = configService.get("REDIS_HOST", "localhost"); + const port = parseInt(configService.get("REDIS_PORT", "6379")); + const redis = await RedisCacheStrategy.create({ host, port, defaultTTL }); + return new MultiCacheStrategy([inMemory, redis]); + } + case "inmemory": + default: + return new InMemoryCacheStrategy(defaultTTL); + } + }, + inject: [ConfigService], + }, + CacheService, + ], + exports: [CacheService], +}) +export class CacheModule {} \ No newline at end of file diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts new file mode 100644 index 00000000..d00c0003 --- /dev/null +++ b/src/cache/cache.service.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { CacheStrategy } from "./cache.interface"; + +export const CACHE_STRATEGY_TOKEN = "CACHE_STRATEGY"; + +@Injectable() +export class CacheService implements CacheStrategy { + constructor( + @Inject(CACHE_STRATEGY_TOKEN) private readonly strategy: CacheStrategy, + ) {} + + async get(key: string): Promise { + return this.strategy.get(key); + } + + async set(key: string, value: T, ttl?: number): Promise { + return this.strategy.set(key, value, ttl); + } + + async del(key: string): Promise { + return this.strategy.del(key); + } + + async reset(): Promise { + return this.strategy.reset(); + } +} \ No newline at end of file diff --git a/src/cache/strategies/file.strategy.ts b/src/cache/strategies/file.strategy.ts new file mode 100644 index 00000000..43e45b64 --- /dev/null +++ b/src/cache/strategies/file.strategy.ts @@ -0,0 +1,76 @@ +import { CacheStrategy } from "../cache.interface"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { Logger } from "@nestjs/common"; + +export class FileCacheStrategy implements CacheStrategy { + private readonly logger = new Logger(FileCacheStrategy.name); + private basePath: string; + private defaultTTL: number; + + constructor(basePath: string, defaultTTL: number = 3600) { + this.basePath = basePath; + this.defaultTTL = defaultTTL; + // Ensure directory exists + fs.mkdir(this.basePath, { recursive: true }).catch((err) => { + this.logger.error(`Failed to create cache dir: ${err.message}`); + }); + } + + private keyPath(key: string): string { + // Replace slashes to avoid nesting + const safeKey = key.replace(/[^a-zA-Z0-9-_:.]/g, "_"); + return path.join(this.basePath, `${safeKey}.json`); + } + + async get(key: string): Promise { + try { + const filePath = this.keyPath(key); + const data = await fs.readFile(filePath, "utf-8"); + const parsed = JSON.parse(data); + if (parsed.expiresAt && Date.now() > parsed.expiresAt) { + // expired + await fs.unlink(filePath).catch(() => {}); + return undefined; + } + return parsed.value as T; + } catch (err) { + if (err.code !== "ENOENT") { + this.logger.error(`File cache get failed: ${err.message}`); + } + return undefined; + } + } + + async set(key: string, value: T, ttl?: number): Promise { + try { + const filePath = this.keyPath(key); + const expiresAt = ttl === 0 ? undefined : Date.now() + 1000 * (ttl || this.defaultTTL); + await fs.writeFile(filePath, JSON.stringify({ value, expiresAt }), "utf-8"); + } catch (err) { + this.logger.error(`File cache set failed: ${err.message}`); + } + } + + async del(key: string): Promise { + try { + const filePath = this.keyPath(key); + await fs.unlink(filePath); + } catch (err) { + if (err.code !== "ENOENT") { + this.logger.error(`File cache del failed: ${err.message}`); + } + } + } + + async reset(): Promise { + try { + const files = await fs.readdir(this.basePath); + await Promise.all( + files.map((f) => fs.unlink(path.join(this.basePath, f)).catch(() => {})), + ); + } catch (err) { + this.logger.error(`File cache reset failed: ${err.message}`); + } + } +} \ No newline at end of file diff --git a/src/cache/strategies/inmemory.strategy.ts b/src/cache/strategies/inmemory.strategy.ts new file mode 100644 index 00000000..8fe7f093 --- /dev/null +++ b/src/cache/strategies/inmemory.strategy.ts @@ -0,0 +1,60 @@ +import { CacheStrategy } from "../cache.interface"; +import { Logger } from "@nestjs/common"; + +interface CacheEntry { + value: any; + expiresAt?: number; +} + +export class InMemoryCacheStrategy implements CacheStrategy { + private readonly logger = new Logger(InMemoryCacheStrategy.name); + private cache = new Map(); + private readonly defaultTTL: number; // in seconds + + constructor(defaultTTL: number = 3600) { + this.defaultTTL = defaultTTL; + // Periodic cleanup every 5 minutes + setInterval(() => this.cleanup(), 5 * 60 * 1000).unref(); + } + + async get(key: string): Promise { + try { + const entry = this.cache.get(key); + if (!entry) return undefined; + if (entry.expiresAt && Date.now() > entry.expiresAt) { + this.cache.delete(key); + return undefined; + } + return entry.value as T; + } catch (e) { + this.logger.error(`InMemory get failed: ${e.message}`); + return undefined; + } + } + + async set(key: string, value: T, ttl?: number): Promise { + try { + const expiresAt = ttl === 0 ? undefined : Date.now() + 1000 * (ttl || this.defaultTTL); + this.cache.set(key, { value, expiresAt }); + } catch (e) { + this.logger.error(`InMemory set failed: ${e.message}`); + } + } + + async del(key: string): Promise { + this.cache.delete(key); + } + + async reset(): Promise { + this.cache.clear(); + } + + private cleanup() { + const now = Date.now(); + for (const [key, entry] of this.cache.entries()) { + if (entry.expiresAt && now > entry.expiresAt) { + this.cache.delete(key); + } + } + } +} \ No newline at end of file diff --git a/src/cache/strategies/multi.strategy.ts b/src/cache/strategies/multi.strategy.ts new file mode 100644 index 00000000..7306bdf5 --- /dev/null +++ b/src/cache/strategies/multi.strategy.ts @@ -0,0 +1,48 @@ +import { CacheStrategy } from "../cache.interface"; +import { Logger } from "@nestjs/common"; + +export class MultiCacheStrategy implements CacheStrategy { + private readonly logger = new Logger(MultiCacheStrategy.name); + private readonly layers: CacheStrategy[]; + constructor(layers: CacheStrategy[]) { + this.layers = layers; + } + + async get(key: string): Promise { + for (const layer of this.layers) { + try { + const value = await layer.get(key); + if (value !== undefined) { + return value; + } + } catch (err) { + this.logger.warn(`Layer get failed, continuing: ${err.message}`); + } + } + return undefined; + } + + async set(key: string, value: T, ttl?: number): Promise { + await Promise.all( + this.layers.map((layer) => layer.set(key, value, ttl).catch((e) => { + this.logger.warn(`Layer set failed: ${e.message}`); + })), + ); + } + + async del(key: string): Promise { + await Promise.all( + this.layers.map((layer) => layer.del(key).catch((e) => { + this.logger.warn(`Layer del failed: ${e.message}`); + })), + ); + } + + async reset(): Promise { + await Promise.all( + this.layers.map((layer) => layer.reset().catch((e) => { + this.logger.warn(`Layer reset failed: ${e.message}`); + })), + ); + } +} \ No newline at end of file diff --git a/src/cache/strategies/nocache.strategy.ts b/src/cache/strategies/nocache.strategy.ts new file mode 100644 index 00000000..1f8d2bd5 --- /dev/null +++ b/src/cache/strategies/nocache.strategy.ts @@ -0,0 +1,17 @@ +import { CacheStrategy } from "../cache.interface"; + +// This strategy disables caching entirely while keeping the same API surface. +export class NoCacheStrategy implements CacheStrategy { + async get(_key: string): Promise { + return undefined; + } + async set(_key: string, _value: T, _ttl?: number): Promise { + /* no-op */ + } + async del(_key: string): Promise { + /* no-op */ + } + async reset(): Promise { + /* no-op */ + } +} \ No newline at end of file diff --git a/src/cache/strategies/redis.strategy.ts b/src/cache/strategies/redis.strategy.ts new file mode 100644 index 00000000..973282fc --- /dev/null +++ b/src/cache/strategies/redis.strategy.ts @@ -0,0 +1,70 @@ +import { CacheStrategy } from "../cache.interface"; +import { Logger } from "@nestjs/common"; +import { createClient, RedisClientType } from "redis"; + +export class RedisCacheStrategy implements CacheStrategy { + private readonly logger = new Logger(RedisCacheStrategy.name); + private client: RedisClientType; + private defaultTTL: number; + + private constructor(client: RedisClientType, defaultTTL: number) { + this.client = client; + this.defaultTTL = defaultTTL; + } + + static async create(options: { + host: string; + port: number; + defaultTTL: number; + }): Promise { + const { host, port, defaultTTL } = options; + const client: RedisClientType = createClient({ url: `redis://${host}:${port}` }); + const logger = new Logger(RedisCacheStrategy.name); + client.on("error", (err) => logger.error(`Redis error: ${err}`)); + try { + if (!client.isOpen) await client.connect(); + logger.log(`Connected to Redis at ${host}:${port}`); + } catch (err) { + logger.error(`Failed to connect Redis: ${err}`); + } + return new RedisCacheStrategy(client, defaultTTL); + } + + async get(key: string): Promise { + try { + const value = await this.client.get(key); + if (value === null || value === undefined) return undefined; + return JSON.parse(value) as T; + } catch (e) { + this.logger.error(`Redis get failed: ${e.message}`); + return undefined; + } + } + + async set(key: string, value: T, ttl?: number): Promise { + try { + const ttlSeconds = ttl || this.defaultTTL; + await this.client.set(key, JSON.stringify(value), { + EX: ttlSeconds, + }); + } catch (e) { + this.logger.error(`Redis set failed: ${e.message}`); + } + } + + async del(key: string): Promise { + try { + await this.client.del(key); + } catch (e) { + this.logger.error(`Redis del failed: ${e.message}`); + } + } + + async reset(): Promise { + try { + await this.client.flushDb(); + } catch (e) { + this.logger.error(`Redis reset failed: ${e.message}`); + } + } +} \ No newline at end of file diff --git a/src/cohort/cohort.controller.ts b/src/cohort/cohort.controller.ts index c7188e3b..794ed427 100644 --- a/src/cohort/cohort.controller.ts +++ b/src/cohort/cohort.controller.ts @@ -47,13 +47,17 @@ import { APIID } from "src/common/utils/api-id.config"; import { isUUID } from "class-validator"; import { API_RESPONSES } from "@utils/response.messages"; import { LoggerUtil } from "src/common/logger/LoggerUtil"; +import { CacheService } from "src/cache/cache.service"; import { GetUserId } from "src/common/decorators/getUserId.decorator"; @ApiTags("Cohort") @Controller("cohort") @UseGuards(JwtAuthGuard) export class CohortController { - constructor(private readonly cohortAdapter: CohortAdapter) {} + constructor( + private readonly cohortAdapter: CohortAdapter, + private readonly cacheService: CacheService, + ) {} @UseFilters(new AllExceptionsFilter(APIID.COHORT_READ)) @Get("/cohortHierarchy/:cohortId") @@ -80,9 +84,21 @@ export class CohortController { getChildData: getChildDataValueBoolean, customField: fieldValueBooelan, }; - return await this.cohortAdapter + const cacheKey = `cohort:hierarchy:${cohortId}:${academicYearId}`; + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return response.status(cached.statusCode || 200).json(cached); + } + const result = await this.cohortAdapter .buildCohortAdapter() .getCohortsDetails(requiredData, response); + + if (result && result.statusCode && result.statusCode >= 200 && result.statusCode < 300) { + // 30 minutes TTL + await this.cacheService.set(cacheKey, result, 30 * 60); + } + + return response.status(result.statusCode).json(result); } @UseFilters(new AllExceptionsFilter(APIID.COHORT_CREATE)) @@ -243,8 +259,19 @@ export class CohortController { getChildData: getChildDataValueBoolean, customField: fieldValueBooelan, }; - return await this.cohortAdapter + const cacheKey = `user:cohorts:${userId}:${tenantId}:${academicYearId}`; + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return response.status(cached.statusCode || 200).json(cached); + } + const result = await this.cohortAdapter .buildCohortAdapter() .getCohortHierarchyData(requiredData, response); + + if (result && result.statusCode && result.statusCode >= 200 && result.statusCode < 300) { + await this.cacheService.set(cacheKey, result, 30 * 60); + } + + return response.status(result.statusCode).json(result); } } diff --git a/src/fields/fields.controller.ts b/src/fields/fields.controller.ts index 1474a4f8..282e6b98 100644 --- a/src/fields/fields.controller.ts +++ b/src/fields/fields.controller.ts @@ -42,11 +42,16 @@ import { JwtAuthGuard } from "src/common/guards/keycloak.guard"; import { AllExceptionsFilter } from "src/common/filters/exception.filter"; import { APIID } from "src/common/utils/api-id.config"; import { FieldValuesDeleteDto } from "./dto/field-values-delete.dto"; +import { CacheService } from "src/cache/cache.service"; +import { createHash } from "crypto"; @ApiTags("Fields") @Controller("fields") export class FieldsController { - constructor(private fieldsAdapter: FieldsAdapter) {} + constructor( + private fieldsAdapter: FieldsAdapter, + private readonly cacheService: CacheService, + ) {} //fields //create fields @@ -107,9 +112,25 @@ export class FieldsController { @Res() response: Response, ) { const tenantid = headers["tenantid"]; - return await this.fieldsAdapter + const hash = createHash("md5") + .update(JSON.stringify(fieldsSearchDto)) + .digest("hex"); + const cacheKey = `fields:search:${hash}:${tenantid}`; + + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return response.status(cached.statusCode || 200).json(cached); + } + + const result = await this.fieldsAdapter .buildFieldsAdapter() .searchFields(tenantid, request, fieldsSearchDto, response); + + if (result && result.statusCode && result.statusCode >= 200 && result.statusCode < 300) { + await this.cacheService.set(cacheKey, result, 2 * 60 * 60); // 2 hours TTL + } + + return response.status(result.statusCode).json(result); } //field values @@ -171,9 +192,23 @@ export class FieldsController { @Body() fieldsOptionsSearchDto: FieldsOptionsSearchDto, @Res() response: Response, ) { - return await this.fieldsAdapter + const { fieldName, controllingfieldfk, context } = fieldsOptionsSearchDto as any; + const cacheKey = `field:options:${fieldName}:${controllingfieldfk}:${context}`; + + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return response.status(cached.statusCode || 200).json(cached); + } + + const result = await this.fieldsAdapter .buildFieldsAdapter() .getFieldOptions(fieldsOptionsSearchDto, response); + + if (result && result.statusCode && result.statusCode >= 200 && result.statusCode < 300) { + await this.cacheService.set(cacheKey, result, 2 * 60 * 60); // 2 hours TTL + } + + return response.status(result.statusCode).json(result); } //Delete Field Option @@ -227,9 +262,24 @@ export class FieldsController { context: context || false, contextType: contextType || false, }; - return await this.fieldsAdapter + const tenantid = headers["tenantid"]; + const cacheKey = `form:fields:${context}:${contextType}:${tenantid}`; + + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return response.status(cached.statusCode || 200).json(cached); + } + + const result = await this.fieldsAdapter .buildFieldsAdapter() .getFormCustomField(requiredData, response); + + if (result && result.statusCode && result.statusCode >= 200 && result.statusCode < 300) { + // 4 hours TTL + await this.cacheService.set(cacheKey, result, 4 * 60 * 60); + } + + return response.status(result.statusCode).json(result); } //delete field values @Delete("/values/delete") diff --git a/src/forms/forms.service.ts b/src/forms/forms.service.ts index c1793309..7b6d21b7 100644 --- a/src/forms/forms.service.ts +++ b/src/forms/forms.service.ts @@ -9,6 +9,7 @@ import { CohortContextType } from "./utils/form-class"; import { FormCreateDto } from "./dto/form-create.dto"; import { APIID } from "@utils/api-id.config"; import { API_RESPONSES } from "@utils/response.messages"; +import { CacheService } from "src/cache/cache.service"; @Injectable() export class FormsService { @@ -16,11 +17,27 @@ export class FormsService { private readonly fieldsService: PostgresFieldsService, @InjectRepository(Form) private readonly formRepository: Repository
, + private readonly cacheService: CacheService, ) {} async getForm(requiredData, response) { const apiId = APIID.FORM_GET; try { + const { context, contextType, tenantId } = requiredData; + + // Generate cache key based on request params + const cacheKey = `form:data:${context}:${contextType || "null"}:${tenantId || "null"}`; + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return APIResponse.success( + response, + apiId, + cached, + HttpStatus.OK, + "Fields fetched successfully.", + ); + } + if (!requiredData.context && !requiredData.contextType) { return APIResponse.error( response, @@ -31,7 +48,6 @@ export class FormsService { ); } - const { context, contextType, tenantId } = requiredData; const validationResult = await this.validateFormInput(requiredData); if (validationResult.error) { @@ -90,13 +106,18 @@ export class FormsService { fields: mappedResponse, }; - return APIResponse.success( + const successResp = APIResponse.success( response, apiId, result, HttpStatus.OK, "Fields fetched successfully.", ); + + // Cache for 2 hours (7200 seconds) + await this.cacheService.set(cacheKey, result, 2 * 60 * 60); + + return successResp; } catch (error) { const errorMessage = error.message || "Internal server error"; return APIResponse.error( diff --git a/src/tenant/tenant.module.ts b/src/tenant/tenant.module.ts index 47183623..1cc35d89 100644 --- a/src/tenant/tenant.module.ts +++ b/src/tenant/tenant.module.ts @@ -4,9 +4,10 @@ import { TenantService } from "./tenant.service"; import { Tenant } from "src/tenant/entities/tenent.entity"; import { TypeOrmModule } from "@nestjs/typeorm"; import { FilesUploadService } from "src/common/services/upload-file"; +import { CacheModule } from "src/cache/cache.module"; @Module({ - imports: [TypeOrmModule.forFeature([Tenant])], + imports: [TypeOrmModule.forFeature([Tenant]), CacheModule], controllers: [TenantController], providers: [TenantService, FilesUploadService], }) diff --git a/src/tenant/tenant.service.ts b/src/tenant/tenant.service.ts index 1ac13dd1..70aa940d 100644 --- a/src/tenant/tenant.service.ts +++ b/src/tenant/tenant.service.ts @@ -1,4 +1,6 @@ import { HttpStatus, Injectable } from "@nestjs/common"; +import { CacheService } from "src/cache/cache.service"; +import { createHash } from "crypto"; import { Tenant } from "./entities/tenent.entity"; import { ILike, In, Repository } from "typeorm"; import APIResponse from "src/common/responses/response"; @@ -16,6 +18,7 @@ export class TenantService { constructor( @InjectRepository(Tenant) private tenantRepository: Repository, + private readonly cacheService: CacheService, ) {} public async getTenants( @@ -24,7 +27,21 @@ export class TenantService { ): Promise { const apiId = APIID.TENANT_LIST; try { - const result = await this.tenantRepository.find({ + + // Try cache first + const cacheKey = "tenants:all"; + const cachedResult = await this.cacheService.get(cacheKey); + if (cachedResult) { + return APIResponse.success( + response, + apiId, + cachedResult, + HttpStatus.OK, + API_RESPONSES.TENANT_GET, + ); + } + + let result = await this.tenantRepository.find({ where: { status: "published" }, }); @@ -38,6 +55,7 @@ export class TenantService { ); } + // Add role details (only when we fetched from DB) for (const tenantData of result) { const query = `SELECT * FROM public."Roles" WHERE "tenantId" = '${tenantData.tenantId}'`; let getRole = await this.tenantRepository.query(query); @@ -63,6 +81,9 @@ export class TenantService { } } + // Cache the result + await this.cacheService.set(cacheKey, result, 3 * 60 * 60); // 3 hours TTL + return APIResponse.success( response, apiId, @@ -96,6 +117,23 @@ export class TenantService { try { const { limit, offset, filters } = tenantSearchDTO; + // Generate cache key based on search DTO + const hash = createHash("md5") + .update(JSON.stringify({ limit, offset, filters })) + .digest("hex"); + const cacheKey = `tenants:search:${hash}`; + + let cached = await this.cacheService.get<{ getTenantDetails: any[]; totalCount: number }>(cacheKey); + if (cached) { + return APIResponse.success( + response, + apiId, + cached, + HttpStatus.OK, + API_RESPONSES.TENANT_SEARCH_SUCCESS, + ); + } + const whereClause: Record = {}; if (filters && Object.keys(filters).length > 0) { Object.entries(filters).forEach(([key, value]) => { @@ -141,13 +179,18 @@ export class TenantService { ); } - return APIResponse.success( + const successResponse = APIResponse.success( response, apiId, { getTenantDetails, totalCount }, HttpStatus.OK, API_RESPONSES.TENANT_SEARCH_SUCCESS, ); + + // Cache for 60 minutes + await this.cacheService.set(cacheKey, { getTenantDetails, totalCount }, 60 * 60); + + return successResponse; } catch (error) { const errorMessage = error.message || API_RESPONSES.INTERNAL_SERVER_ERROR; LoggerUtil.error( @@ -218,6 +261,9 @@ export class TenantService { } const result = await this.tenantRepository.save(tenantCreateDto); + // Invalidate caches + await this.cacheService.del("tenants:all"); + await this.cacheService.reset(); if (result) { return APIResponse.success( response, @@ -268,6 +314,12 @@ export class TenantService { const result = await this.tenantRepository.delete(tenantId); + // Invalidate caches + if (result && result.affected && result.affected > 0) { + await this.cacheService.del("tenants:all"); + await this.cacheService.reset(); + } + if (result && result.affected && result.affected > 0) { return APIResponse.success( response, @@ -368,6 +420,8 @@ export class TenantService { tenantUpdateDto, ); if (result && result.affected && result.affected > 0) { + await this.cacheService.del("tenants:all"); + await this.cacheService.reset(); return APIResponse.success( response, apiId, diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 74993054..05266646 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -57,6 +57,8 @@ import { LoggerUtil } from "src/common/logger/LoggerUtil"; import { OtpSendDTO } from "./dto/otpSend.dto"; import { OtpVerifyDTO } from "./dto/otpVerify.dto"; import { UploadS3Service } from "src/common/services/upload-S3.service"; +import { CacheService } from "src/cache/cache.service"; +import { createHash } from "crypto"; import { GetUserId } from "src/common/decorators/getUserId.decorator"; export interface UserData { context: string; @@ -71,6 +73,7 @@ export class UserController { constructor( private userAdapter: UserAdapter, private readonly uploadS3Service: UploadS3Service, + private readonly cacheService: CacheService, ) {} @UseFilters(new AllExceptionsFilter(APIID.USER_GET)) @@ -115,10 +118,25 @@ export class UserController { userId: userId, fieldValue: fieldValueBoolean, }; + + // -------------------- CACHE START -------------------- + const cacheKey = `user:profile:${userId}:${tenantId}:${fieldValueBoolean}`; + const cached = await this.cacheService.get(cacheKey); + if (cached) { + // assumption: cached object already contains statusCode & payload + return response.status(cached.statusCode || 200).json(cached); + } + // -------------------- CACHE END -------------------- const result = await this.userAdapter .buildUserAdapter() .getUsersDetailsById(userData, response); + // Cache only on successful fetch (2xx) + if (result && result.statusCode && result.statusCode >= 200 && result.statusCode < 300) { + // 15 minutes TTL (900 seconds) + await this.cacheService.set(cacheKey, result, 15 * 60); + } + return response.status(result.statusCode).json(result); } @@ -201,9 +219,28 @@ export class UserController { @Body() userSearchDto: UserSearchDto, ) { const tenantId = headers["tenantid"]; - return await this.userAdapter + + // Generate a hash of the search DTO for cache key uniqueness + const hash = createHash("md5") + .update(JSON.stringify(userSearchDto)) + .digest("hex"); + const cacheKey = `users:search:${hash}:${tenantId}`; + + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return response.status(cached.statusCode || 200).json(cached); + } + + const result = await this.userAdapter .buildUserAdapter() .searchUser(tenantId, request, response, userSearchDto); + + if (result && result.statusCode && result.statusCode >= 200 && result.statusCode < 300) { + // 10 minutes TTL + await this.cacheService.set(cacheKey, result, 10 * 60); + } + + return response.status(result.statusCode).json(result); } @Post("/password-reset-link") @@ -355,12 +392,22 @@ export class UserController { @Query("fileType") fileType: string, @Res() response, ) { + const cacheKey = `presigned:${filename}:${foldername}:${fileType}`; + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return { url: cached }; + } + const url = await this.uploadS3Service.getPresignedUrl( filename, fileType, response, foldername, ); + + // Cache for 10 minutes + await this.cacheService.set(cacheKey, url, 10 * 60); + return { url }; } }