diff --git a/src/app.module.ts b/src/app.module.ts index 863da9e..8a4f118 100755 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { rateLimitConfig } from "./configs/rateLimit.config"; // import { telegrafConfig } from "./configs/telegraf.config"; import { databaseConfigs } from "./configs/typeorm.config"; import { HTTPLogger } from "./core/middlewares/logger.middleware"; +import { AccessLogsModule } from "./modules/access-logs/access-logs.module"; import { AddressModule } from "./modules/address/address.module"; import { AdsModule } from "./modules/ads/ads.module"; import { AnnouncementsModule } from "./modules/announcements/announcement.module"; @@ -83,6 +84,7 @@ import { WalletsModule } from "./modules/wallets/wallets.module"; ReferralsModule, DiscountsModule, SupportPlansModule, + AccessLogsModule, ], controllers: [], }) diff --git a/src/common/decorators/access-log.decorator.ts b/src/common/decorators/access-log.decorator.ts new file mode 100644 index 0000000..e1e549a --- /dev/null +++ b/src/common/decorators/access-log.decorator.ts @@ -0,0 +1,16 @@ +import { SetMetadata } from "@nestjs/common"; + +import { OperationType } from "../../modules/access-logs/enums/operation-type.enum"; + +export interface AccessLogOptions { + operationType: OperationType; + entityName: string; + description?: string; + logOldValues?: boolean; + logNewValues?: boolean; + logRequest?: boolean; +} + +export const ACCESS_LOG_KEY = "access_log"; + +export const AccessLog = (options: AccessLogOptions) => SetMetadata(ACCESS_LOG_KEY, options); diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 0b994aa..91cf7db 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -123,7 +123,9 @@ export const enum CommonMessage { SEARCH_QUERY_STRING = "رشته جستجو باید یک رشته باشد", UPDATED = "با موفقیت آپدیت شد", IS_ACTIVE_SHOULD_BE_1_0 = "وضعیت باید یکی از مقادیر ۰ و ۱ باشد", - PAGINATE_SHOULD_BE_1_0 = "PAGINATE_SHOULD_BE_1_0", + PAGINATE_SHOULD_BE_1_0 = "صفحه بندی باید یکی از مقادیر ۰ و ۱ باشد", + ENTITY_NAME_REQUIRED = "نام موجودیت مورد نیاز است", + ENTITY_NAME_STRING = "نام موجودیت باید یک رشته باشد", } export const enum CategoryMessage { @@ -817,6 +819,8 @@ export const enum BlogMessage { PARENT_REPLY_ID_SHOULD_BE_A_UUID = "شناسه نظر والد باید یک UUID باشد", PARENT_REPLY_ID_REQUIRED = "شناسه نظر والد مورد نیاز است", REPLY_ADDED = "نظر با موفقیت ثبت شد", + AUDIO_URL_STRING = "AUDIO_URL_STRING", + AUDIO_URL_REQUIRED = "AUDIO_URL_REQUIRED", } export const enum SliderMessage { @@ -890,3 +894,7 @@ export const enum UploaderMessage { UPLOAD_FILE_TOO_LARGE = "[MAX_FILE_SIZE] فایل بزرگتر از حد مجاز است", UPLOAD_FILE_TYPE_NOT_SUPPORTED = "[MIME_TYPES] نوع فایل مجاز نیست", } + +export const enum AccessLogMessage { + ACCESS_LOG_NOT_FOUND = "لاگ دسترسی مورد نظر یافت نشد", +} diff --git a/src/configs/typeorm.config.ts b/src/configs/typeorm.config.ts index 8d8378f..9391ab2 100755 --- a/src/configs/typeorm.config.ts +++ b/src/configs/typeorm.config.ts @@ -13,7 +13,7 @@ export function databaseConfigs(): TypeOrmModuleAsyncOptions { username: configService.getOrThrow("DB_USER"), password: configService.getOrThrow("DB_PASS"), autoLoadEntities: true, - synchronize: configService.getOrThrow("NODE_ENV") == "production" ? false : true, + synchronize: configService.getOrThrow("NODE_ENV") == "production" ? false : false, logging: configService.getOrThrow("NODE_ENV") == "production" ? false : true, migrationsTableName: "typeorm_migrations", migrationsRun: false, diff --git a/src/modules/access-logs/DTO/access-log-query.dto.ts b/src/modules/access-logs/DTO/access-log-query.dto.ts new file mode 100644 index 0000000..67baee6 --- /dev/null +++ b/src/modules/access-logs/DTO/access-log-query.dto.ts @@ -0,0 +1,64 @@ +import { ApiPropertyOptional, PickType } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsDateString, IsEnum, IsNumber, IsOptional, IsString, IsUUID } from "class-validator"; + +import { PaginationDto } from "../../../common/DTO/pagination.dto"; +import { OperationType } from "../enums/operation-type.enum"; +import { UserType } from "../enums/user-type.enum"; + +export class AccessLogQueryDto extends PaginationDto { + @IsOptional() + @IsEnum(OperationType) + @ApiPropertyOptional({ enum: OperationType, description: "operation type" }) + operationType?: OperationType; + + @IsOptional() + @IsEnum(UserType) + @ApiPropertyOptional({ enum: UserType, description: "user type" }) + userType?: UserType; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "entity name" }) + entityName?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "entity id" }) + entityId?: string; + + @IsOptional() + @IsUUID() + @ApiPropertyOptional({ description: "user id" }) + userId?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "endpoint" }) + endpoint?: string; + + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ description: "status code" }) + statusCode?: number; + + @IsOptional() + @IsDateString({}, { message: "startDate must be a valid date" }) + @ApiPropertyOptional({ description: "start date" }) + startDate?: string; + + @IsOptional() + @IsDateString({}, { message: "endDate must be a valid date" }) + @ApiPropertyOptional({ description: "end date" }) + endDate?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "search" }) + search?: string; // For searching in actionDescription, entityName, etc. +} + +export class AccessLogStatsQueryDto extends PickType(AccessLogQueryDto, ["page", "limit", "startDate", "endDate"]) {} + +export class EntityIdQueryDto extends PickType(AccessLogQueryDto, ["entityId", "page", "limit"]) {} diff --git a/src/modules/access-logs/DTO/create-access-log.dto.ts b/src/modules/access-logs/DTO/create-access-log.dto.ts new file mode 100644 index 0000000..567fcbb --- /dev/null +++ b/src/modules/access-logs/DTO/create-access-log.dto.ts @@ -0,0 +1,64 @@ +import { IsEnum, IsNumber, IsObject, IsOptional, IsString, IsUUID } from "class-validator"; + +import { User } from "../../users/entities/user.entity"; +import { OperationType } from "../enums/operation-type.enum"; +import { UserType } from "../enums/user-type.enum"; + +export class CreateAccessLogDto { + @IsEnum(OperationType) + operationType: OperationType; + + @IsEnum(UserType) + userType: UserType; + + @IsString() + entityName: string; + + @IsOptional() + @IsString() + entityId?: string; + + @IsOptional() + @IsString() + actionDescription?: string; + + @IsOptional() + @IsString() + oldValues?: string; + + @IsOptional() + @IsString() + newValues?: string; + + @IsOptional() + @IsString() + ipAddress?: string; + + @IsOptional() + @IsString() + userAgent?: string; + + @IsOptional() + @IsString() + endpoint?: string; + + @IsOptional() + @IsNumber() + statusCode?: number; + + @IsOptional() + @IsString() + errorMessage?: string; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsUUID() + user?: Pick; + + @IsOptional() + @IsString() + requestId?: string; +} diff --git a/src/modules/access-logs/DTO/entity-id-query.dto.ts b/src/modules/access-logs/DTO/entity-id-query.dto.ts new file mode 100644 index 0000000..4540bb1 --- /dev/null +++ b/src/modules/access-logs/DTO/entity-id-query.dto.ts @@ -0,0 +1,9 @@ +import { IsOptional, IsString } from "class-validator"; + +import { PaginationDto } from "../../../common/DTO/pagination.dto"; + +export class EntityIdQueryDto extends PaginationDto { + @IsOptional() + @IsString() + entityId?: string; +} diff --git a/src/modules/access-logs/DTO/entity-name-param.dto.ts b/src/modules/access-logs/DTO/entity-name-param.dto.ts new file mode 100644 index 0000000..d494136 --- /dev/null +++ b/src/modules/access-logs/DTO/entity-name-param.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +import { CommonMessage } from "../../../common/enums/message.enum"; + +export class EntityNameParamDto { + @IsNotEmpty({ message: CommonMessage.ENTITY_NAME_REQUIRED }) + @IsString({ message: CommonMessage.ENTITY_NAME_STRING }) + @ApiProperty({ description: "Entity name", example: "user" }) + entityName: string; +} diff --git a/src/modules/access-logs/FRONTEND_IMPLEMENTATION_GUIDE.md b/src/modules/access-logs/FRONTEND_IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..e19882d --- /dev/null +++ b/src/modules/access-logs/FRONTEND_IMPLEMENTATION_GUIDE.md @@ -0,0 +1,861 @@ +# Access Log Module - Frontend Implementation Guide + +This guide provides everything a frontend engineer needs to implement the Access Log module interface. + +## 📋 Table of Contents + +1. [API Endpoints](#api-endpoints) +2. [Data Models](#data-models) +3. [Enums & Constants](#enums--constants) +4. [Request/Response Examples](#requestresponse-examples) +5. [UI Components](#ui-components) +6. [State Management](#state-management) +7. [Implementation Examples](#implementation-examples) + +## 🔗 API Endpoints + +### Base URL + +``` +/api/access-logs +``` + +### Authentication + +All endpoints require: + +- **Authorization**: Bearer token in header +- **Permissions**: `LOGS` permission +- **Role**: Admin access only + +### Endpoints Overview + +| Method | Endpoint | Description | Query Parameters | +| ------ | --------------------------------- | ---------------------------------- | ----------------------------------------- | +| `GET` | `/access-logs` | Get all access logs with filtering | See [Query Parameters](#query-parameters) | +| `GET` | `/access-logs/stats` | Get operation statistics | `startDate`, `endDate`, `page`, `limit` | +| `GET` | `/access-logs/top-users` | Get top users by activity | `startDate`, `endDate`, `limit` | +| `GET` | `/access-logs/errors` | Get error logs only | See [Query Parameters](#query-parameters) | +| `GET` | `/access-logs/user/:id` | Get logs for specific user | See [Query Parameters](#query-parameters) | +| `GET` | `/access-logs/entity/:entityName` | Get logs for specific entity | `entityId`, `page`, `limit` | +| `GET` | `/access-logs/:id` | Get specific log by ID | None | + +## 📊 Data Models + +### AccessLog Entity + +```typescript +interface AccessLog { + id: string; + operationType: OperationType; + userType: UserType; + entityName: string; + entityId?: string; + actionDescription?: string; + oldValues?: string; // JSON string + newValues?: string; // JSON string + ipAddress?: string; + userAgent?: string; + endpoint?: string; + httpMethod?: string; + statusCode?: number; + errorMessage?: string; + metadata?: Record; // JSON object + userId?: string; + sessionId?: string; + requestId?: string; + executionTime?: number; // in milliseconds + createdAt: string; // ISO date string + updatedAt: string; // ISO date string + user?: { + id: string; + firstName: string; + lastName: string; + email: string; + phone: string; + }; +} +``` + +### API Response Models + +```typescript +// Standard paginated response +interface AccessLogsResponse { + accessLogs: AccessLog[]; + count: number; + paginate: boolean; +} + +// Statistics response +interface OperationStatsResponse { + operationStats: Array<{ + operationType: OperationType; + userType: UserType; + entityName: string; + count: number; + }>; + count: number; + paginate: boolean; +} + +// Top users response +interface TopUsersResponse { + topUsers: Array<{ + userId: string; + firstName: string; + lastName: string; + email: string; + activityCount: number; + }>; + count: number; + paginate: boolean; +} +``` + +## 🏷️ Enums & Constants + +### Operation Types + +```typescript +enum OperationType { + CREATE = "CREATE", + UPDATE = "UPDATE", + DELETE = "DELETE", + READ = "READ", + LOGIN = "LOGIN", + LOGOUT = "LOGOUT", + APPROVE = "APPROVE", + REJECT = "REJECT", + ACTIVATE = "ACTIVATE", + DEACTIVATE = "DEACTIVATE", + EXPORT = "EXPORT", + IMPORT = "IMPORT", + BULK_UPDATE = "BULK_UPDATE", + BULK_DELETE = "BULK_DELETE", +} +``` + +### User Types + +```typescript +enum UserType { + SYSTEM = "SYSTEM", + ADMIN = "ADMIN", + USER = "USER", +} +``` + +### HTTP Methods + +```typescript +enum HttpMethod { + GET = "GET", + POST = "POST", + PUT = "PUT", + PATCH = "PATCH", + DELETE = "DELETE", +} +``` + +## 🔍 Query Parameters + +### AccessLogQueryDto + +```typescript +interface AccessLogQuery { + // Pagination + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; + + // Filters + operationType?: OperationType; + userType?: UserType; + entityName?: string; + entityId?: string; + userId?: string; + ipAddress?: string; + endpoint?: string; + httpMethod?: string; + statusCode?: number; + + // Date range + startDate?: string; // ISO date string + endDate?: string; // ISO date string + + // Search + search?: string; // Searches in actionDescription and entityName + + // Performance + minExecutionTime?: number; // milliseconds + maxExecutionTime?: number; // milliseconds +} +``` + +## 📝 Request/Response Examples + +### 1. Get All Access Logs + +```typescript +// Request +GET /api/access-logs?page=1&limit=20&operationType=CREATE&userType=ADMIN&startDate=2024-01-01&endDate=2024-12-31 + +// Response +{ + "accessLogs": [ + { + "id": "uuid-123", + "operationType": "CREATE", + "userType": "ADMIN", + "entityName": "User", + "entityId": "user-uuid-456", + "actionDescription": "Admin created user: john@example.com", + "ipAddress": "192.168.1.1", + "userAgent": "Mozilla/5.0...", + "endpoint": "/api/users", + "httpMethod": "POST", + "statusCode": 201, + "executionTime": 150, + "createdAt": "2024-01-15T10:30:00Z", + "user": { + "id": "admin-uuid-789", + "firstName": "Admin", + "lastName": "User", + "email": "admin@example.com" + }, + "metadata": { + "createdBy": "admin", + "email": "john@example.com" + } + } + ], + "count": 150, + "paginate": true +} +``` + +### 2. Get Operation Statistics + +```typescript +// Request +GET /api/access-logs/stats?startDate=2024-01-01&endDate=2024-01-31&limit=10 + +// Response +{ + "operationStats": [ + { + "operationType": "CREATE", + "userType": "ADMIN", + "entityName": "User", + "count": 45 + }, + { + "operationType": "UPDATE", + "userType": "ADMIN", + "entityName": "Blog", + "count": 32 + } + ], + "count": 8, + "paginate": true +} +``` + +### 3. Get Top Users by Activity + +```typescript +// Request +GET /api/access-logs/top-users?limit=5&startDate=2024-01-01&endDate=2024-01-31 + +// Response +{ + "topUsers": [ + { + "userId": "admin-uuid-123", + "firstName": "Admin", + "lastName": "User", + "email": "admin@example.com", + "activityCount": 156 + } + ], + "count": 5, + "paginate": true +} +``` + +### 4. Get Error Logs + +```typescript +// Request +GET /api/access-logs/errors?page=1&limit=10&startDate=2024-01-01 + +// Response +{ + "errorLogs": [ + { + "id": "error-uuid-123", + "operationType": "CREATE", + "entityName": "User", + "errorMessage": "Email already exists", + "statusCode": 400, + "createdAt": "2024-01-15T10:30:00Z", + "user": { + "id": "admin-uuid-789", + "firstName": "Admin", + "lastName": "User" + } + } + ], + "count": 25, + "paginate": true +} +``` + +## 🎨 UI Components + +### 1. Access Logs Table Component + +```typescript +interface AccessLogsTableProps { + logs: AccessLog[]; + loading: boolean; + onFilterChange: (filters: AccessLogQuery) => void; + onPageChange: (page: number) => void; + onSort: (field: string, order: "ASC" | "DESC") => void; +} + +// Key columns to display: +// - Date/Time +// - Operation Type (with color coding) +// - User Type (with badges) +// - Entity Name +// - Action Description +// - User (who performed action) +// - IP Address +// - Status Code +// - Execution Time +// - Actions (View Details) +``` + +### 2. Filter Panel Component + +```typescript +interface FilterPanelProps { + filters: AccessLogQuery; + onFiltersChange: (filters: AccessLogQuery) => void; + onReset: () => void; +} + +// Filter options: +// - Date Range Picker +// - Operation Type Dropdown +// - User Type Dropdown +// - Entity Name Input +// - User ID Input +// - IP Address Input +// - HTTP Method Dropdown +// - Status Code Input +// - Search Input +// - Execution Time Range +``` + +### 3. Statistics Dashboard Component + +```typescript +interface StatsDashboardProps { + stats: OperationStatsResponse; + topUsers: TopUsersResponse; + loading: boolean; +} + +// Display: +// - Operation Type Chart (Pie/Bar) +// - User Type Distribution +// - Entity Activity Chart +// - Top Users Table +// - Time-based Activity Chart +``` + +### 4. Log Details Modal Component + +```typescript +interface LogDetailsModalProps { + log: AccessLog | null; + isOpen: boolean; + onClose: () => void; +} + +// Display: +// - Basic Information +// - Request Details +// - Old Values (if available) +// - New Values (if available) +// - Metadata +// - Error Details (if error) +``` + +## 🔄 State Management + +### Redux/Zustand Store Structure + +```typescript +interface AccessLogsState { + // Data + logs: AccessLog[]; + stats: OperationStatsResponse | null; + topUsers: TopUsersResponse | null; + errorLogs: AccessLog[]; + currentLog: AccessLog | null; + + // UI State + loading: boolean; + error: string | null; + filters: AccessLogQuery; + pagination: { + page: number; + limit: number; + total: number; + }; + + // Actions + fetchLogs: (filters?: AccessLogQuery) => Promise; + fetchStats: (filters?: AccessLogStatsQueryDto) => Promise; + fetchTopUsers: (filters?: AccessLogStatsQueryDto) => Promise; + fetchErrorLogs: (filters?: AccessLogQuery) => Promise; + fetchLogById: (id: string) => Promise; + setFilters: (filters: AccessLogQuery) => void; + setPage: (page: number) => void; + clearError: () => void; +} +``` + +## 💻 Implementation Examples + +### 1. React Hook for Access Logs + +```typescript +import { useState, useEffect } from "react"; +import { AccessLog, AccessLogQuery } from "./types"; + +export const useAccessLogs = () => { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState({ + page: 1, + limit: 20, + }); + + const fetchLogs = async (newFilters?: AccessLogQuery) => { + setLoading(true); + setError(null); + + try { + const queryParams = new URLSearchParams(); + const currentFilters = newFilters || filters; + + Object.entries(currentFilters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + queryParams.append(key, value.toString()); + } + }); + + const response = await fetch(`/api/access-logs?${queryParams}`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch access logs"); + } + + const data = await response.json(); + setLogs(data.accessLogs); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchLogs(); + }, [filters]); + + return { + logs, + loading, + error, + filters, + setFilters, + fetchLogs, + refetch: () => fetchLogs(), + }; +}; +``` + +### 2. API Service Class + +```typescript +class AccessLogsService { + private baseUrl = "/api/access-logs"; + private token = localStorage.getItem("token"); + + private async request(endpoint: string, options: RequestInit = {}): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + ...options, + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + ...options.headers, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + } + + async getLogs(filters: AccessLogQuery): Promise { + const params = new URLSearchParams(); + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + params.append(key, value.toString()); + } + }); + + return this.request(`?${params}`); + } + + async getStats(filters: AccessLogStatsQueryDto): Promise { + const params = new URLSearchParams(); + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + params.append(key, value.toString()); + } + }); + + return this.request(`/stats?${params}`); + } + + async getTopUsers(filters: AccessLogStatsQueryDto): Promise { + const params = new URLSearchParams(); + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + params.append(key, value.toString()); + } + }); + + return this.request(`/top-users?${params}`); + } + + async getErrorLogs(filters: AccessLogQuery): Promise { + const params = new URLSearchParams(); + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + params.append(key, value.toString()); + } + }); + + return this.request(`/errors?${params}`); + } + + async getLogById(id: string): Promise<{ accessLog: AccessLog }> { + return this.request<{ accessLog: AccessLog }>(`/${id}`); + } + + async getUserLogs(userId: string, filters: AccessLogQuery): Promise { + const params = new URLSearchParams(); + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + params.append(key, value.toString()); + } + }); + + return this.request(`/user/${userId}?${params}`); + } + + async getEntityLogs(entityName: string, filters: EntityIdQueryDto): Promise { + const params = new URLSearchParams(); + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + params.append(key, value.toString()); + } + }); + + return this.request(`/entity/${entityName}?${params}`); + } +} + +export const accessLogsService = new AccessLogsService(); +``` + +### 3. React Component Example + +```typescript +import React, { useState, useEffect } from 'react'; +import { AccessLog, OperationType, UserType } from './types'; +import { useAccessLogs } from './hooks/useAccessLogs'; + +const AccessLogsPage: React.FC = () => { + const { logs, loading, error, filters, setFilters, fetchLogs } = useAccessLogs(); + const [selectedLog, setSelectedLog] = useState(null); + + const handleFilterChange = (newFilters: Partial) => { + setFilters({ ...filters, ...newFilters, page: 1 }); + }; + + const handlePageChange = (page: number) => { + setFilters({ ...filters, page }); + }; + + const getOperationTypeColor = (type: OperationType): string => { + const colors = { + CREATE: 'green', + UPDATE: 'blue', + DELETE: 'red', + READ: 'gray', + LOGIN: 'purple', + LOGOUT: 'orange', + APPROVE: 'green', + REJECT: 'red', + ACTIVATE: 'green', + DEACTIVATE: 'red', + EXPORT: 'blue', + IMPORT: 'blue', + BULK_UPDATE: 'blue', + BULK_DELETE: 'red' + }; + return colors[type] || 'gray'; + }; + + const getUserTypeBadge = (type: UserType): string => { + const badges = { + SYSTEM: 'bg-gray-100 text-gray-800', + ADMIN: 'bg-red-100 text-red-800', + USER: 'bg-blue-100 text-blue-800' + }; + return badges[type] || 'bg-gray-100 text-gray-800'; + }; + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( +
+

