diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f71246..2eb816f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added + - Changelog documentation and maintenance guide ### Changed @@ -25,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [1.0.0] - 2026-06-01 ### Added + - **User Management**: Registration, authentication, and profile management with JWT tokens - **Role-Based Access Control**: USER, AGENT, and ADMIN roles with route protection - **Property Listings**: Create, manage, and search property listings @@ -46,27 +48,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/). #### Database Migrations -| Feature | Migration | -|---------|-----------| -| Location & search logs | [`20260222084710_location`](prisma/migrations/20260222084710_location), [`20260222091519_add_search_logs`](prisma/migrations/20260222091519_add_search_logs) | -| Password history | [`20260324073730_add_password_history`](prisma/migrations/20260324073730_add_password_history) | -| API key rotation analytics | [`20260324084550_add_api_key_rotation_analytics`](prisma/migrations/20260324084550_add_api_key_rotation_analytics) | -| Composite indexes | [`20260325120000_composite_indexes`](prisma/migrations/20260325120000_composite_indexes) | -| SEO & soft delete | [`20260330000000_add_seo_and_soft_delete_fields`](prisma/migrations/20260330000000_add_seo_and_soft_delete_fields.sql) | -| Auth security foundation | [`20260422000000_add_auth_security_foundation`](prisma/migrations/20260422000000_add_auth_security_foundation.sql) | -| Google OAuth | [`20260422000001_add_google_oauth`](prisma/migrations/20260422000001_add_google_oauth) | -| User preferences & verification docs | [`20260422000001_add_user_preferences_and_verification_documents`](prisma/migrations/20260422000001_add_user_preferences_and_verification_documents) | -| Session management | [`20260422170000_add_session_management`](prisma/migrations/20260422170000_add_session_management.sql) | -| Trust score | [`20260422180000_add_trust_score`](prisma/migrations/20260422180000_add_trust_score.sql) | -| API key permissions & usage | [`20260423000000_add_api_key_permissions_and_usage`](prisma/migrations/20260423000000_add_api_key_permissions_and_usage.sql) | -| Document features | [`20260424000000_add_document_features`](prisma/migrations/20260424000000_add_document_features.sql) | -| Fraud detection | [`20260424010000_add_fraud_detection`](prisma/migrations/20260424010000_add_fraud_detection) | -| Database backup management | [`20260425093000_add_database_backup_management`](prisma/migrations/20260425093000_add_database_backup_management) | -| Email digest & transaction audit | [`20260429000000_add_email_digest`](prisma/migrations/20260429000000_add_email_digest), [`20260429000000_add_transaction_audit_log`](prisma/migrations/20260429000000_add_transaction_audit_log) | -| Transaction lifecycle enforcement | [`20260429000000_enforce_transaction_status_lifecycle`](prisma/migrations/20260429000000_enforce_transaction_status_lifecycle) | -| Transaction documents | [`20260429001000_add_transaction_documents`](prisma/migrations/20260429001000_add_transaction_documents) | +| Feature | Migration | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Location & search logs | [`20260222084710_location`](prisma/migrations/20260222084710_location), [`20260222091519_add_search_logs`](prisma/migrations/20260222091519_add_search_logs) | +| Password history | [`20260324073730_add_password_history`](prisma/migrations/20260324073730_add_password_history) | +| API key rotation analytics | [`20260324084550_add_api_key_rotation_analytics`](prisma/migrations/20260324084550_add_api_key_rotation_analytics) | +| Composite indexes | [`20260325120000_composite_indexes`](prisma/migrations/20260325120000_composite_indexes) | +| SEO & soft delete | [`20260330000000_add_seo_and_soft_delete_fields`](prisma/migrations/20260330000000_add_seo_and_soft_delete_fields.sql) | +| Auth security foundation | [`20260422000000_add_auth_security_foundation`](prisma/migrations/20260422000000_add_auth_security_foundation.sql) | +| Google OAuth | [`20260422000001_add_google_oauth`](prisma/migrations/20260422000001_add_google_oauth) | +| User preferences & verification docs | [`20260422000001_add_user_preferences_and_verification_documents`](prisma/migrations/20260422000001_add_user_preferences_and_verification_documents) | +| Session management | [`20260422170000_add_session_management`](prisma/migrations/20260422170000_add_session_management.sql) | +| Trust score | [`20260422180000_add_trust_score`](prisma/migrations/20260422180000_add_trust_score.sql) | +| API key permissions & usage | [`20260423000000_add_api_key_permissions_and_usage`](prisma/migrations/20260423000000_add_api_key_permissions_and_usage.sql) | +| Document features | [`20260424000000_add_document_features`](prisma/migrations/20260424000000_add_document_features.sql) | +| Fraud detection | [`20260424010000_add_fraud_detection`](prisma/migrations/20260424010000_add_fraud_detection) | +| Database backup management | [`20260425093000_add_database_backup_management`](prisma/migrations/20260425093000_add_database_backup_management) | +| Email digest & transaction audit | [`20260429000000_add_email_digest`](prisma/migrations/20260429000000_add_email_digest), [`20260429000000_add_transaction_audit_log`](prisma/migrations/20260429000000_add_transaction_audit_log) | +| Transaction lifecycle enforcement | [`20260429000000_enforce_transaction_status_lifecycle`](prisma/migrations/20260429000000_enforce_transaction_status_lifecycle) | +| Transaction documents | [`20260429001000_add_transaction_documents`](prisma/migrations/20260429001000_add_transaction_documents) | ### Fixed + - Race condition in concurrent document uploads - Memory leak in WebSocket connection handling - Incorrect user role checks in disputes controller @@ -74,6 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Email sending failures with retry logic ### Security + - Implemented password hashing with bcrypt - Added JWT token validation and expiration - Enforced HTTPS-only cookie transmission diff --git a/README.md b/README.md index 2bda3fc..0a41203 100644 --- a/README.md +++ b/README.md @@ -101,57 +101,57 @@ The application uses environment variables for configuration. Copy `.env.example ### Environment Variables -| Variable | Description | Default | -| :--- | :--- | :--- | -| `DATABASE_URL` | PostgreSQL connection string | Required | -| `PORT` | Server port | 3000 | -| `NODE_ENV` | Environment mode | development | -| `FRONTEND_URL` | Frontend application URL for email links | http://localhost:3000 | -| `JWT_SECRET` | JWT signing secret | Required | -| `JWT_REFRESH_SECRET` | JWT refresh token secret | Required | -| `JWT_ACCESS_EXPIRES_IN` | Access token expiration | 15m | -| `JWT_REFRESH_EXPIRES_IN` | Refresh token expiration | 7d | -| `BCRYPT_ROUNDS` | Password hashing rounds | 12 | -| `PASSWORD_HISTORY_LIMIT` | Password history limit | 5 | -| `PASSWORD_MIN_LENGTH` | Minimum password length | 8 | -| `PASSWORD_REQUIRE_UPPERCASE` | Require uppercase in password | true | -| `PASSWORD_REQUIRE_LOWERCASE` | Require lowercase in password | true | -| `PASSWORD_REQUIRE_DIGIT` | Require digit in password | true | -| `PASSWORD_REQUIRE_SPECIAL` | Require special char in password | true | -| `PASSWORD_SPECIAL_CHARS` | Allowed special characters | !@#$%^&*()_+-=... | -| `FRONTEND_URL` | Frontend application URL for email links | http://localhost:3000 | -| `RECAPTCHA_SECRET` | Google reCAPTCHA v3 private key | Required | -| `CAPTCHA_THRESHOLD` | Minimum reCAPTCHA score to pass | 0.5 | -| `BASE_URL` | Root URL of this API server | http://localhost:3000 | -| `API_URL` | Full API base URL for email links | http://localhost:3000/api | -| `AVATAR_UPLOAD_DIR` | Directory for user avatar uploads | ./uploads/avatars | -| `AVATAR_MAX_FILE_SIZE` | Max avatar file size in bytes | 5242880 | -| `CORS_ORIGINS` | Comma-separated allowed origins | http://localhost:3000 | -| `DEBUG_PII` | Enable PII debugging in auth logs | false | -| `EMAIL_VERIFICATION_EXPIRES_IN` | Email verification token TTL | 24h | -| `GOOGLE_CLIENT_ID` | Google OAuth2 client ID | — | -| `GOOGLE_CLIENT_SECRET` | Google OAuth2 client secret | — | -| `GOOGLE_CALLBACK_URL` | Google OAuth2 callback URL | /api/auth/google/callback | -| `BLOCKCHAIN_ENABLED` | Enable blockchain integration | true | -| `BLOCKCHAIN_NETWORK` | Ethereum network | sepolia | -| `BLOCKCHAIN_RPC_URL` | Ethereum RPC endpoint | — | -| `BLOCKCHAIN_CONTRACT_ADDRESS` | Smart contract address | — | -| `BLOCKCHAIN_PRIVATE_KEY` | Wallet private key for signing | — | -| `BACKUP_STORAGE_PATH` | Directory for DB backup files | ./backups | -| `PG_DUMP_PATH` | Path to pg_dump binary | pg_dump | -| `PSQL_PATH` | Path to psql binary | psql | -| `PROPERTY_IMAGES_UPLOAD_DIR` | Directory for property images | ./uploads/properties | -| `PROPERTY_IMAGE_MAX_SIZE` | Max property image size in bytes | 10485760 | -| `PROPERTY_IMAGE_MAX_PER_PROPERTY` | Max images per property | 30 | -| `GEOCODING_PROVIDER` | Geocoding provider (nominatim/google) | nominatim | -| `NOMINATIM_BASE_URL` | Nominatim API base URL | https://nominatim.openstreetmap.org | -| `GEOCODING_USER_AGENT` | User agent for geocoding requests | PropChain-Backend/1.0 | -| `GEOCODING_TIMEOUT_MS` | Geocoding request timeout (ms) | 5000 | -| `GOOGLE_GEOCODING_API_KEY` | Google Geocoding API key (optional) | — | -| `FRAUD_ALERT_RECIPIENTS` | Comma-separated fraud alert emails | — | -| `CACHE_WARMING_ENABLED` | Enable cache warming on startup | false | -| `CACHE_WARMING_INTERVAL` | Cache warming interval (ms) | — | -| `TEST_DATABASE_URL` | PostgreSQL URL for integration tests | — | +| Variable | Description | Default | +| :-------------------------------- | :--------------------------------------- | :---------------------------------- | +| `DATABASE_URL` | PostgreSQL connection string | Required | +| `PORT` | Server port | 3000 | +| `NODE_ENV` | Environment mode | development | +| `FRONTEND_URL` | Frontend application URL for email links | http://localhost:3000 | +| `JWT_SECRET` | JWT signing secret | Required | +| `JWT_REFRESH_SECRET` | JWT refresh token secret | Required | +| `JWT_ACCESS_EXPIRES_IN` | Access token expiration | 15m | +| `JWT_REFRESH_EXPIRES_IN` | Refresh token expiration | 7d | +| `BCRYPT_ROUNDS` | Password hashing rounds | 12 | +| `PASSWORD_HISTORY_LIMIT` | Password history limit | 5 | +| `PASSWORD_MIN_LENGTH` | Minimum password length | 8 | +| `PASSWORD_REQUIRE_UPPERCASE` | Require uppercase in password | true | +| `PASSWORD_REQUIRE_LOWERCASE` | Require lowercase in password | true | +| `PASSWORD_REQUIRE_DIGIT` | Require digit in password | true | +| `PASSWORD_REQUIRE_SPECIAL` | Require special char in password | true | +| `PASSWORD_SPECIAL_CHARS` | Allowed special characters | !@#$%^&*()_+-=... | +| `FRONTEND_URL` | Frontend application URL for email links | http://localhost:3000 | +| `RECAPTCHA_SECRET` | Google reCAPTCHA v3 private key | Required | +| `CAPTCHA_THRESHOLD` | Minimum reCAPTCHA score to pass | 0.5 | +| `BASE_URL` | Root URL of this API server | http://localhost:3000 | +| `API_URL` | Full API base URL for email links | http://localhost:3000/api | +| `AVATAR_UPLOAD_DIR` | Directory for user avatar uploads | ./uploads/avatars | +| `AVATAR_MAX_FILE_SIZE` | Max avatar file size in bytes | 5242880 | +| `CORS_ORIGINS` | Comma-separated allowed origins | http://localhost:3000 | +| `DEBUG_PII` | Enable PII debugging in auth logs | false | +| `EMAIL_VERIFICATION_EXPIRES_IN` | Email verification token TTL | 24h | +| `GOOGLE_CLIENT_ID` | Google OAuth2 client ID | — | +| `GOOGLE_CLIENT_SECRET` | Google OAuth2 client secret | — | +| `GOOGLE_CALLBACK_URL` | Google OAuth2 callback URL | /api/auth/google/callback | +| `BLOCKCHAIN_ENABLED` | Enable blockchain integration | true | +| `BLOCKCHAIN_NETWORK` | Ethereum network | sepolia | +| `BLOCKCHAIN_RPC_URL` | Ethereum RPC endpoint | — | +| `BLOCKCHAIN_CONTRACT_ADDRESS` | Smart contract address | — | +| `BLOCKCHAIN_PRIVATE_KEY` | Wallet private key for signing | — | +| `BACKUP_STORAGE_PATH` | Directory for DB backup files | ./backups | +| `PG_DUMP_PATH` | Path to pg_dump binary | pg_dump | +| `PSQL_PATH` | Path to psql binary | psql | +| `PROPERTY_IMAGES_UPLOAD_DIR` | Directory for property images | ./uploads/properties | +| `PROPERTY_IMAGE_MAX_SIZE` | Max property image size in bytes | 10485760 | +| `PROPERTY_IMAGE_MAX_PER_PROPERTY` | Max images per property | 30 | +| `GEOCODING_PROVIDER` | Geocoding provider (nominatim/google) | nominatim | +| `NOMINATIM_BASE_URL` | Nominatim API base URL | https://nominatim.openstreetmap.org | +| `GEOCODING_USER_AGENT` | User agent for geocoding requests | PropChain-Backend/1.0 | +| `GEOCODING_TIMEOUT_MS` | Geocoding request timeout (ms) | 5000 | +| `GOOGLE_GEOCODING_API_KEY` | Google Geocoding API key (optional) | — | +| `FRAUD_ALERT_RECIPIENTS` | Comma-separated fraud alert emails | — | +| `CACHE_WARMING_ENABLED` | Enable cache warming on startup | false | +| `CACHE_WARMING_INTERVAL` | Cache warming interval (ms) | — | +| `TEST_DATABASE_URL` | PostgreSQL URL for integration tests | — | ## 🗄️ Database Setup @@ -210,19 +210,19 @@ prisma/ ## 🔧 Available Scripts -| Command | Description | -|---------|-------------| -| `npm run build` | Build the application | -| `npm run start:dev` | Start in development mode with watch | -| `npm run start:prod` | Start in production mode | -| `npm run lint` | Run ESLint with auto-fix | -| `npm run format` | Format code with Prettier | -| `npm test` | Run tests | -| `npm run test:cov` | Run tests with coverage | -| `npm run migrate` | Run database migrations | -| `npm run migrate:deploy` | Deploy migrations to production | -| `npm run db:generate` | Generate Prisma Client | -| `npm run db:studio` | Open Prisma Studio | +| Command | Description | +| ------------------------ | ------------------------------------ | +| `npm run build` | Build the application | +| `npm run start:dev` | Start in development mode with watch | +| `npm run start:prod` | Start in production mode | +| `npm run lint` | Run ESLint with auto-fix | +| `npm run format` | Format code with Prettier | +| `npm test` | Run tests | +| `npm run test:cov` | Run tests with coverage | +| `npm run migrate` | Run database migrations | +| `npm run migrate:deploy` | Deploy migrations to production | +| `npm run db:generate` | Generate Prisma Client | +| `npm run db:studio` | Open Prisma Studio | ## 📊 Database Schema @@ -272,9 +272,11 @@ npm run start:prod ## 📝 API Endpoints ### Health Check + - `GET /api/health` - Application health status ### Users + - `POST /api/users` - Create user - `GET /api/users` - List all users - `GET /api/users/:id` - Get user by ID @@ -282,6 +284,7 @@ npm run start:prod - `DELETE /api/users/:id` - Delete user ### Properties + - `POST /api/properties` - Create property - `GET /api/properties` - List all properties - `GET /api/properties/:id` - Get property by ID @@ -289,6 +292,7 @@ npm run start:prod - `DELETE /api/properties/:id` - Delete property ### Tax Strategy Suggestions + - `GET /api/transactions/:transactionId/tax-strategies` - List tax strategy suggestions for a transaction - `POST /api/transactions/:transactionId/tax-strategies` - Create a tax strategy suggestion - `PATCH /api/transactions/:transactionId/tax-strategies/:strategyId` - Update a tax strategy suggestion @@ -299,7 +303,6 @@ Tax strategy suggestions are informational only and are not legal or tax advice. See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines, branch naming conventions, PR expectations, and local test/lint instructions. - 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 3. Commit your changes (`git commit -m 'Add amazing feature'`) diff --git a/docs/API_VERSIONING.md b/docs/API_VERSIONING.md index ca5a240..4a03bca 100644 --- a/docs/API_VERSIONING.md +++ b/docs/API_VERSIONING.md @@ -29,10 +29,10 @@ If no version is specified, the request defaults to **v2**. ## Version Status -| Version | Status | Released | Sunset Date | -|---------|--------|----------|-------------| -| v1 | Deprecated | 2026-01-01 | 2026-12-31 | -| v2 | Active | 2026-04-01 | — | +| Version | Status | Released | Sunset Date | +| ------- | ---------- | ---------- | ----------- | +| v1 | Deprecated | 2026-01-01 | 2026-12-31 | +| v2 | Active | 2026-04-01 | — | ## Deprecation Policy diff --git a/docs/Auth_and_User_APIs.md b/docs/Auth_and_User_APIs.md index 2a5b19f..c6199a9 100644 --- a/docs/Auth_and_User_APIs.md +++ b/docs/Auth_and_User_APIs.md @@ -38,6 +38,7 @@ Success response (201 Created): ``` Errors: + - 400 Bad Request — validation failure (missing/weak password, invalid email) - 400 Bad Request — email already exists - 400 Bad Request — registration already pending from this IP @@ -56,11 +57,13 @@ The user receives an email containing a verification link that points to the `POST /auth/verify-email` endpoint: **Request:** + ```json { "token": "62-char-random-token" } ``` **Success response (200 OK):** + ```json { "message": "Email verified successfully", @@ -71,6 +74,7 @@ The user receives an email containing a verification link that points to the ``` Errors: + - 400 Bad Request — invalid or expired verification token - 400 Bad Request — email already verified @@ -109,13 +113,14 @@ Success response (200 OK): ```json { - "user": { "id": "user_abc123", "email": "user@example.com", "firstName":"Jane" }, + "user": { "id": "user_abc123", "email": "user@example.com", "firstName": "Jane" }, "accessToken": "ey...", "refreshToken": "ey..." } ``` Errors: + - 401 Unauthorized — invalid credentials - 401 Unauthorized — account locked (after failed attempts) - 401 Unauthorized — 2FA required or invalid 2FA code @@ -133,6 +138,7 @@ Request payload: Success (200): returns a new access + refresh token pair. Errors: + - 401 Unauthorized — invalid or reused refresh token --- @@ -174,6 +180,7 @@ Request payload: ``` Success: 200 OK. Errors: + - 400 Bad Request — invalid/expired token - 400 Bad Request — password doesn't meet complexity @@ -192,6 +199,7 @@ Delete user — DELETE /api/users/:id Typical responses mirror the `user` object shape and return 200 or 201 where appropriate. Authorization: endpoints that modify or list users require admin privileges. Errors (common): + - 401 Unauthorized — missing/invalid token - 403 Forbidden — insufficient role - 404 Not Found — user not found @@ -221,7 +229,7 @@ If a **blacklisted refresh token** is presented (i.e., a token that was already 1. The server detects the reuse attempt 2. **All tokens in the same family are immediately invalidated** (mass logout) 3. A fraud alert is created via `FraudService` -4. The user receives a `401` response with message: *"Token reuse detected. All sessions have been invalidated for security. Please login again."* +4. The user receives a `401` response with message: _"Token reuse detected. All sessions have been invalidated for security. Please login again."_ ### What Triggers Mass Logout @@ -239,11 +247,11 @@ When a token-reuse fraud alert fires: ### Event Details -| Event | Description | -|-------|-------------| -| `Token reuse detected` | A previously-used refresh token was presented | -| `Invalidating N tokens in family X` | All tokens in the family are being cleaned up | -| `Refresh token reuse detected` (fraud alert) | FraudService created a BLOCK-level alert | +| Event | Description | +| -------------------------------------------- | --------------------------------------------- | +| `Token reuse detected` | A previously-used refresh token was presented | +| `Invalidating N tokens in family X` | All tokens in the family are being cleaned up | +| `Refresh token reuse detected` (fraud alert) | FraudService created a BLOCK-level alert | --- @@ -258,7 +266,11 @@ The API generally returns errors in the form: or for validation errors: ```json -{ "statusCode": 400, "message": ["field must be an email", "password is too weak"], "error": "Bad Request" } +{ + "statusCode": 400, + "message": ["field must be an email", "password is too weak"], + "error": "Bad Request" +} ``` --- diff --git a/docs/INCIDENT_RUNBOOK_RATE_LIMIT.md b/docs/INCIDENT_RUNBOOK_RATE_LIMIT.md index bb0475b..e2d3ca8 100644 --- a/docs/INCIDENT_RUNBOOK_RATE_LIMIT.md +++ b/docs/INCIDENT_RUNBOOK_RATE_LIMIT.md @@ -46,6 +46,7 @@ INFO memory ### 3. Verify Guard Configuration Check `src/auth/rate-limit.config.ts` for: + - Window size (should match `RATE_LIMIT_WINDOW_MS`) - Max requests per window - Whether endpoint-specific overrides are misconfigured diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cb79e0c..86b2f88 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -598,6 +598,7 @@ model PropertyView { user User? @relation(fields: [userId], references: [id], onDelete: SetNull) @@index([propertyId, viewedAt]) + @@index([propertyId, userId, viewedAt]) @@index([userId]) @@index([ipAddress]) @@map("property_views") @@ -713,6 +714,7 @@ model Transaction { @@index([propertyId]) @@index([buyerId]) @@index([sellerId]) + @@index([buyerId, sellerId]) @@index([status]) @@index([deleted]) @@index([blockchainHash]) @@ -1123,6 +1125,7 @@ model Dispute { @@index([transactionId]) @@index([initiatorId]) @@index([status]) + @@index([status, transactionId]) @@map("disputes") } diff --git a/src/common/filters/validation-exception.filter.ts b/src/common/filters/validation-exception.filter.ts index fa87b9c..1affdf1 100644 --- a/src/common/filters/validation-exception.filter.ts +++ b/src/common/filters/validation-exception.filter.ts @@ -1,10 +1,4 @@ -import { - ExceptionFilter, - Catch, - ArgumentsHost, - BadRequestException, - Logger, -} from '@nestjs/common'; +import { ExceptionFilter, Catch, ArgumentsHost, BadRequestException, Logger } from '@nestjs/common'; import { Request, Response } from 'express'; import { I18nService, SupportedLanguage } from '../../i18n/i18n.service'; @@ -72,7 +66,7 @@ export class ValidationExceptionFilter implements ExceptionFilter { const message = typeof exceptionResponse === 'string' ? exceptionResponse - : (exceptionResponse as { message?: string }).message ?? 'Bad Request'; + : ((exceptionResponse as { message?: string }).message ?? 'Bad Request'); response.status(status).json({ success: false, @@ -141,10 +135,8 @@ export class ValidationExceptionFilter implements ExceptionFilter { const lower = message.toLowerCase(); if (lower.includes('must be a valid email') || lower.includes('must be an email')) return 'IS_EMAIL'; - if (lower.includes('must be longer') || lower.includes('must be at least')) - return 'MIN_LENGTH'; - if (lower.includes('must be shorter') || lower.includes('must be at most')) - return 'MAX_LENGTH'; + if (lower.includes('must be longer') || lower.includes('must be at least')) return 'MIN_LENGTH'; + if (lower.includes('must be shorter') || lower.includes('must be at most')) return 'MAX_LENGTH'; if (lower.includes('should not be empty') || lower.includes('must not be empty')) return 'IS_NOT_EMPTY'; if (lower.includes('must be a string')) return 'IS_STRING'; @@ -166,8 +158,7 @@ export class ValidationExceptionFilter implements ExceptionFilter { if (lower.includes('must contain')) return 'CONTAINS'; if (lower.includes('must not contain')) return 'NOT_CONTAINS'; if (lower.includes('must be defined')) return 'IS_DEFINED'; - if (lower.includes('should not exist') || lower.includes('must not exist')) - return 'NOT_EXISTS'; + if (lower.includes('should not exist') || lower.includes('must not exist')) return 'NOT_EXISTS'; if (lower.includes('must be a valid phone')) return 'IS_PHONE_NUMBER'; if (lower.includes('must be a valid postal')) return 'IS_POSTAL_CODE'; if (lower.includes('must be a valid credit card')) return 'IS_CREDIT_CARD'; diff --git a/src/transactions/transactions.service.spec.ts b/src/transactions/transactions.service.spec.ts index f770d82..40fc8ba 100644 --- a/src/transactions/transactions.service.spec.ts +++ b/src/transactions/transactions.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Decimal } from '@prisma/client/runtime/library'; import { TransactionsService } from './transactions.service'; @@ -48,6 +48,47 @@ describe('TransactionsService', () => { updateCommissionsStatus: jest.fn().mockResolvedValue(undefined), }; + const mockTransactionFeesService = { + calculateFees: jest.fn().mockReturnValue({ + platformFee: 0, + agentCommission: 0, + }), + }; + + const mockTimelineService = { + addMilestone: jest.fn(), + updateMilestone: jest.fn(), + getTimeline: jest.fn(), + addStageEvent: jest.fn(), + }; + + const mockAuditService = { + log: jest.fn(), + findByTransaction: jest.fn(), + }; + + const mockTransaction = { + id: 't-1', + buyerId: 'user-1', + sellerId: 'user-2', + propertyId: 'prop-1', + amount: new Decimal('100000'), + type: 'SALE', + status: 'PENDING', + notes: null, + blockchainHash: null, + contractAddress: null, + feeBreakdown: null, + escrowStatus: null, + escrowAmount: null, + paymentStatus: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + buyer: { id: 'user-1', email: 'b@test.com', firstName: 'B', lastName: 'B' }, + seller: { id: 'user-2', email: 's@test.com', firstName: 'S', lastName: 'S' }, + property: { id: 'prop-1', title: 'Test', address: '123 Main' }, + }; + beforeEach(async () => { jest.clearAllMocks(); @@ -58,20 +99,9 @@ describe('TransactionsService', () => { { provide: BlockchainService, useValue: mockBlockchainService }, { provide: NotificationsService, useValue: mockNotificationsService }, { provide: CommissionsService, useValue: mockCommissionsService }, - { provide: TransactionFeesService, useValue: { calculateFees: jest.fn() } }, - { - provide: TimelineService, - useValue: { - addMilestone: jest.fn(), - updateMilestone: jest.fn(), - getTimeline: jest.fn(), - addStageEvent: jest.fn(), - }, - }, - { - provide: TransactionAuditService, - useValue: { log: jest.fn(), findByTransaction: jest.fn() }, - }, + { provide: TransactionFeesService, useValue: mockTransactionFeesService }, + { provide: TimelineService, useValue: mockTimelineService }, + { provide: TransactionAuditService, useValue: mockAuditService }, ], }).compile(); @@ -84,48 +114,101 @@ describe('TransactionsService', () => { }); describe('findAll', () => { - it('should call prisma findMany and count with correct arguments', async () => { - const query = { - page: 1, - limit: 10, - }; - + it('should return paginated transactions', async () => { mockPrismaService.transaction.findMany.mockResolvedValue([]); mockPrismaService.transaction.count.mockResolvedValue(0); - const result = await service.findAll(query); + const result = await service.findAll({ page: 1, limit: 10 }); expect(prisma.transaction.findMany).toHaveBeenCalled(); expect(prisma.transaction.count).toHaveBeenCalled(); expect(result).toHaveProperty('items'); expect(result).toHaveProperty('total'); + expect(result).toHaveProperty('nextCursor'); + expect(result).toHaveProperty('previousCursor'); + }); + + it('should apply filter params', async () => { + mockPrismaService.transaction.findMany.mockResolvedValue([]); + mockPrismaService.transaction.count.mockResolvedValue(0); + + await service.findAll({ + page: 1, + limit: 10, + propertyId: 'prop-1', + buyerId: 'user-1', + sellerId: 'user-2', + status: 'PENDING' as any, + type: 'SALE' as any, + }); + + expect(prisma.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + propertyId: 'prop-1', + buyerId: 'user-1', + sellerId: 'user-2', + status: 'PENDING', + type: 'SALE', + }), + }), + ); + }); + + it('should apply cursor-based pagination', async () => { + const cursor = Buffer.from('2026-01-01T00:00:00.000Z').toString('base64'); + mockPrismaService.transaction.findMany.mockResolvedValue([]); + mockPrismaService.transaction.count.mockResolvedValue(0); + + await service.findAll({ page: 1, limit: 10, cursor } as any); + + expect(prisma.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: { lt: new Date('2026-01-01T00:00:00.000Z') }, + }), + }), + ); + }); + + it('should compute nextCursor when items fill the page', async () => { + const items = Array.from({ length: 5 }, (_, i) => ({ + ...mockTransaction, + id: `t-${i}`, + createdAt: new Date(`2026-01-0${i + 1}T00:00:00.000Z`), + })); + mockPrismaService.transaction.findMany.mockResolvedValue(items); + mockPrismaService.transaction.count.mockResolvedValue(5); + + const result = await service.findAll({ page: 1, limit: 5 }); + + expect(result.nextCursor).toBeTruthy(); + expect(result.items).toHaveLength(5); + }); + + it('should return null nextCursor when fewer items than limit', async () => { + mockPrismaService.transaction.findMany.mockResolvedValue([mockTransaction]); + mockPrismaService.transaction.count.mockResolvedValue(1); + + const result = await service.findAll({ page: 1, limit: 20 }); + + expect(result.nextCursor).toBeNull(); }); }); describe('findOne', () => { it('should return transaction if found', async () => { - const mockTransaction = { - id: 't-1', - buyerId: 'user-1', - sellerId: 'user-2', - propertyId: 'prop-1', - amount: 100000, - type: 'SALE', - status: 'PENDING', - notes: null, - blockchainHash: null, - contractAddress: null, - createdAt: new Date(), - updatedAt: new Date(), - buyer: { id: 'user-1', email: 'b@test.com', firstName: 'B', lastName: 'B' }, - seller: { id: 'user-2', email: 's@test.com', firstName: 'S', lastName: 'S' }, - property: { id: 'prop-1', title: 'Test', address: '123 Main' }, - }; mockPrismaService.transaction.findUnique.mockResolvedValue(mockTransaction); const result = await service.findOne('t-1'); expect(result.id).toBe('t-1'); }); + + it('should throw NotFoundException if not found', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue(null); + + await expect(service.findOne('nonexistent')).rejects.toThrow(NotFoundException); + }); }); describe('create', () => { @@ -141,8 +224,9 @@ describe('TransactionsService', () => { type: 'SALE', status: 'PENDING', notes: null, - blockchianHash: null, + blockchainHash: null, contractAddress: null, + feeBreakdown: null, createdAt: new Date(), updatedAt: new Date(), }); @@ -156,6 +240,183 @@ describe('TransactionsService', () => { }); expect(result.id).toBe('t-new'); + expect(mockCommissionsService.createCommissionsForTransaction).toHaveBeenCalledWith('t-new'); + }); + + it('should throw NotFoundException if property not found', async () => { + mockPrismaService.property.findUnique.mockResolvedValue(null); + + await expect( + service.create({ + propertyId: 'nonexistent', + buyerId: 'user-1', + sellerId: 'user-2', + amount: 100000, + type: TransactionTypeDto.SALE, + }), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException if buyer not found', async () => { + mockPrismaService.property.findUnique.mockResolvedValue({ id: 'prop-1' }); + mockPrismaService.user.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'user-2' }); + + await expect( + service.create({ + propertyId: 'prop-1', + buyerId: 'nonexistent', + sellerId: 'user-2', + amount: 100000, + type: TransactionTypeDto.SALE, + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('update', () => { + it('should update a transaction', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue(mockTransaction); + mockPrismaService.transaction.update.mockResolvedValue({ + ...mockTransaction, + status: 'COMPLETED', + }); + + const result = await service.update('t-1', { status: 'COMPLETED' as any }); + + expect(result.status).toBe('COMPLETED'); + expect(mockCommissionsService.updateCommissionsStatus).toHaveBeenCalledWith( + 't-1', + 'COMPLETED', + ); + }); + + it('should throw NotFoundException if transaction not found', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue(null); + + await expect(service.update('nonexistent', { status: 'COMPLETED' as any })).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('updateTransactionStatus', () => { + it('should update transaction status with audit log', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue(mockTransaction); + mockPrismaService.transaction.update.mockResolvedValue({ + ...mockTransaction, + status: 'COMPLETED', + }); + + const result = await service.updateTransactionStatus('t-1', 'COMPLETED', 'admin-1'); + + expect(result.status).toBe('COMPLETED'); + expect(mockAuditService.log).toHaveBeenCalled(); + expect(mockTimelineService.addStageEvent).toHaveBeenCalledWith('t-1', 'COMPLETED'); + }); + + it('should reject invalid status transition', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue({ + ...mockTransaction, + status: 'COMPLETED', + }); + + await expect(service.updateTransactionStatus('t-1', 'PENDING', 'admin-1')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw NotFoundException if transaction not found', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue(null); + + await expect(service.updateTransactionStatus('nonexistent', 'COMPLETED')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('recordOnBlockchain', () => { + it('should record a transaction on the blockchain', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue(mockTransaction); + mockBlockchainService.recordTransactionOnBlockchain.mockResolvedValue({ + blockchainHash: '0xabc123', + contractAddress: '0xcontract', + }); + mockPrismaService.transaction.update.mockResolvedValue({ + ...mockTransaction, + blockchainHash: '0xabc123', + contractAddress: '0xcontract', + }); + + const result = await service.recordOnBlockchain('t-1', { + buyerAddress: '0xbuyer', + sellerAddress: '0xseller', + }); + + expect(result.blockchain.blockchainHash).toBe('0xabc123'); + expect(mockBlockchainService.recordTransactionOnBlockchain).toHaveBeenCalled(); + }); + + it('should reject if already recorded on blockchain', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue({ + ...mockTransaction, + blockchainHash: '0xexisting', + }); + + await expect( + service.recordOnBlockchain('t-1', { buyerAddress: '0xbuyer', sellerAddress: '0xseller' }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if transaction not found', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue(null); + + await expect( + service.recordOnBlockchain('nonexistent', { + buyerAddress: '0xbuyer', + sellerAddress: '0xseller', + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('verifyOnBlockchain', () => { + it('should verify a blockchain transaction', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue({ + ...mockTransaction, + blockchainHash: '0xabc123', + }); + mockBlockchainService.verifyBlockchainTransaction.mockResolvedValue({ + verified: true, + status: 'success', + }); + + const result = await service.verifyOnBlockchain('t-1'); + + expect(result.verified).toBe(true); + }); + + it('should reject if not recorded on blockchain', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue(mockTransaction); + + await expect(service.verifyOnBlockchain('t-1')).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if transaction not found', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue(null); + + await expect(service.verifyOnBlockchain('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('getBlockchainStats', () => { + it('should return blockchain stats', async () => { + mockBlockchainService.getBlockchainStats.mockResolvedValue({ totalBlocks: 100 }); + + const result = await service.getBlockchainStats(); + + expect(result).toEqual({ totalBlocks: 100 }); }); }); @@ -268,5 +529,53 @@ describe('TransactionsService', () => { ).rejects.toThrow(BadRequestException); expect(prisma.transaction.findMany).not.toHaveBeenCalled(); }); + + it('should handle empty transactions', async () => { + mockPrismaService.transaction.findMany.mockResolvedValue([]); + + const result = await service.getAnalytics({ + granularity: TransactionAnalyticsGranularity.MONTH, + }); + + expect(result.totalTransactions).toBe(0); + expect(result.averagePrice).toBe(0); + expect(result.completionRate).toBe(0); + expect(result.revenue).toBe(0); + expect(result.volumeTrends).toEqual([]); + }); + + it('should cap startDate-only range', async () => { + const startDate = new Date('2026-01-01T00:00:00.000Z'); + mockPrismaService.transaction.findMany.mockResolvedValue([]); + + await service.getAnalytics({ startDate, maxDays: 30 }); + + expect(prisma.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: expect.objectContaining({ + gte: startDate, + }), + }), + }), + ); + }); + + it('should cap endDate-only range', async () => { + const endDate = new Date('2026-06-01T00:00:00.000Z'); + mockPrismaService.transaction.findMany.mockResolvedValue([]); + + await service.getAnalytics({ endDate, maxDays: 30 }); + + expect(prisma.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: expect.objectContaining({ + lte: endDate, + }), + }), + }), + ); + }); }); });