Access Logs

+ + {/* Filter Panel */} +
+
+
+ + +
+ +
+ + +
+ +
+ + handleFilterChange({ entityName: e.target.value })} + className="w-full p-2 border rounded" + placeholder="e.g., User, Blog" + /> +
+ +
+ + handleFilterChange({ search: e.target.value })} + className="w-full p-2 border rounded" + placeholder="Search in descriptions..." + /> +
+
+
+ + {/* Logs Table */} +
+ + + + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + + + ))} + +
+ Date/Time + + Operation + + User Type + + Entity + + Description + + User + + Status + + Actions +
+ {new Date(log.createdAt).toLocaleString()} + + + {log.operationType} + + + + {log.userType} + + + {log.entityName} + + {log.actionDescription} + + {log.user ? `${log.user.firstName} ${log.user.lastName}` : 'System'} + + = 200 && log.statusCode < 300 + ? 'bg-green-100 text-green-800' + : 'bg-red-100 text-red-800' + }`}> + {log.statusCode || 'N/A'} + + + +
+
+ + {/* Log Details Modal */} + {selectedLog && ( + setSelectedLog(null)} + /> + )} +
+ ); +}; + +export default AccessLogsPage; +``` + +## 🎯 Key Features to Implement + +### 1. **Advanced Filtering** + +- Date range picker +- Multi-select dropdowns +- Real-time search +- Filter presets +- Export filtered results + +### 2. **Data Visualization** + +- Operation type distribution chart +- User activity timeline +- Error rate trends +- Performance metrics + +### 3. **Real-time Updates** + +- WebSocket connection for live updates +- Auto-refresh functionality +- Real-time notifications for critical operations + +### 4. **Export Functionality** + +- CSV export +- PDF reports +- Scheduled reports +- Custom date ranges + +### 5. **Security Features** + +- IP address masking for privacy +- Sensitive data filtering +- Access control based on user roles +- Audit trail for log access + +## 🚀 Getting Started + +1. **Set up API client** with authentication +2. **Create TypeScript interfaces** for all data models +3. **Implement basic table component** with pagination +4. **Add filtering capabilities** +5. **Create detailed view modal** +6. **Add statistics dashboard** +7. **Implement real-time updates** +8. **Add export functionality** + +## 📱 Mobile Considerations + +- Responsive table design +- Collapsible filter panel +- Touch-friendly interactions +- Optimized for smaller screens +- Swipe gestures for navigation + +This guide provides everything needed to implement a comprehensive access logs interface. The modular approach allows for incremental implementation and easy maintenance. diff --git a/src/modules/access-logs/access-logs.controller.ts b/src/modules/access-logs/access-logs.controller.ts new file mode 100644 index 0000000..7f0b1eb --- /dev/null +++ b/src/modules/access-logs/access-logs.controller.ts @@ -0,0 +1,81 @@ +import { Controller, Get, Param, Query } from "@nestjs/common"; +import { ApiOperation, ApiResponse } from "@nestjs/swagger"; + +import { AccessLogQueryDto, AccessLogStatsQueryDto, EntityIdQueryDto } from "./DTO/access-log-query.dto"; +import { EntityNameParamDto } from "./DTO/entity-name-param.dto"; +import { AccessLogService } from "./providers/access-log.service"; +import { AdminRoute } from "../../common/decorators/admin.decorator"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { PermissionsDec } from "../../common/decorators/permission.decorator"; +import { ParamDto } from "../../common/DTO/param.dto"; +import { PermissionEnum } from "../users/enums/permission.enum"; + +@AuthGuards() +@Controller("access-logs") +export class AccessLogsController { + constructor(private readonly accessLogService: AccessLogService) {} + + @Get() + @AdminRoute() + @PermissionsDec(PermissionEnum.LOGS) + @ApiOperation({ summary: "Get access logs with filtering and pagination" }) + @ApiResponse({ status: 200, description: "Access logs retrieved successfully" }) + getLogs(@Query() queryDto: AccessLogQueryDto) { + return this.accessLogService.getLogs(queryDto); + } + + @Get("stats") + @AdminRoute() + @PermissionsDec(PermissionEnum.LOGS) + @ApiOperation({ summary: "Get operation statistics" }) + @ApiResponse({ status: 200, description: "Operation statistics retrieved successfully" }) + getOperationStats(@Query() queryDto: AccessLogStatsQueryDto) { + return this.accessLogService.getOperationStats(queryDto); + } + + @Get("top-users") + @AdminRoute() + @PermissionsDec(PermissionEnum.LOGS) + @ApiOperation({ summary: "Get top users by activity" }) + @ApiResponse({ status: 200, description: "Top users retrieved successfully" }) + getTopUsersByActivity(@Query() queryDto: AccessLogStatsQueryDto) { + return this.accessLogService.getTopUsersByActivity(queryDto); + } + + @Get("errors") + @AdminRoute() + @PermissionsDec(PermissionEnum.LOGS) + @ApiOperation({ summary: "Get error logs" }) + @ApiResponse({ status: 200, description: "Error logs retrieved successfully" }) + getErrorLogs(@Query() queryDto: AccessLogQueryDto) { + return this.accessLogService.getErrorLogs(queryDto); + } + + @Get("user/:id") + @AdminRoute() + @PermissionsDec(PermissionEnum.LOGS) + @ApiOperation({ summary: "Get logs for a specific user" }) + @ApiResponse({ status: 200, description: "User logs retrieved successfully" }) + getUserLogs(@Param() paramDto: ParamDto, @Query() queryDto: AccessLogQueryDto) { + return this.accessLogService.getLogsByUser(paramDto.id, queryDto); + } + + @Get("entity/:entityName") + @AdminRoute() + @PermissionsDec(PermissionEnum.LOGS) + @ApiOperation({ summary: "Get logs for a specific entity" }) + @ApiResponse({ status: 200, description: "Entity logs retrieved successfully" }) + getEntityLogs(@Param() paramDto: EntityNameParamDto, @Query() queryDto: EntityIdQueryDto) { + return this.accessLogService.getLogsByEntity(paramDto.entityName, queryDto); + } + + @Get(":id") + @AdminRoute() + @PermissionsDec(PermissionEnum.LOGS) + @ApiOperation({ summary: "Get a specific access log by ID" }) + @ApiResponse({ status: 200, description: "Access log retrieved successfully" }) + @ApiResponse({ status: 404, description: "Access log not found" }) + getLogById(@Param() paramDto: ParamDto) { + return this.accessLogService.getLogById(paramDto.id); + } +} diff --git a/src/modules/access-logs/access-logs.module.ts b/src/modules/access-logs/access-logs.module.ts new file mode 100644 index 0000000..f02fcf4 --- /dev/null +++ b/src/modules/access-logs/access-logs.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { AccessLogsController } from "./access-logs.controller"; +import { AccessLog } from "./entities/access-log.entity"; +import { AccessLogService } from "./providers/access-log.service"; +import { AccessLogRepository } from "./repositories/access-log.repository"; + +@Module({ + imports: [TypeOrmModule.forFeature([AccessLog])], + controllers: [AccessLogsController], + providers: [AccessLogService, AccessLogRepository], + exports: [AccessLogService, AccessLogRepository], +}) +export class AccessLogsModule {} diff --git a/src/modules/access-logs/entities/access-log.entity.ts b/src/modules/access-logs/entities/access-log.entity.ts new file mode 100644 index 0000000..be577b6 --- /dev/null +++ b/src/modules/access-logs/entities/access-log.entity.ts @@ -0,0 +1,54 @@ +import { Column, Entity, ManyToOne } from "typeorm"; + +import { BaseEntity } from "../../../common/entities/base.entity"; +import { User } from "../../users/entities/user.entity"; +import { OperationType } from "../enums/operation-type.enum"; +import { UserType } from "../enums/user-type.enum"; + +@Entity("access_logs") +export class AccessLog extends BaseEntity { + @Column({ type: "enum", enum: OperationType }) + operationType: OperationType; + + @Column({ type: "enum", enum: UserType }) + userType: UserType; + + @Column({ type: "varchar", length: 255 }) + entityName: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + entityId: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + actionDescription: string; + + @Column({ type: "text", nullable: true }) + oldValues: string; + + @Column({ type: "text", nullable: true }) + newValues: string; + + @Column({ type: "varchar", length: 45, nullable: true }) + ipAddress: string; + + @Column({ type: "varchar", length: 500, nullable: true }) + userAgent: string; + + @Column({ type: "varchar", length: 255, nullable: true }) + endpoint: string; + + @Column({ type: "integer", nullable: true }) + statusCode: number; + + @Column({ type: "text", nullable: true }) + errorMessage: string; + + @Column({ type: "jsonb", nullable: true }) + metadata: Record; + + @ManyToOne(() => User, { nullable: true }) + user: User; + + @Column({ type: "varchar", length: 255, nullable: true }) + requestId: string; +} diff --git a/src/modules/access-logs/enums/operation-type.enum.ts b/src/modules/access-logs/enums/operation-type.enum.ts new file mode 100644 index 0000000..26bd279 --- /dev/null +++ b/src/modules/access-logs/enums/operation-type.enum.ts @@ -0,0 +1,16 @@ +export enum OperationType { + CREATE = "CREATE", + UPDATE = "UPDATE", + DELETE = "DELETE", + READ = "READ", + LOGIN = "LOGIN", + LOGOUT = "LOGOUT", + APPROVE = "APPROVE", + REJECT = "REJECT", + ACTIVATE = "ACTIVATE", + DEACTIVATE = "DEACTIVATE", + EXPORT = "EXPORT", + IMPORT = "IMPORT", + BULK_UPDATE = "BULK_UPDATE", + BULK_DELETE = "BULK_DELETE", +} diff --git a/src/modules/access-logs/enums/user-type.enum.ts b/src/modules/access-logs/enums/user-type.enum.ts new file mode 100644 index 0000000..1daf784 --- /dev/null +++ b/src/modules/access-logs/enums/user-type.enum.ts @@ -0,0 +1,5 @@ +export enum UserType { + SYSTEM = "SYSTEM", + ADMIN = "ADMIN", + USER = "USER", +} diff --git a/src/modules/access-logs/interfaces/log-context.interface.ts b/src/modules/access-logs/interfaces/log-context.interface.ts new file mode 100644 index 0000000..27dfd5c --- /dev/null +++ b/src/modules/access-logs/interfaces/log-context.interface.ts @@ -0,0 +1,20 @@ +import { FastifyRequest } from "fastify"; + +import { User } from "../../users/entities/user.entity"; +import { OperationType } from "../enums/operation-type.enum"; +import { UserType } from "../enums/user-type.enum"; + +export interface ILogContext { + operationType: OperationType; + entityName: string; + entityId?: string; + actionDescription?: string; + oldValues?: any; + newValues?: any; + user?: Pick; + userType?: UserType; + metadata?: Record; + request?: FastifyRequest; + statusCode?: number; + errorMessage?: string; +} diff --git a/src/modules/access-logs/providers/access-log-example.service.ts b/src/modules/access-logs/providers/access-log-example.service.ts new file mode 100644 index 0000000..312512a --- /dev/null +++ b/src/modules/access-logs/providers/access-log-example.service.ts @@ -0,0 +1,133 @@ +// import { Injectable } from "@nestjs/common"; + +// import { AccessLogService } from "./access-log.service"; +// import { OperationType } from "../enums/operation-type.enum"; +// import { UserType } from "../enums/user-type.enum"; + +// /** +// * Example service demonstrating how to use the AccessLogService +// * This service shows various ways to log operations in your application +// */ +// @Injectable() +// export class AccessLogExampleService { +// constructor(private readonly accessLogService: AccessLogService) {} + +// /** +// * Example: Log a user creation +// */ +// async logUserCreation(userId: string, userData: any, adminId: string) { +// return await this.accessLogService.logCreate("User", userId, userData, { +// userId: adminId, +// userType: UserType.ADMIN, +// actionDescription: `Admin created new user: ${userData.email}`, +// metadata: { createdBy: adminId, userEmail: userData.email }, +// }); +// } + +// /** +// * Example: Log a user update +// */ +// async logUserUpdate(userId: string, oldData: any, newData: any, adminId: string) { +// return await this.accessLogService.logUpdate("User", userId, oldData, newData, { +// userId: adminId, +// userType: UserType.ADMIN, +// actionDescription: `Admin updated user: ${newData.email}`, +// metadata: { updatedBy: adminId, userEmail: newData.email }, +// }); +// } + +// /** +// * Example: Log a user deletion +// */ +// async logUserDeletion(userId: string, userData: any, adminId: string) { +// return await this.accessLogService.logDelete("User", userId, userData, { +// userId: adminId, +// userType: UserType.ADMIN, +// actionDescription: `Admin deleted user: ${userData.email}`, +// metadata: { deletedBy: adminId, userEmail: userData.email }, +// }); +// } + +// /** +// * Example: Log a blog post creation +// */ +// async logBlogCreation(blogId: string, blogData: any, authorId: string) { +// return await this.accessLogService.logCreate("Blog", blogId, blogData, { +// userId: authorId, +// userType: UserType.ADMIN, +// actionDescription: `Created blog post: ${blogData.title}`, +// metadata: { authorId, blogTitle: blogData.title }, +// }); +// } + +// /** +// * Example: Log a payment processing +// */ +// async logPaymentProcessing(paymentId: string, paymentData: any, systemContext: any) { +// return await this.accessLogService.logSystemAction( +// OperationType.UPDATE, +// "Payment", +// paymentId, +// `Payment processed: ${paymentData.amount} ${paymentData.currency}`, +// { +// userType: UserType.SYSTEM, +// metadata: { +// amount: paymentData.amount, +// currency: paymentData.currency, +// gateway: paymentData.gateway, +// status: paymentData.status, +// }, +// }, +// ); +// } + +// /** +// * Example: Log a user login +// */ +// async logUserLogin(userId: string, loginData: any, request: any) { +// return await this.accessLogService.logLogin(userId, { +// request, +// metadata: { +// loginMethod: loginData.method, +// userAgent: request?.get?.("User-Agent"), +// ipAddress: request?.ip, +// }, +// }); +// } + +// /** +// * Example: Log a bulk operation +// */ +// async logBulkUserUpdate(userIds: string[], updateData: any, adminId: string) { +// return await this.accessLogService.logAdminAction( +// OperationType.BULK_UPDATE, +// "User", +// userIds.join(","), +// `Bulk updated ${userIds.length} users`, +// { +// userId: adminId, +// userType: UserType.ADMIN, +// metadata: { +// updatedBy: adminId, +// affectedUsers: userIds.length, +// updateFields: Object.keys(updateData), +// }, +// }, +// ); +// } + +// /** +// * Example: Log an error +// */ +// async logError(error: Error, context: any, userId?: string) { +// return await this.accessLogService.logError(OperationType.UPDATE, context.entityName || "Unknown", error.message, { +// userId, +// userType: userId ? UserType.ADMIN : UserType.SYSTEM, +// metadata: { +// errorStack: error.stack, +// context, +// timestamp: new Date().toISOString(), +// }, +// }); +// } +// } diff --git a/src/modules/access-logs/providers/access-log.service.ts b/src/modules/access-logs/providers/access-log.service.ts new file mode 100644 index 0000000..38a182b --- /dev/null +++ b/src/modules/access-logs/providers/access-log.service.ts @@ -0,0 +1,240 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { FastifyRequest } from "fastify"; + +import { AccessLogMessage } from "../../../common/enums/message.enum"; +import { AccessLogQueryDto, AccessLogStatsQueryDto, EntityIdQueryDto } from "../DTO/access-log-query.dto"; +import { CreateAccessLogDto } from "../DTO/create-access-log.dto"; +import { OperationType } from "../enums/operation-type.enum"; +import { UserType } from "../enums/user-type.enum"; +import { ILogContext } from "../interfaces/log-context.interface"; +import { AccessLogRepository } from "../repositories/access-log.repository"; + +@Injectable() +export class AccessLogService { + constructor(private readonly accessLogRepository: AccessLogRepository) {} + + async logOperation(context: ILogContext) { + const logData: CreateAccessLogDto = { + operationType: context.operationType, + userType: context.userType || UserType.ADMIN, + entityName: context.entityName, + entityId: context.entityId, + actionDescription: context.actionDescription, + oldValues: context.oldValues ? JSON.stringify(context.oldValues) : undefined, + newValues: context.newValues ? JSON.stringify(context.newValues) : undefined, + user: context.user, + metadata: context.metadata, + statusCode: context.statusCode, + errorMessage: context.errorMessage, + }; + + // Extract request information if available + if (context.request) { + logData.ipAddress = this.getClientIp(context.request); + logData.userAgent = context.request.headers["user-agent"]; + logData.endpoint = context.request.url; + logData.requestId = context.request.headers["x-request-id"] as string; + } + + const accessLog = this.accessLogRepository.create({ ...logData }); + await this.accessLogRepository.save(accessLog); + + return { accessLog }; + } + + //######################################################################################## + async logCreate(entityName: string, entityId: string, newValues: Record, context: Partial = {}) { + return this.logOperation({ + operationType: OperationType.CREATE, + entityName, + entityId, + newValues, + actionDescription: `Created ${entityName}`, + ...context, + }); + } + + //######################################################################################## + async logUpdate( + entityName: string, + entityId: string, + oldValues: Record, + newValues: Record, + context: Partial = {}, + ) { + return this.logOperation({ + operationType: OperationType.UPDATE, + entityName, + entityId, + oldValues, + newValues, + actionDescription: `Updated ${entityName}`, + ...context, + }); + } + //######################################################################################## + async logDelete(entityName: string, entityId: string, oldValues: Record, context: Partial = {}) { + return this.logOperation({ + operationType: OperationType.DELETE, + entityName, + entityId, + oldValues, + actionDescription: `Deleted ${entityName}`, + ...context, + }); + } + + //######################################################################################## + async logRead(entityName: string, entityId?: string, context: Partial = {}) { + return this.logOperation({ + operationType: OperationType.READ, + entityName, + entityId, + actionDescription: `Read ${entityName}`, + ...context, + }); + } + + //######################################################################################## + async logLogin(userId: string, context: Partial = {}) { + return await this.logOperation({ + operationType: OperationType.LOGIN, + entityName: "User", + entityId: userId, + user: { id: userId }, + userType: UserType.USER, + actionDescription: "User login", + ...context, + }); + } + + //######################################################################################## + async logLogout(userId: string, context: Partial = {}) { + return await this.logOperation({ + operationType: OperationType.LOGOUT, + entityName: "User", + entityId: userId, + user: { id: userId }, + userType: UserType.USER, + actionDescription: "User logout", + ...context, + }); + } + + //######################################################################################## + async logAdminAction( + operationType: OperationType, + entityName: string, + entityId: string, + actionDescription: string, + context: Partial = {}, + ) { + return await this.logOperation({ + operationType, + entityName, + entityId, + actionDescription, + userType: UserType.ADMIN, + ...context, + }); + } + + //######################################################################################## + + async logUserAction( + operationType: OperationType, + entityName: string, + entityId: string, + actionDescription: string, + context: Partial = {}, + ) { + return this.logOperation({ + operationType, + entityName, + entityId, + actionDescription, + userType: UserType.USER, + ...context, + }); + } + + //######################################################################################## + async logSystemAction( + operationType: OperationType, + entityName: string, + entityId: string, + actionDescription: string, + context: Partial = {}, + ) { + return this.logOperation({ + operationType, + entityName, + entityId, + actionDescription, + userType: UserType.SYSTEM, + ...context, + }); + } + + //######################################################################################## + async logError(operationType: OperationType, entityName: string, errorMessage: string, context: Partial = {}) { + return this.logOperation({ + operationType, + entityName, + errorMessage, + actionDescription: `Error in ${operationType} operation on ${entityName}`, + statusCode: 500, + ...context, + }); + } + //######################################################################################## + async getLogs(queryDto: AccessLogQueryDto) { + const [accessLogs, count] = await this.accessLogRepository.findAll(queryDto); + return { accessLogs, count, paginate: true }; + } + + //######################################################################################## + async getLogsByUser(userId: string, queryDto: AccessLogQueryDto) { + const [accessLogs, count] = await this.accessLogRepository.findByUser(userId, queryDto); + return { accessLogs, count, paginate: true }; + } + + //######################################################################################## + async getLogsByEntity(entityName: string, queryDto: EntityIdQueryDto) { + const [accessLogs, count] = await this.accessLogRepository.findByEntity(entityName, queryDto); + return { accessLogs, count, paginate: true }; + } + + //######################################################################################## + async getOperationStats(queryDto: AccessLogStatsQueryDto) { + const [operationStats, count] = await this.accessLogRepository.getOperationStats(queryDto); + return { operationStats, count, paginate: true }; + } + + //######################################################################################## + async getTopUsersByActivity(queryDto: AccessLogStatsQueryDto) { + const [topUsers, count] = await this.accessLogRepository.getTopUsersByActivity(queryDto); + return { topUsers, count, paginate: true }; + } + + //######################################################################################## + async getErrorLogs(queryDto: AccessLogQueryDto) { + const [errorLogs, count] = await this.accessLogRepository.getErrorLogs(queryDto); + + return { errorLogs, count, paginate: true }; + } + + //######################################################################################## + async getLogById(id: string) { + const accessLog = await this.accessLogRepository.findById(id); + if (!accessLog) throw new BadRequestException(AccessLogMessage.ACCESS_LOG_NOT_FOUND); + return { accessLog }; + } + + //######################################################################################## + private getClientIp(request: FastifyRequest): string { + return ( + (request.headers["x-forwarded-for"] as string)?.split(",")[0] || (request.headers["x-real-ip"] as string) || request.ip || "unknown" + ); + } +} diff --git a/src/modules/access-logs/repositories/access-log.repository.ts b/src/modules/access-logs/repositories/access-log.repository.ts new file mode 100644 index 0000000..1a1f04a --- /dev/null +++ b/src/modules/access-logs/repositories/access-log.repository.ts @@ -0,0 +1,190 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository, SelectQueryBuilder } from "typeorm"; + +import { PaginationUtils } from "../../utils/providers/pagination.utils"; +import { AccessLogQueryDto, AccessLogStatsQueryDto, EntityIdQueryDto } from "../DTO/access-log-query.dto"; +import { AccessLog } from "../entities/access-log.entity"; + +@Injectable() +export class AccessLogRepository extends Repository { + // + constructor(@InjectRepository(AccessLog) repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } + + //######################################################################################## + async findById(id: string): Promise { + return await this.findOne({ where: { id }, relations: ["user"] }); + } + + //######################################################################################## + async findAll(queryDto: AccessLogQueryDto) { + const { limit, skip } = PaginationUtils(queryDto); + + const queryBuilder = this.createQueryBuilder("accessLog"); + queryBuilder.leftJoinAndSelect("accessLog.user", "user").addSelect(["user.id", "user.firstName", "user.lastName", "user.email"]); + + this.applyFilters(queryBuilder, queryDto); + + queryBuilder.take(limit).skip(skip).orderBy("accessLog.createdAt", "DESC"); + + return queryBuilder.getManyAndCount(); + } + + //######################################################################################## + async findByUser(userId: string, queryDto: AccessLogQueryDto) { + const { limit, skip } = PaginationUtils(queryDto); + + const queryBuilder = this.createQueryBuilder("accessLog"); + queryBuilder.where("accessLog.userId = :userId", { userId }); + + this.applyFilters(queryBuilder, queryDto); + queryBuilder.take(limit).skip(skip).orderBy("accessLog.createdAt", "DESC"); + + return queryBuilder.getManyAndCount(); + } + + //######################################################################################## + async findByEntity(entityName: string, queryDto: EntityIdQueryDto) { + const { limit, skip } = PaginationUtils(queryDto); + + const queryBuilder = this.createQueryBuilder("accessLog"); + queryBuilder.where("accessLog.entityName = :entityName", { entityName }); + + if (queryDto.entityId) { + queryBuilder.andWhere("accessLog.entityId = :entityId", { entityId: queryDto.entityId }); + } + + queryBuilder.orderBy("accessLog.createdAt", "DESC").take(limit).skip(skip); + + return queryBuilder.getManyAndCount(); + } + //######################################################################################## + async getOperationStats(queryDto: AccessLogStatsQueryDto) { + const startDate = queryDto.startDate ? new Date(queryDto.startDate) : undefined; + const endDate = queryDto.endDate ? new Date(queryDto.endDate) : undefined; + + const { limit, skip } = PaginationUtils(queryDto); + + const queryBuilder = this.createQueryBuilder("accessLog"); + queryBuilder + .select(["accessLog.operationType", "accessLog.userType", "accessLog.entityName", "COUNT(*) as count"]) + .groupBy("accessLog.operationType, accessLog.userType, accessLog.entityName"); + + if (startDate) { + queryBuilder.andWhere("accessLog.createdAt >= :startDate", { startDate }); + } + + if (endDate) { + queryBuilder.andWhere("accessLog.createdAt <= :endDate", { endDate }); + } + + queryBuilder.orderBy("count", "DESC"); + queryBuilder.take(limit).skip(skip); + + return queryBuilder.getManyAndCount(); + } + //######################################################################################## + async getTopUsersByActivity(queryDto: AccessLogStatsQueryDto) { + const startDate = queryDto.startDate ? new Date(queryDto.startDate) : undefined; + const endDate = queryDto.endDate ? new Date(queryDto.endDate) : undefined; + + const { limit, skip } = PaginationUtils(queryDto); + + const queryBuilder = this.createQueryBuilder("accessLog"); + queryBuilder + .select(["accessLog.userId", "user.firstName", "user.lastName", "user.email", "COUNT(*) as activityCount"]) + .leftJoin("accessLog.user", "user") + .where("accessLog.userId IS NOT NULL") + .groupBy("accessLog.userId, user.firstName, user.lastName, user.email"); + + if (startDate) { + queryBuilder.andWhere("accessLog.createdAt >= :startDate", { startDate }); + } + + if (endDate) { + queryBuilder.andWhere("accessLog.createdAt <= :endDate", { endDate }); + } + + queryBuilder.orderBy("activityCount", "DESC").take(limit).skip(skip); + + return queryBuilder.getManyAndCount(); + } + + //######################################################################################## + async getErrorLogs(queryDto: AccessLogQueryDto) { + const { limit, skip } = PaginationUtils(queryDto); + + const queryBuilder = this.createQueryBuilder("accessLog"); + queryBuilder.where("accessLog.errorMessage IS NOT NULL"); + + this.applyFilters(queryBuilder, queryDto); + + queryBuilder.orderBy("accessLog.createdAt", "DESC").take(limit).skip(skip); + return queryBuilder.getManyAndCount(); + } + + //######################################################################################## + async deleteOldLogs(olderThanDays: number): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - olderThanDays); + + const result = await this.createQueryBuilder().delete().where("createdAt < :cutoffDate", { cutoffDate }).execute(); + + return result.affected || 0; + } + + //######################################################################################## + private applyFilters(queryBuilder: SelectQueryBuilder, queryDto: AccessLogQueryDto): void { + if (queryDto.operationType) { + queryBuilder.andWhere("accessLog.operationType = :operationType", { + operationType: queryDto.operationType, + }); + } + + if (queryDto.userType) { + queryBuilder.andWhere("accessLog.userType = :userType", { userType: queryDto.userType }); + } + + if (queryDto.entityName) { + queryBuilder.andWhere("accessLog.entityName ILIKE :entityName", { + entityName: `%${queryDto.entityName}%`, + }); + } + + if (queryDto.entityId) { + queryBuilder.andWhere("accessLog.entityId = :entityId", { entityId: queryDto.entityId }); + } + + if (queryDto.userId) { + queryBuilder.andWhere("accessLog.userId = :userId", { userId: queryDto.userId }); + } + + if (queryDto.endpoint) { + queryBuilder.andWhere("accessLog.endpoint ILIKE :endpoint", { + endpoint: `%${queryDto.endpoint}%`, + }); + } + + if (queryDto.statusCode) { + queryBuilder.andWhere("accessLog.statusCode = :statusCode", { statusCode: queryDto.statusCode }); + } + + if (queryDto.startDate) { + queryBuilder.andWhere("accessLog.createdAt >= :startDate", { startDate: queryDto.startDate }); + } + + if (queryDto.endDate) { + queryBuilder.andWhere("accessLog.createdAt <= :endDate", { endDate: queryDto.endDate }); + } + + if (queryDto.search) { + queryBuilder.andWhere("(accessLog.actionDescription ILIKE :search OR accessLog.entityName ILIKE :search)", { + search: `%${queryDto.search}%`, + }); + } + } + + //######################################################################################## +} diff --git a/src/modules/announcements/announcement.controller.ts b/src/modules/announcements/announcement.controller.ts index 66d44b5..7f22230 100755 --- a/src/modules/announcements/announcement.controller.ts +++ b/src/modules/announcements/announcement.controller.ts @@ -19,22 +19,22 @@ export class AnnouncementController { @PermissionsDec(PermissionEnum.ANNOUNCEMENTS) @ApiProperty({ description: "Create a new announcement (admin route)" }) @Post() - create(@Body() createAnnouncementDto: CreateAnnouncementDto) { - return this.announcementService.createAnnouncement(createAnnouncementDto); + create(@Body() createAnnouncementDto: CreateAnnouncementDto, @UserDec("id") userId: string) { + return this.announcementService.createAnnouncement(createAnnouncementDto, userId); } @PermissionsDec(PermissionEnum.ANNOUNCEMENTS) @ApiProperty({ description: "Update an announcement (admin route)" }) @Patch(":id") - update(@Param() paramDto: ParamDto, @Body() updateAnnouncementDto: UpdateAnnouncementDto) { - return this.announcementService.updateAnnouncement(paramDto.id, updateAnnouncementDto); + update(@Param() paramDto: ParamDto, @Body() updateAnnouncementDto: UpdateAnnouncementDto, @UserDec("id") userId: string) { + return this.announcementService.updateAnnouncement(paramDto.id, updateAnnouncementDto, userId); } @PermissionsDec(PermissionEnum.ANNOUNCEMENTS) @ApiProperty({ description: "Delete an announcement (admin route)" }) @Delete(":id") - delete(@Param() paramDto: ParamDto) { - return this.announcementService.deleteAnnouncement(paramDto.id); + delete(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { + return this.announcementService.deleteAnnouncement(paramDto.id, userId); } @ApiProperty({ description: "Get all announcements (admin route)" }) diff --git a/src/modules/announcements/announcement.module.ts b/src/modules/announcements/announcement.module.ts index 5d2396f..96b5b17 100755 --- a/src/modules/announcements/announcement.module.ts +++ b/src/modules/announcements/announcement.module.ts @@ -12,6 +12,7 @@ import { DanakServicesModule } from "../danak-services/danak-services.module"; import { UsersModule } from "../users/users.module"; import { UserAnnouncement } from "./entities/user-announcement.entity"; import { UserAnnouncementRepository } from "./repositories/user-announcement.repository"; +import { AccessLogsModule } from "../access-logs/access-logs.module"; import { NotificationModule } from "../notifications/notifications.module"; @Module({ imports: [ @@ -20,6 +21,7 @@ import { NotificationModule } from "../notifications/notifications.module"; DanakServicesModule, UsersModule, NotificationModule, + AccessLogsModule, ], providers: [AnnouncementService, AnnouncementRepository, UserAnnouncementRepository, AnnouncementProcessor], controllers: [AnnouncementController], diff --git a/src/modules/announcements/providers/announcement.service.ts b/src/modules/announcements/providers/announcement.service.ts index 6ec115d..76dfb66 100755 --- a/src/modules/announcements/providers/announcement.service.ts +++ b/src/modules/announcements/providers/announcement.service.ts @@ -4,6 +4,8 @@ import { Queue } from "bullmq"; import dayjs from "dayjs"; import { AnnouncementMessage } from "../../../common/enums/message.enum"; +import { UserType } from "../../access-logs/enums/user-type.enum"; +import { AccessLogService } from "../../access-logs/providers/access-log.service"; import { DanakServicesService } from "../../danak-services/providers/danak-services.service"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; import { ANNOUNCEMENT } from "../constants"; @@ -20,11 +22,12 @@ export class AnnouncementService { private readonly announcementRepository: AnnouncementRepository, private readonly userAnnouncementRepository: UserAnnouncementRepository, private readonly danakServicesService: DanakServicesService, + private readonly accessLogService: AccessLogService, ) {} /***************************************** */ - async createAnnouncement(createAnnouncementDto: CreateAnnouncementDto) { + async createAnnouncement(createAnnouncementDto: CreateAnnouncementDto, userId: string) { const { serviceId, userIds } = createAnnouncementDto; let isPublic = true; @@ -62,6 +65,15 @@ export class AnnouncementService { }, ); + await this.accessLogService.logCreate("Announcement", announcement.id, announcement, { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin created announcement: ${announcement.title}`, + metadata: { + serviceId: serviceId, + }, + }); + return { message: AnnouncementMessage.CREATED_AND_SENT_TO_USERS_AFTER_PUBLISH, announcement, @@ -198,7 +210,7 @@ export class AnnouncementService { } //************************************ */ - async updateAnnouncement(id: string, updateAnnouncementDto: UpdateAnnouncementDto) { + async updateAnnouncement(id: string, updateAnnouncementDto: UpdateAnnouncementDto, userId: string) { const announcement = await this.announcementRepository.findOneBy({ id }); if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); @@ -243,6 +255,15 @@ export class AnnouncementService { ); } + await this.accessLogService.logUpdate("Announcement", announcement.id, announcement, { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin updated announcement: ${announcement.title}`, + metadata: { + serviceId: serviceId, + }, + }); + return { message: AnnouncementMessage.UPDATED_SUCCESSFULLY, announcement, @@ -251,7 +272,7 @@ export class AnnouncementService { //************************************ */ - async deleteAnnouncement(id: string) { + async deleteAnnouncement(id: string, userId: string) { const announcement = await this.announcementRepository.findOneBy({ id }); if (!announcement) throw new BadRequestException(AnnouncementMessage.NOT_FOUND); @@ -263,6 +284,15 @@ export class AnnouncementService { await this.announcementRepository.delete(id); + await this.accessLogService.logDelete("Announcement", announcement.id, announcement, { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin deleted announcement: ${announcement.title}`, + metadata: { + serviceId: announcement.targetService.id, + }, + }); + return { message: AnnouncementMessage.DELETED_SUCCESSFULLY, }; diff --git a/src/modules/blogs/DTO/create-blog.dto.ts b/src/modules/blogs/DTO/create-blog.dto.ts index d725d02..d3d30f7 100755 --- a/src/modules/blogs/DTO/create-blog.dto.ts +++ b/src/modules/blogs/DTO/create-blog.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { ArrayMinSize, IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID, MaxLength } from "class-validator"; +import { ArrayMinSize, IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID, IsUrl, MaxLength } from "class-validator"; import { BlogMessage } from "../../../common/enums/message.enum"; @@ -69,4 +69,10 @@ export class CreateBlogDto { @IsOptional() @IsBoolean({ message: BlogMessage.IS_PINNED_SHOULD_BE_A_BOOLEAN }) isPinned?: boolean; + + @IsOptional() + @IsNotEmpty({ message: BlogMessage.AUDIO_URL_REQUIRED }) + @IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: BlogMessage.AUDIO_URL_STRING }) + @ApiPropertyOptional({ description: "Audio URL of the blog", example: "https://example.com/audio.mp3" }) + audioUrl?: string; } diff --git a/src/modules/blogs/blogs.controller.ts b/src/modules/blogs/blogs.controller.ts index 8f1e9db..b2cb7fc 100755 --- a/src/modules/blogs/blogs.controller.ts +++ b/src/modules/blogs/blogs.controller.ts @@ -108,8 +108,8 @@ export class BlogsController { @Patch(":id") @AdminRoute() @PermissionsDec(PermissionEnum.BLOGS) - updateBlog(@Param() paramDto: ParamDto, @Body() updateBlogDto: UpdateBlogDto) { - return this.blogsService.updateBlog(paramDto.id, updateBlogDto); + updateBlog(@Param() paramDto: ParamDto, @Body() updateBlogDto: UpdateBlogDto, @UserDec("id") userId: string) { + return this.blogsService.updateBlog(paramDto.id, updateBlogDto, userId); } @ApiOperation({ summary: "Delete a blog (admin route) " }) diff --git a/src/modules/blogs/blogs.module.ts b/src/modules/blogs/blogs.module.ts index 913875a..02a248b 100755 --- a/src/modules/blogs/blogs.module.ts +++ b/src/modules/blogs/blogs.module.ts @@ -12,11 +12,16 @@ import { BlogCategoriesRepository } from "./repositories/blog-categories.reposit import { BlogCommentReplyRepository } from "./repositories/blog-comment-reply.repository"; import { BlogCommentsRepository } from "./repositories/blog-comments.repository"; import { BlogsRepository } from "./repositories/blogs.repository"; +import { AccessLogsModule } from "../access-logs/access-logs.module"; import { NotificationModule } from "../notifications/notifications.module"; import { UsersModule } from "../users/users.module"; - @Module({ - imports: [TypeOrmModule.forFeature([Blog, BlogComment, BlogCommentReply, BlogCategory]), UsersModule, NotificationModule], + imports: [ + TypeOrmModule.forFeature([Blog, BlogComment, BlogCommentReply, BlogCategory]), + UsersModule, + NotificationModule, + AccessLogsModule, + ], controllers: [BlogsController], providers: [ BlogsService, diff --git a/src/modules/blogs/entities/blog.entity.ts b/src/modules/blogs/entities/blog.entity.ts index fe452af..96e0eab 100755 --- a/src/modules/blogs/entities/blog.entity.ts +++ b/src/modules/blogs/entities/blog.entity.ts @@ -45,6 +45,9 @@ export class Blog extends BaseEntity { @Index() isPinned: boolean; + @Column({ type: "varchar", length: 255, nullable: true }) + audioUrl: string; + @ManyToOne(() => User, (user) => user.blogs, { onDelete: "CASCADE", nullable: false }) @Index() author: User; diff --git a/src/modules/blogs/providers/blogs.service.ts b/src/modules/blogs/providers/blogs.service.ts index fc19966..9583ae4 100755 --- a/src/modules/blogs/providers/blogs.service.ts +++ b/src/modules/blogs/providers/blogs.service.ts @@ -2,6 +2,8 @@ import { BadRequestException, Injectable } from "@nestjs/common"; import { Not } from "typeorm"; import { AdminMessage, BlogMessage, CommonMessage } from "../../../common/enums/message.enum"; +import { UserType } from "../../access-logs/enums/user-type.enum"; +import { AccessLogService } from "../../access-logs/providers/access-log.service"; import { CategoryListSearchQueryDto } from "../../danak-services/DTO/category-search-query.dto"; import { UsersService } from "../../users/providers/users.service"; import { CreateBlogDto } from "../DTO/create-blog.dto"; @@ -19,6 +21,7 @@ export class BlogsService { private readonly blogCommentsRepository: BlogCommentsRepository, private readonly blogCategoriesRepository: BlogCategoriesRepository, private readonly usersService: UsersService, + private readonly accessLogService: AccessLogService, ) {} //*********************************** */ @@ -121,6 +124,22 @@ export class BlogsService { await this.blogsRepository.save(blog); + await this.accessLogService.logCreate( + "Blog", + blog.id, + { blog: createBlogDto.title }, + { + user: { id: authorId }, + userType: UserType.ADMIN, + actionDescription: `Admin created blog: ${createBlogDto.title}`, + metadata: { + categoryId: createBlogDto.categoryId, + slug: slug, + authorId: authorId, + }, + }, + ); + return { message: CommonMessage.CREATED, blog, @@ -129,7 +148,7 @@ export class BlogsService { //*********************************** */ - async updateBlog(id: string, updateBlogDto: UpdateBlogDto) { + async updateBlog(id: string, updateBlogDto: UpdateBlogDto, userId: string) { const blog = await this.findBlogById(id); if (updateBlogDto.title) { @@ -153,6 +172,21 @@ export class BlogsService { slug: this.generateSlug(updateBlogDto.slug ?? blog.slug), }); + await this.accessLogService.logUpdate( + "Blog", + blog.id, + { blog: blog.title }, + { blog: updateBlogDto.title }, + { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin updated blog: ${blog.title}`, + metadata: { + categoryId: updateBlogDto.categoryId, + }, + }, + ); + return { message: CommonMessage.UPDATE_SUCCESS, blog, @@ -168,6 +202,20 @@ export class BlogsService { if (!blog) throw new BadRequestException(BlogMessage.BLOG_NOT_FOUND); await this.blogsRepository.softDelete({ id }); + + await this.accessLogService.logDelete( + "Blog", + blog.id, + { blog: blog.title }, + { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin deleted blog: ${blog.title}`, + metadata: { + categoryId: blog.category.id, + }, + }, + ); return { message: CommonMessage.DELETED, }; diff --git a/src/modules/danak-services/danak-services.module.ts b/src/modules/danak-services/danak-services.module.ts index 27dfbde..79e3aaa 100755 --- a/src/modules/danak-services/danak-services.module.ts +++ b/src/modules/danak-services/danak-services.module.ts @@ -24,6 +24,7 @@ import { NotificationModule } from "../notifications/notifications.module"; import { UsersModule } from "../users/users.module"; import { DanakServiceVideo } from "./entities/danak-service-video.entity"; import { DanakServicesVideoRepository } from "./repositories/danak-services-video.repository"; +import { AccessLogsModule } from "../access-logs/access-logs.module"; @Module({ imports: [ @@ -40,6 +41,7 @@ import { DanakServicesVideoRepository } from "./repositories/danak-services-vide ]), UsersModule, NotificationModule, + AccessLogsModule, ], providers: [ DanakServicesService, diff --git a/src/modules/danak-services/providers/danak-services.service.ts b/src/modules/danak-services/providers/danak-services.service.ts index 18d207d..90d316b 100755 --- a/src/modules/danak-services/providers/danak-services.service.ts +++ b/src/modules/danak-services/providers/danak-services.service.ts @@ -3,6 +3,8 @@ import { DataSource, FindOptionsWhere, In, IsNull, LessThan, MoreThan, Not, Quer import { ParamDto } from "../../../common/DTO/param.dto"; import { CategoryMessage, CommonMessage, ServiceMessage } from "../../../common/enums/message.enum"; +import { UserType } from "../../access-logs/enums/user-type.enum"; +import { AccessLogService } from "../../access-logs/providers/access-log.service"; import { SubscriptionStatus } from "../../subscriptions/enums/subscription-status.enum"; import { User } from "../../users/entities/user.entity"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; @@ -42,6 +44,7 @@ export class DanakServicesService { private readonly danakServicesAudioRepository: DanakServicesAudioRepository, private readonly danakServicesVideoRepository: DanakServicesVideoRepository, private readonly dataSource: DataSource, + private readonly accessLogService: AccessLogService, ) {} /******************************************** */ @@ -306,6 +309,21 @@ export class DanakServicesService { const service = this.danakServicesRepository.create({ ...createDto, category, images, audios, videos, slug }); await this.danakServicesRepository.save(service); + + // Log the service creation + await this.accessLogService.logCreate("DanakService", service.id, createDto, { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin created service: ${createDto.name}`, + metadata: { + categoryId: createDto.categoryId, + slug: slug, + hasImages: createDto.images && createDto.images.length > 0, + hasAudios: createDto.audios && createDto.audios.length > 0, + hasVideos: createDto.videos && createDto.videos.length > 0, + }, + }); + return { message: CommonMessage.CREATED, service, diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts index c9298e8..c8b3cec 100755 --- a/src/modules/invoices/invoices.controller.ts +++ b/src/modules/invoices/invoices.controller.ts @@ -23,22 +23,22 @@ export class InvoicesController { @ApiOperation({ summary: "create an invoice ==> admin route" }) @PermissionsDec(PermissionEnum.INVOICES) @Post() - createInvoice(@Body() createDto: CreateInvoiceDto) { - return this.invoiceService.createInvoiceAdmin(createDto); + createInvoice(@Body() createDto: CreateInvoiceDto, @UserDec("id") userId: string) { + return this.invoiceService.createInvoiceAdmin(createDto, userId); } @ApiOperation({ summary: "update an invoice ==> admin route" }) @PermissionsDec(PermissionEnum.INVOICES) @Patch(":id") - updateInvoice(@Param() paramDto: ParamDto, @Body() updateDto: UpdateInvoiceDto) { - return this.invoiceService.updateInvoiceAdmin(paramDto.id, updateDto); + updateInvoice(@Param() paramDto: ParamDto, @Body() updateDto: UpdateInvoiceDto, @UserDec("id") userId: string) { + return this.invoiceService.updateInvoiceAdmin(paramDto.id, updateDto, userId); } @ApiOperation({ summary: "delete an invoice ==> admin route" }) @PermissionsDec(PermissionEnum.INVOICES) @Delete(":id") - deleteInvoice(@Param() paramDto: ParamDto) { - return this.invoiceService.deleteInvoiceAdmin(paramDto.id); + deleteInvoice(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { + return this.invoiceService.deleteInvoiceAdmin(paramDto.id, userId); } @ApiOperation({ summary: "get all invoices ==> admin route" }) diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts index 346aa9b..2a6c588 100755 --- a/src/modules/invoices/invoices.module.ts +++ b/src/modules/invoices/invoices.module.ts @@ -10,6 +10,7 @@ import { InvoicesService } from "./providers/invoices.service"; import { InvoiceProcessor } from "./queue/invoice.processor"; import { InvoiceItemsRepository } from "./repositories/invoice-items.repository"; import { InvoicesRepository } from "./repositories/invoices.repository"; +import { AccessLogsModule } from "../access-logs/access-logs.module"; import { Discount } from "../discounts/entities/discount.entity"; import { UsageDiscount } from "../discounts/entities/usage-discount.entity"; import { DiscountRepository } from "../discounts/repositories/discounts.repository"; @@ -44,6 +45,7 @@ import { WalletsModule } from "../wallets/wallets.module"; UtilsModule, NotificationModule, PaymentsModule, + AccessLogsModule, ], providers: [InvoicesService, InvoicesRepository, InvoiceItemsRepository, DiscountRepository, UsageDiscountRepository, InvoiceProcessor], controllers: [InvoicesController], diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index 06abb53..da994ce 100755 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -6,6 +6,9 @@ import Decimal from "decimal.js"; import { Between, DataSource, In, QueryRunner } from "typeorm"; import { AuthMessage, InvoiceMessage, WalletMessage } from "../../../common/enums/message.enum"; +import { OperationType } from "../../access-logs/enums/operation-type.enum"; +import { UserType } from "../../access-logs/enums/user-type.enum"; +import { AccessLogService } from "../../access-logs/providers/access-log.service"; import { VerifyOtpWithUserId } from "../../auth/DTO/verify-otp.dto"; import { Discount } from "../../discounts/entities/discount.entity"; import { DiscountType } from "../../discounts/enums/discount-type.enum"; @@ -48,11 +51,12 @@ export class InvoicesService { private readonly otpService: OTPService, private readonly smsService: SmsService, private readonly dataSource: DataSource, + private readonly accessLogService: AccessLogService, ) {} ///********************************** */ - async createInvoiceAdmin(createDto: CreateInvoiceDto) { + async createInvoiceAdmin(createDto: CreateInvoiceDto, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); try { @@ -79,7 +83,7 @@ export class InvoicesService { const dueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate(); const invoice = queryRunner.manager.create(this.invoiceRepository.target, { - user: { id: createDto.userId }, + user: { id: userId }, totalPrice: totalPrice.add(tax).toNumber(), originalPrice: totalPrice.add(tax).toNumber(), items: invoiceItems, @@ -108,6 +112,15 @@ export class InvoicesService { }); await this.scheduleInvoiceJobs(invoice, createDto); + + await this.accessLogService.logCreate("Invoice", invoice.id, createDto, { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin created invoice: ${invoice.numericId}`, + metadata: { + items: invoiceItems.map((item) => item.name).join(", "), + }, + }); await queryRunner.commitTransaction(); return { @@ -128,7 +141,7 @@ export class InvoicesService { //********************************** */ - async updateInvoiceAdmin(invoiceId: string, updateDto: UpdateInvoiceDto) { + async updateInvoiceAdmin(invoiceId: string, updateDto: UpdateInvoiceDto, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); try { @@ -191,6 +204,12 @@ export class InvoicesService { await queryRunner.commitTransaction(); + await this.accessLogService.logUpdate("Invoice", invoice.id, updateDto, { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin updated invoice: ${invoice.numericId}`, + }); + return { message: InvoiceMessage.INVOICE_UPDATED, invoice, @@ -205,7 +224,7 @@ export class InvoicesService { //********************************** */ - async deleteInvoiceAdmin(invoiceId: string) { + async deleteInvoiceAdmin(invoiceId: string, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); try { @@ -226,6 +245,12 @@ export class InvoicesService { await queryRunner.manager.remove(this.invoiceRepository.target, invoice); + await this.accessLogService.logDelete("Invoice", invoice.id, invoice, { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin deleted invoice: ${invoice.numericId}`, + }); + await queryRunner.commitTransaction(); return { @@ -239,9 +264,7 @@ export class InvoicesService { } } - /** - * Clean up all queue jobs related to a specific invoice - */ + //################################################ private async cleanupInvoiceQueueJobs(invoiceId: string): Promise { try { this.logger.log(`Starting cleanup of queue jobs for invoice: ${invoiceId}`); @@ -334,6 +357,17 @@ export class InvoicesService { items, ); + await this.accessLogService.logUserAction( + OperationType.APPROVE, + "Invoice", + invoiceId, + `user approved invoice request: ${invoice.numericId}`, + { + user: { id: userId }, + userType: UserType.USER, + }, + ); + await queryRunner.commitTransaction(); return { @@ -380,6 +414,17 @@ export class InvoicesService { await this.otpService.delOtpFormCache(user.phone, "INVOICE_VERIFY"); + await this.accessLogService.logUserAction( + OperationType.APPROVE, + "Invoice", + invoiceId, + `user approved invoice by user: ${invoice.numericId}`, + { + user: { id: userId }, + userType: UserType.USER, + }, + ); + await queryRunner.commitTransaction(); return { @@ -739,6 +784,11 @@ export class InvoicesService { paidAt: invoice.paidAt, }); + await this.accessLogService.logUserAction(OperationType.UPDATE, "Invoice", invoiceId, `user paid invoice: ${invoice.numericId}`, { + user: { id: userId }, + userType: UserType.USER, + }); + if (transactionStarted) await queryRunner.commitTransaction(); return { diff --git a/src/modules/users/providers/admins.service.ts b/src/modules/users/providers/admins.service.ts index 8e2b32d..272736d 100755 --- a/src/modules/users/providers/admins.service.ts +++ b/src/modules/users/providers/admins.service.ts @@ -3,6 +3,8 @@ import slugify from "slugify"; import { DataSource, In, Not, QueryRunner } from "typeorm"; import { AdminMessage, AdsMessage, UserMessage } from "../../../common/enums/message.enum"; +import { UserType } from "../../access-logs/enums/user-type.enum"; +import { AccessLogService } from "../../access-logs/providers/access-log.service"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; import { PasswordService } from "../../utils/providers/password.service"; import { CreateAdminDto } from "../DTO/create-admin.dto"; @@ -23,9 +25,10 @@ export class AdminsService { private readonly permissionsRepository: PermissionsRepository, private readonly passwordService: PasswordService, private readonly dataSource: DataSource, + private readonly accessLogService: AccessLogService, ) {} - async createAdmin(createDto: CreateAdminDto) { + async createAdmin(createDto: CreateAdminDto, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); try { @@ -61,6 +64,19 @@ export class AdminsService { await queryRunner.manager.save(this.userRepository.target, adminUser); await queryRunner.commitTransaction(); + // Log the admin creation + await this.accessLogService.logCreate("User", adminUser.id, createDto, { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin created new admin: ${createDto.email}`, + metadata: { + roleId: createDto.roleId, + createdBy: "admin", + email: createDto.email, + phone: createDto.phone, + }, + }); + return { message: AdminMessage.ADMIN_CREATED, adminUser, @@ -74,7 +90,7 @@ export class AdminsService { } /************************************************************ */ - async updateAdmin(id: string, updateDto: UpdateAdminDto) { + async updateAdmin(id: string, updateDto: UpdateAdminDto, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); try { @@ -121,6 +137,18 @@ export class AdminsService { await queryRunner.commitTransaction(); + await this.accessLogService.logUpdate( + "User", + admin.id, + { ...admin }, + { ...admin }, + { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin updated admin: ${admin.email}`, + }, + ); + return { message: AdminMessage.ADMIN_UPDATED, }; @@ -133,11 +161,23 @@ export class AdminsService { } /************************************************************ */ - async deleteAdmin(id: string) { + async deleteAdmin(id: string, userId: string) { const admin = await this.userRepository.findOne({ where: { id, roles: { isAdmin: true } } }); if (!admin) throw new BadRequestException(AdminMessage.ADMIN_NOT_FOUND); await this.userRepository.softDelete(id); + + await this.accessLogService.logDelete( + "User", + admin.id, + { ...admin }, + { + user: { id: userId }, + userType: UserType.ADMIN, + actionDescription: `Admin deleted admin: ${admin.email}`, + }, + ); + return { message: AdminMessage.ADMIN_DELETED, }; diff --git a/src/modules/users/providers/customers.service.ts b/src/modules/users/providers/customers.service.ts index 3c2e33d..5c68d74 100755 --- a/src/modules/users/providers/customers.service.ts +++ b/src/modules/users/providers/customers.service.ts @@ -2,6 +2,8 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { DataSource, Not } from "typeorm"; import { CommonMessage, UserMessage } from "../../../common/enums/message.enum"; +import { UserType } from "../../access-logs/enums/user-type.enum"; +import { AccessLogService } from "../../access-logs/providers/access-log.service"; import { Address } from "../../address/entities/address.entity"; import { AddressService } from "../../address/providers/address.service"; import { UserSettingsService } from "../../settings/providers/user-settings.service"; @@ -32,6 +34,7 @@ export class CustomersService { private readonly userSettingsService: UserSettingsService, private readonly rolesRepository: RoleRepository, private readonly walletsService: WalletsService, + private readonly accessLogService: AccessLogService, ) {} async createCustomer(createDto: CreateCustomerDto) { @@ -68,6 +71,18 @@ export class CustomersService { await this.createLegalUserData(createDto, user.id, queryRunner); await queryRunner.commitTransaction(); + + // Log the customer creation + await this.accessLogService.logCreate("User", user.id, createDto, { + userType: UserType.ADMIN, + actionDescription: `Admin created customer: ${createDto.email}`, + metadata: { + createdBy: "admin", + email: createDto.email, + phone: createDto.phone, + }, + }); + return { message: CommonMessage.CREATED, user }; } catch (error) { await queryRunner.rollbackTransaction(); diff --git a/src/modules/users/providers/users.service.ts b/src/modules/users/providers/users.service.ts index 10f043e..e4df025 100755 --- a/src/modules/users/providers/users.service.ts +++ b/src/modules/users/providers/users.service.ts @@ -7,6 +7,8 @@ import slugify from "slugify"; import { In, Not, QueryRunner } from "typeorm"; import { AuthMessage, CommonMessage, UserMessage } from "../../../common/enums/message.enum"; +import { UserType } from "../../access-logs/enums/user-type.enum"; +import { AccessLogService } from "../../access-logs/providers/access-log.service"; import { AddressService } from "../../address/providers/address.service"; import { RequestOtpDto } from "../../auth/DTO/request-otp.dto"; import { VerifyOtpDto } from "../../auth/DTO/verify-otp.dto"; @@ -47,6 +49,7 @@ export class UsersService { private readonly smsService: SmsService, private readonly cacheService: CacheService, private readonly referralsService: ReferralsService, + private readonly accessLogService: AccessLogService, ) {} /************************************************************ */ @@ -114,6 +117,8 @@ export class UsersService { const user = await this.userRepository.findOneBy({ id: userId }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + const oldUserData = { ...user }; + if (updateProfileDto.cityId) { const { city: existCity } = await this.addressService.getCity(+updateProfileDto.cityId); if (!existCity) throw new BadRequestException(UserMessage.CITY_NOT_FOUND); @@ -127,8 +132,16 @@ export class UsersService { if (existUserName) throw new BadRequestException(UserMessage.USERNAME_EXIST); } - await this.userRepository.save({ ...user, ...updateProfileDto, city }); - // + const updatedUser = await this.userRepository.save({ ...user, ...updateProfileDto, city }); + + // Log the profile update + await this.accessLogService.logUpdate("User", userId, oldUserData, updatedUser, { + user: { id: userId }, + userType: UserType.USER, + actionDescription: `User updated profile: ${user.email}`, + metadata: { updatedFields: Object.keys(updateProfileDto) }, + }); + return { message: CommonMessage.UPDATE_SUCCESS, }; diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index d5355bc..8cc308a 100755 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -180,22 +180,22 @@ export class UsersController { @ApiOperation({ summary: "update admin ==> admin route" }) @PermissionsDec(PermissionEnum.ADMINS, PermissionEnum.CUSTOMERS) @Patch("admins/:id") - updateAdmin(@Param() paramDto: ParamDto, @Body() updateDto: UpdateAdminDto) { - return this.adminsService.updateAdmin(paramDto.id, updateDto); + updateAdmin(@Param() paramDto: ParamDto, @Body() updateDto: UpdateAdminDto, @UserDec("id") userId: string) { + return this.adminsService.updateAdmin(paramDto.id, updateDto, userId); } @ApiOperation({ summary: "delete admin ==> admin route" }) @PermissionsDec(PermissionEnum.ADMINS, PermissionEnum.CUSTOMERS) @Delete("admins/:id") - deleteAdmin(@Param() paramDto: ParamDto) { - return this.adminsService.deleteAdmin(paramDto.id); + deleteAdmin(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { + return this.adminsService.deleteAdmin(paramDto.id, userId); } @ApiOperation({ summary: "create admin ==> admin route" }) @PermissionsDec(PermissionEnum.ADMINS, PermissionEnum.CUSTOMERS) @Post("admins") - createAdmin(@Body() createDto: CreateAdminDto) { - return this.adminsService.createAdmin(createDto); + createAdmin(@Body() createDto: CreateAdminDto, @UserDec("id") userId: string) { + return this.adminsService.createAdmin(createDto, userId); } @ApiOperation({ summary: "get admin permissions" }) diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 0be3cde..0c464de 100755 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -13,6 +13,7 @@ import { PermissionsRepository } from "./repositories/permissions.repository"; import { RoleRepository } from "./repositories/roles.repository"; import { UserGroupRepository } from "./repositories/user-group.repository"; import { UserRepository } from "./repositories/users.repository"; +import { AccessLogsModule } from "../access-logs/access-logs.module"; import { AddressModule } from "../address/address.module"; import { AddressService } from "../address/providers/address.service"; import { UserSetting } from "../settings/entities/user-setting.entity"; @@ -35,6 +36,7 @@ import { ReferralsModule } from "../referrals/referrals.module"; UtilsModule, AddressModule, ReferralsModule, + AccessLogsModule, ], providers: [ UsersService,