diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..238755f --- /dev/null +++ b/.cursorrules @@ -0,0 +1,270 @@ +# Cursor AI Rules for StarShop Backend + +## ๐Ÿšจ CRITICAL RULES - NEVER VIOLATE + +### 1. Environment Variables +- **NEVER** use `process.env.*` directly +- **ALWAYS** import from `src/config/env` and use the `config` object +- **ALWAYS** validate environment variables with Zod schema + +```typescript +// โŒ FORBIDDEN +const dbHost = process.env.DB_HOST; + +// โœ… REQUIRED +import { config } from '../config/env'; +const dbHost = config.database.host; +``` + +### 2. TypeScript Types +- **ALWAYS** provide explicit return types for functions +- **NEVER** use `any` type (use `unknown` if necessary) +- **ALWAYS** create interfaces for complex objects +- **ALWAYS** type all function parameters + +```typescript +// โŒ FORBIDDEN +function getUser(id) { + return userRepository.findOne(id); +} + +// โœ… REQUIRED +interface UserResponse { + id: string; + name: string; + email: string; +} + +async function getUser(id: string): Promise { + return userRepository.findOne(id); +} +``` + +### 3. Code Quality +- **NEVER** include `console.log` statements +- **NEVER** leave unused imports +- **NEVER** leave commented-out code +- **ALWAYS** follow ESLint and Prettier rules + +### 4. Testing Requirements +- **ALWAYS** create tests for new features +- **ALWAYS** mock external dependencies +- **ALWAYS** test both success and error cases +- **ALWAYS** ensure tests are deterministic + +### 5. Architecture Patterns +- **ALWAYS** follow NestJS module structure +- **ALWAYS** use DTOs for data validation +- **ALWAYS** use services for business logic +- **ALWAYS** use controllers for HTTP handling + +## ๐Ÿ“‹ Code Generation Guidelines + +### When generating code, ensure: + +1. **Environment Access**: Use centralized config +2. **Type Safety**: Explicit types everywhere +3. **Error Handling**: Proper try-catch blocks +4. **Validation**: Use class-validator decorators +5. **Testing**: Include corresponding test files +6. **Documentation**: Add JSDoc comments +7. **Logging**: Use proper logging instead of console.log + +### Example Service Generation: + +```typescript +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { config } from '../config/env'; + +interface CreateUserRequest { + name: string; + email: string; + walletAddress: string; +} + +interface UserResponse { + id: string; + name: string; + email: string; + walletAddress: string; + createdAt: Date; +} + +@Injectable() +export class UserService { + private readonly logger = new Logger(UserService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async createUser(data: CreateUserRequest): Promise { + try { + this.logger.log(`Creating user with email: ${data.email}`); + + const user = this.userRepository.create({ + ...data, + // Add any additional logic here + }); + + const savedUser = await this.userRepository.save(user); + + this.logger.log(`User created successfully with ID: ${savedUser.id}`); + + return { + id: savedUser.id, + name: savedUser.name, + email: savedUser.email, + walletAddress: savedUser.walletAddress, + createdAt: savedUser.createdAt, + }; + } catch (error) { + this.logger.error(`Failed to create user: ${error.message}`, error.stack); + throw new Error('Failed to create user'); + } + } +} +``` + +### Example Test Generation: + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserService } from './user.service'; +import { User } from './entities/user.entity'; + +describe('UserService', () => { + let service: UserService; + let repository: Repository; + + const mockRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserService, + { + provide: getRepositoryToken(User), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(UserService); + repository = module.get>(getRepositoryToken(User)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createUser', () => { + it('should create a user successfully', async () => { + // Arrange + const userData = { + name: 'John Doe', + email: 'john@example.com', + walletAddress: 'GD123...', + }; + + const expectedUser = { + id: '1', + ...userData, + createdAt: new Date(), + }; + + mockRepository.create.mockReturnValue(expectedUser); + mockRepository.save.mockResolvedValue(expectedUser); + + // Act + const result = await service.createUser(userData); + + // Assert + expect(mockRepository.create).toHaveBeenCalledWith(userData); + expect(mockRepository.save).toHaveBeenCalledWith(expectedUser); + expect(result).toEqual({ + id: expectedUser.id, + name: expectedUser.name, + email: expectedUser.email, + walletAddress: expectedUser.walletAddress, + createdAt: expectedUser.createdAt, + }); + }); + + it('should throw error when user creation fails', async () => { + // Arrange + const userData = { + name: 'John Doe', + email: 'john@example.com', + walletAddress: 'GD123...', + }; + + mockRepository.create.mockReturnValue(userData); + mockRepository.save.mockRejectedValue(new Error('Database error')); + + // Act & Assert + await expect(service.createUser(userData)).rejects.toThrow('Failed to create user'); + }); + }); +}); +``` + +## ๐Ÿ”ง File Structure Requirements + +When creating new modules, follow this structure: + +``` +src/modules/[module-name]/ +โ”œโ”€โ”€ controllers/ +โ”‚ โ””โ”€โ”€ [module].controller.ts +โ”œโ”€โ”€ services/ +โ”‚ โ””โ”€โ”€ [module].service.ts +โ”œโ”€โ”€ entities/ +โ”‚ โ””โ”€โ”€ [module].entity.ts +โ”œโ”€โ”€ dto/ +โ”‚ โ”œโ”€โ”€ create-[module].dto.ts +โ”‚ โ”œโ”€โ”€ update-[module].dto.ts +โ”‚ โ””โ”€โ”€ [module].response.dto.ts +โ”œโ”€โ”€ tests/ +โ”‚ โ”œโ”€โ”€ [module].controller.spec.ts +โ”‚ โ”œโ”€โ”€ [module].service.spec.ts +โ”‚ โ””โ”€โ”€ [module].integration.spec.ts +โ””โ”€โ”€ [module].module.ts +``` + +## ๐Ÿšซ Common Mistakes to Avoid + +1. **Direct process.env access** - Always use centralized config +2. **Missing return types** - Always specify function return types +3. **No error handling** - Always wrap in try-catch blocks +4. **Missing tests** - Always create corresponding test files +5. **console.log usage** - Use proper logging instead +6. **any types** - Use specific types or unknown +7. **Missing validation** - Always validate input data +8. **No documentation** - Add JSDoc comments for complex logic + +## โœ… Quality Checklist + +Before suggesting code, ensure: +- [ ] Uses centralized environment config +- [ ] Has explicit TypeScript types +- [ ] Includes proper error handling +- [ ] Follows NestJS patterns +- [ ] Includes corresponding tests +- [ ] Uses proper logging +- [ ] Validates input data +- [ ] Has JSDoc documentation +- [ ] Follows ESLint/Prettier rules +- [ ] Is production-ready diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..ff47e52 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,52 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +echo "๐Ÿ” Running pre-commit checks..." + +# Run linting +echo "๐Ÿ“ Running ESLint..." +npm run lint +if [ $? -ne 0 ]; then + echo "โŒ ESLint failed. Please fix the issues before committing." + exit 1 +fi + +# Run formatting check +echo "๐ŸŽจ Checking code formatting..." +npm run format:check +if [ $? -ne 0 ]; then + echo "โŒ Code formatting check failed. Run 'npm run format' to fix." + exit 1 +fi + +# Run tests +echo "๐Ÿงช Running tests..." +npm run test:ci +if [ $? -ne 0 ]; then + echo "โŒ Tests failed. Please fix the issues before committing." + exit 1 +fi + +# Check for direct process.env usage +echo "๐Ÿ” Checking for direct process.env usage..." +if grep -r "process\.env\." src/ --exclude-dir=node_modules --exclude="*.spec.ts" --exclude="*.test.ts"; then + echo "โŒ Direct process.env usage detected. Use centralized config instead." + echo " Import from 'src/config/env' and use the config object." + exit 1 +fi + +# Check for console.log statements +echo "๐Ÿ” Checking for console.log statements..." +if grep -r "console\.log" src/ --exclude-dir=node_modules --exclude="*.spec.ts" --exclude="*.test.ts"; then + echo "โŒ console.log statements detected. Remove them before committing." + exit 1 +fi + +# Check for TODO/FIXME comments +echo "๐Ÿ” Checking for TODO/FIXME comments..." +if grep -r "TODO\|FIXME" src/ --exclude-dir=node_modules --exclude="*.spec.ts" --exclude="*.test.ts"; then + echo "โš ๏ธ TODO/FIXME comments detected. Consider addressing them." + # Don't fail the commit, just warn +fi + +echo "โœ… All pre-commit checks passed!" diff --git a/README.md b/README.md index fd56818..4980cee 100644 --- a/README.md +++ b/README.md @@ -188,9 +188,20 @@ PORT=3000 ```bash # Run migrations to create database tables -npm run typeorm migration:run +# dist/data-source.js is your file data-source in dist directory +npm run typeorm migration:run -d dist/data-source.js + +# or + +# dist/data-source.js is your file data-source in dist directory +# The --fake flag tells TypeORM to mark the migration as executed without actually running it. +npm run typeorm migration:run -d dist/data-source.js --fake ``` +> [!WARNING] +> Is important that you have dist file to use migrations + + ### 6. Start the Application ```bash diff --git a/docker-compose.yml b/docker-compose.yml index 02f09f7..0b5cc34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,5 +12,16 @@ services: volumes: - postgres_data:/var/lib/postgresql/data + redis: + image: redis:7-alpine + container_name: starshop-redis + ports: + - '6379:6379' + volumes: + - redis_data:/data + command: redis-server --appendonly yes + restart: unless-stopped + volumes: postgres_data: + redis_data: diff --git a/docs/IMPLEMENTATION_GUIDE.md b/docs/IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..44ea999 --- /dev/null +++ b/docs/IMPLEMENTATION_GUIDE.md @@ -0,0 +1,429 @@ +# StarShop Backend - User Registration Implementation Guide + +## Overview + +This document provides a comprehensive guide to the user registration system implemented in the StarShop backend. The system supports both buyer and seller registration with role-specific data validation and enhanced user profile fields. + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Database Schema](#database-schema) +3. [API Endpoints](#api-endpoints) +4. [Validation Rules](#validation-rules) +5. [Implementation Details](#implementation-details) +6. [Testing Strategy](#testing-strategy) +7. [Migration Guide](#migration-guide) +8. [Troubleshooting](#troubleshooting) + +## Architecture Overview + +### System Components + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Controller โ”‚ โ”‚ Service โ”‚ โ”‚ Repository โ”‚ +โ”‚ (Validation) โ”‚โ”€โ”€โ”€โ–ถโ”‚ (Business โ”‚โ”€โ”€โ”€โ–ถโ”‚ (Database) โ”‚ +โ”‚ โ”‚ โ”‚ Logic) โ”‚ โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ + โ–ผ โ–ผ โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ DTOs โ”‚ โ”‚ Entities โ”‚ โ”‚ Migrations โ”‚ +โ”‚ (Input/ โ”‚ โ”‚ (Data Model) โ”‚ โ”‚ (Schema โ”‚ +โ”‚ Output) โ”‚ โ”‚ โ”‚ โ”‚ Changes) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Key Features + +- **Role-based Registration**: Support for buyer and seller roles +- **Enhanced User Profile**: Location, country, and role-specific data +- **Strict Validation**: Prevents cross-role data mixing +- **Flexible Data Storage**: JSON fields for customizable role data +- **Backward Compatibility**: Maintains existing user_roles structure + +## Database Schema + +### Users Table + +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email VARCHAR UNIQUE, + name VARCHAR, + wallet_address VARCHAR UNIQUE NOT NULL, + location VARCHAR, -- NEW FIELD + country VARCHAR, -- NEW FIELD + buyer_data JSONB, -- NEW FIELD + seller_data JSONB, -- NEW FIELD + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### User Roles Table (Existing) + +```sql +CREATE TABLE user_roles ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + role_id INTEGER REFERENCES roles(id) +); +``` + +### Roles Table (Existing) + +```sql +CREATE TABLE roles ( + id SERIAL PRIMARY KEY, + name VARCHAR UNIQUE CHECK (name IN ('buyer', 'seller', 'admin')) +); +``` + +## API Endpoints + +### User Registration + +**Endpoint:** `POST /api/v1/users` + +**Request Body:** +```json +{ + "walletAddress": "G...", + "role": "buyer" | "seller", + "name": "string (optional)", + "email": "string (optional)", + "location": "string (optional)", + "country": "string (optional)", + "buyerData": "object (required for buyer)", + "sellerData": "object (required for seller)" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "user": { + "id": 1, + "walletAddress": "G...", + "name": "string", + "email": "string", + "role": "buyer" | "seller", + "location": "string", + "country": "string", + "buyerData": "object | null", + "sellerData": "object | null" + }, + "expiresIn": 3600 + } +} +``` + +### User Update + +**Endpoint:** `PUT /api/v1/users/update/:id` + +**Request Body:** Same as registration but all fields optional + +### User Retrieval + +**Endpoint:** `GET /api/v1/users/:id` + +**Response:** Same structure as registration response + +## Validation Rules + +### Required Fields + +- `walletAddress`: Must be valid Stellar wallet address (G + 55 characters) +- `role`: Must be either "buyer" or "seller" +- `buyerData`: Required for buyer role, must be object +- `sellerData`: Required for seller role, must be object + +### Role-Specific Validation + +#### Buyers +- โœ… Can have: `buyerData` (required) +- โŒ Cannot have: `sellerData` +- โœ… Optional: `name`, `email`, `location`, `country` + +#### Sellers +- โœ… Can have: `sellerData` (required) +- โŒ Cannot have: `buyerData` +- โœ… Optional: `name`, `email`, `location`, `country` + +### Field Validation + +- **walletAddress**: Regex pattern `^G[A-Z2-7]{55}$` +- **email**: Valid email format (if provided) +- **name**: 2-50 characters (if provided) +- **location**: Max 100 characters (if provided) +- **country**: Max 100 characters (if provided) +- **buyerData**: Must be valid JSON object +- **sellerData**: Must be valid JSON object + +## Implementation Details + +### Custom Validator + +We implemented a custom validator `@IsRoleSpecificData` that ensures: + +```typescript +function IsRoleSpecificData(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: 'isRoleSpecificData', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + const obj = args.object as any; + const role = obj.role; + + if (propertyName === 'buyerData') { + // buyerData is only allowed for buyers + if (role !== 'buyer' && value !== undefined) { + return false; + } + } + + if (propertyName === 'sellerData') { + // sellerData is only allowed for sellers + if (role !== 'seller' && value !== undefined) { + return false; + } + } + + return true; + }, + defaultMessage(args: ValidationArguments) { + if (args.property === 'buyerData') { + return 'buyerData is only allowed for buyers'; + } + if (args.property === 'sellerData') { + return 'sellerData is only allowed for sellers'; + } + return 'Invalid role-specific data'; + } + } + }); + }; +} +``` + +### Service Layer Validation + +Additional validation in the service layer: + +```typescript +// Validate role-specific data +if (data.role === 'buyer' && data.buyerData === undefined) { + throw new BadRequestError('Buyer data is required for buyer role'); +} +if (data.role === 'seller' && data.sellerData === undefined) { + throw new BadRequestError('Seller data is required for seller role'); +} + +// Validate that buyers can't have seller data and sellers can't have buyer data +if (data.role === 'buyer' && data.sellerData !== undefined) { + throw new BadRequestError('Buyers cannot have seller data'); +} +if (data.role === 'seller' && data.buyerData !== undefined) { + throw new BadRequestError('Sellers cannot have buyer data'); +} +``` + +### Data Flow + +1. **Request Received** โ†’ Controller +2. **DTO Validation** โ†’ Custom validators run +3. **Service Layer** โ†’ Business logic validation +4. **Database** โ†’ Data persistence +5. **Response** โ†’ User data with JWT token + +## Testing Strategy + +### Test Coverage + +We've implemented comprehensive testing across multiple layers: + +#### 1. DTO Validation Tests (`dto-validation.spec.ts`) +- โœ… Valid DTOs with all combinations +- โŒ Invalid DTOs with role-specific data violations +- โŒ Missing required fields +- โŒ Invalid field formats + +#### 2. Service Layer Tests (`role-validation.spec.ts`) +- โœ… Valid registration scenarios +- โŒ Invalid registration scenarios +- โŒ Cross-role data violations + +#### 3. Controller Tests (`user-registration.spec.ts`) +- โœ… End-to-end registration flow +- โœ… Response format validation +- โœ… Error handling +- โœ… Cookie setting + +#### 4. Integration Tests +- โœ… Database operations +- โœ… Role assignment +- โœ… JWT token generation + +### Running Tests + +```bash +# Run all tests +npm test + +# Run specific test file +npm test -- user-registration.spec.ts + +# Run tests with coverage +npm run test:cov + +# Run e2e tests +npm run test:e2e +``` + +## Migration Guide + +### Database Migration + +The migration `1751199237000-AddUserFields.ts` adds new columns: + +```sql +-- Add new columns to users table +ALTER TABLE "users" +ADD COLUMN "location" character varying, +ADD COLUMN "country" character varying, +ADD COLUMN "buyerData" jsonb, +ADD COLUMN "sellerData" jsonb; +``` + +### Running Migrations + +```bash +# Run migrations +npm run migration:run + +# Revert migrations +npm run migration:revert + +# Generate new migration +npm run migration:generate -- -n MigrationName +``` + +### Data Migration Strategy + +1. **Backup existing data** +2. **Run migration** +3. **Update existing users** (if needed) +4. **Verify data integrity** + +## Troubleshooting + +### Common Issues + +#### 1. Validation Errors + +**Problem:** "buyerData is only allowed for buyers" +**Solution:** Ensure the role matches the data being sent + +**Problem:** "Invalid Stellar wallet address format" +**Solution:** Check wallet address starts with 'G' and is 56 characters + +#### 2. Database Errors + +**Problem:** JSONB column errors +**Solution:** Ensure data is valid JSON object + +**Problem:** Constraint violations +**Solution:** Check role values match enum constraints + +#### 3. Test Failures + +**Problem:** Mock service not working +**Solution:** Verify mock setup and return values + +**Problem:** Validation errors in tests +**Solution:** Check DTO instantiation and field assignment + +### Debug Mode + +Enable debug logging: + +```typescript +// In main.ts or config +process.env.DEBUG = 'true'; +``` + +### Logging + +Check application logs for detailed error information: + +```bash +tail -f logs/error.log +``` + +## Best Practices + +### 1. Data Validation +- Always validate at DTO level first +- Use custom validators for complex business rules +- Provide clear error messages + +### 2. Error Handling +- Use specific error types (BadRequestError, UnauthorizedError) +- Log errors with context +- Return user-friendly error messages + +### 3. Testing +- Test both valid and invalid scenarios +- Mock external dependencies +- Use descriptive test names +- Maintain high test coverage + +### 4. Security +- Validate all input data +- Use JWT tokens for authentication +- Implement role-based access control +- Sanitize user data before storage + +## Future Enhancements + +### Potential Improvements + +1. **Enhanced Validation** + - Custom buyerData/sellerData schemas + - Field-level validation rules + - Conditional field requirements + +2. **Data Enrichment** + - Address validation + - Country code standardization + - Phone number validation + +3. **Performance** + - Database indexing on new fields + - Query optimization + - Caching strategies + +4. **Monitoring** + - Registration metrics + - Validation failure tracking + - Performance monitoring + +## Conclusion + +This implementation provides a robust, scalable user registration system with: + +- โœ… **Comprehensive validation** at multiple layers +- โœ… **Role-based data management** with strict rules +- โœ… **Flexible data storage** for future extensibility +- โœ… **Thorough testing** coverage +- โœ… **Clear documentation** for developers + +The system is production-ready and follows NestJS best practices while maintaining backward compatibility with existing functionality. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e146f40 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,362 @@ +# StarShop Backend - User Registration System + +## ๐ŸŽฏ Overview + +This repository contains the complete implementation of a robust user registration system for the StarShop backend. The system supports both buyer and seller registration with enhanced user profile fields and strict role-based data validation. + +## โœจ Key Features + +- **๐Ÿ” Role-Based Registration**: Support for buyer and seller roles +- **๐Ÿ“ Enhanced User Profile**: Location, country, and role-specific data +- **๐Ÿ›ก๏ธ Strict Validation**: Prevents cross-role data mixing at multiple levels +- **๐Ÿ“Š Flexible Data Storage**: JSON fields for customizable role data +- **๐Ÿ”„ Backward Compatibility**: Maintains existing user_roles structure +- **๐Ÿงช Comprehensive Testing**: Full test coverage across all layers + +## ๐Ÿ“š Documentation + +### 1. [User Registration API](./user-registration.md) +Complete API documentation with examples, request/response formats, and error codes. + +### 2. [Implementation Guide](./IMPLEMENTATION_GUIDE.md) +Step-by-step guide for developers implementing or extending the system. + +### 3. [Technical Specification](./TECHNICAL_SPECIFICATION.md) +Detailed technical implementation, architecture, and design decisions. + +## ๐Ÿ—๏ธ Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Controller โ”‚ โ”‚ Service โ”‚ โ”‚ Repository โ”‚ +โ”‚ (Validation) โ”‚โ”€โ”€โ”€โ–ถโ”‚ (Business โ”‚โ”€โ”€โ”€โ–ถโ”‚ (Database) โ”‚ +โ”‚ โ”‚ โ”‚ Logic) โ”‚ โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ + โ–ผ โ–ผ โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ DTOs โ”‚ โ”‚ Entities โ”‚ โ”‚ Migrations โ”‚ +โ”‚ (Input/ โ”‚ โ”‚ (Data Model) โ”‚ โ”‚ (Schema โ”‚ +โ”‚ Output) โ”‚ โ”‚ โ”‚ โ”‚ Changes) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## ๐Ÿš€ Quick Start + +### Prerequisites + +- Node.js 16+ +- PostgreSQL 12+ +- TypeScript 4.5+ + +### Installation + +```bash +# Clone the repository +git clone +cd StarShop-Backend + +# Install dependencies +npm install + +# Set up environment variables +cp .env.example .env + +# Run database migrations +npm run migration:run + +# Start the development server +npm run start:dev +``` + +### Environment Variables + +```bash +# Database +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=starshop +DATABASE_USERNAME=postgres +DATABASE_PASSWORD=password + +# JWT +JWT_SECRET=your-secret-key +JWT_EXPIRATION_TIME=1h + +# Server +PORT=3000 +NODE_ENV=development +``` + +## ๐Ÿ“ก API Usage + +### Register a Buyer + +```bash +curl -X POST 'http://localhost:3000/api/v1/users' \ + -H 'Content-Type: application/json' \ + -d '{ + "walletAddress": "GD6LXK4RB6D522ECACFVUEOKPCYBGQ6SKYONMVNIUOWUAIRNLSYAOB4Q", + "role": "buyer", + "name": "John Doe", + "email": "john@example.com", + "location": "New York", + "country": "United States", + "buyerData": {} + }' +``` + +### Register a Seller + +```bash +curl -X POST 'http://localhost:3000/api/v1/users' \ + -H 'Content-Type: application/json' \ + -d '{ + "walletAddress": "GXYZABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890", + "role": "seller", + "name": "Jane Smith", + "email": "jane@example.com", + "location": "Los Angeles", + "country": "United States", + "sellerData": { + "businessName": "Tech Store", + "categories": ["electronics", "computers"], + "rating": 4.5 + } + }' +``` + +## ๐Ÿงช Testing + +### Run All Tests + +```bash +npm test +``` + +### Run Specific Test Files + +```bash +# DTO validation tests +npm test -- dto-validation.spec.ts + +# Role validation tests +npm test -- role-validation.spec.ts + +# User registration tests +npm test -- user-registration.spec.ts +``` + +### Test Coverage + +```bash +npm run test:cov +``` + +### E2E Tests + +```bash +npm run test:e2e +``` + +## ๐Ÿ”ง Development + +### Code Structure + +``` +src/ +โ”œโ”€โ”€ modules/ +โ”‚ โ”œโ”€โ”€ users/ # User management +โ”‚ โ”‚ โ”œโ”€โ”€ entities/ # Database entities +โ”‚ โ”‚ โ”œโ”€โ”€ controllers/ # API endpoints +โ”‚ โ”‚ โ”œโ”€โ”€ services/ # Business logic +โ”‚ โ”‚ โ””โ”€โ”€ tests/ # Test files +โ”‚ โ””โ”€โ”€ auth/ # Authentication +โ”‚ โ”œโ”€โ”€ dto/ # Data transfer objects +โ”‚ โ”œโ”€โ”€ services/ # Auth services +โ”‚ โ””โ”€โ”€ tests/ # Auth tests +โ”œโ”€โ”€ types/ # TypeScript types +โ”œโ”€โ”€ migrations/ # Database migrations +โ””โ”€โ”€ docs/ # Documentation +``` + +### Key Components + +1. **User Entity** (`src/modules/users/entities/user.entity.ts`) + - Enhanced with new fields: `location`, `country`, `buyerData`, `sellerData` + - Maintains existing relationships and constraints + +2. **Custom Validator** (`src/modules/auth/dto/auth.dto.ts`) + - `@IsRoleSpecificData` decorator ensures role-specific data rules + - Prevents buyers from having seller data and vice versa + +3. **Service Layer** (`src/modules/users/services/user.service.ts`) + - Business logic validation + - Role assignment and user creation + +4. **Database Migration** (`src/migrations/1751199237000-AddUserFields.ts`) + - Adds new columns to users table + - Maintains data integrity + +## ๐Ÿ›ก๏ธ Validation Rules + +### Required Fields +- `walletAddress`: Valid Stellar wallet address (G + 55 characters) +- `role`: Either "buyer" or "seller" +- `buyerData`: Required for buyer role, must be object +- `sellerData`: Required for seller role, must be object + +### Role-Specific Rules + +| Role | Allowed Data | Forbidden Data | +|------|--------------|----------------| +| **Buyer** | `buyerData` (required) | `sellerData` | +| **Seller** | `sellerData` (required) | `buyerData` | + +### Field Validation +- **walletAddress**: Regex pattern `^G[A-Z2-7]{55}$` +- **email**: Valid email format (if provided) +- **name**: 2-50 characters (if provided) +- **location**: Max 100 characters (if provided) +- **country**: Max 100 characters (if provided) +- **buyerData/sellerData**: Must be valid JSON objects + +## ๐Ÿ” Error Handling + +### Common Error Responses + +```json +{ + "success": false, + "message": "buyerData is only allowed for buyers" +} +``` + +### HTTP Status Codes + +- **200 OK**: Successful operations +- **201 Created**: User registered successfully +- **400 Bad Request**: Validation errors +- **401 Unauthorized**: Missing authentication +- **403 Forbidden**: Insufficient permissions +- **500 Internal Server Error**: Server errors + +## ๐Ÿš€ Deployment + +### Production Checklist + +- [ ] Environment variables configured +- [ ] Database migrations run +- [ ] SSL certificates installed +- [ ] Logging configured +- [ ] Monitoring enabled +- [ ] Health checks implemented + +### Database Migration + +```bash +# Run migrations +npm run migration:run + +# Revert if needed +npm run migration:revert + +# Generate new migration +npm run migration:generate -- -n MigrationName +``` + +## ๐Ÿค Contributing + +### Development Workflow + +1. **Fork** the repository +2. **Create** a feature branch +3. **Implement** your changes +4. **Add** tests for new functionality +5. **Run** all tests to ensure they pass +6. **Submit** a pull request + +### Code Standards + +- Follow TypeScript best practices +- Use meaningful variable and function names +- Add JSDoc comments for complex functions +- Maintain test coverage above 90% +- Follow the existing code style + +## ๐Ÿ“Š Monitoring + +### Key Metrics + +- **Registration Success Rate**: Track successful vs failed registrations +- **Validation Error Rates**: Monitor validation failure patterns +- **Response Times**: Track API endpoint performance +- **Error Patterns**: Identify common error scenarios + +### Health Checks + +```bash +# Health check endpoint +GET /health + +# Response +{ + "status": "ok", + "timestamp": "2024-01-01T00:00:00.000Z", + "uptime": 3600, + "database": "connected" +} +``` + +## ๐Ÿ”ฎ Future Enhancements + +### Planned Features + +1. **Enhanced Validation** + - Custom buyerData/sellerData schemas + - Field-level validation rules + - Conditional field requirements + +2. **Data Enrichment** + - Address validation + - Country code standardization + - Phone number validation + +3. **Performance** + - Database indexing on new fields + - Query optimization + - Caching strategies + +4. **Monitoring** + - Registration metrics + - Validation failure tracking + - Performance monitoring + +## ๐Ÿ“ž Support + +### Getting Help + +- **Documentation**: Check the docs folder first +- **Issues**: Create a GitHub issue for bugs or feature requests +- **Discussions**: Use GitHub Discussions for questions + +### Common Issues + +1. **Validation Errors**: Check the validation rules and ensure data format is correct +2. **Database Errors**: Verify database connection and migration status +3. **Test Failures**: Ensure all dependencies are installed and environment is configured + +## ๐Ÿ“„ License + +This project is licensed under the MIT License - see the [LICENSE](../LICENSE) file for details. + +## ๐Ÿ™ Acknowledgments + +- **NestJS Team** for the excellent framework +- **TypeORM** for robust database management +- **class-validator** for comprehensive validation +- **Jest** for testing framework + +--- + +**Built with โค๏ธ for the StarShop community** diff --git a/docs/TECHNICAL_SPECIFICATION.md b/docs/TECHNICAL_SPECIFICATION.md new file mode 100644 index 0000000..f3cfd83 --- /dev/null +++ b/docs/TECHNICAL_SPECIFICATION.md @@ -0,0 +1,552 @@ +# StarShop Backend - Technical Specification + +## Overview + +This document provides the technical details of the user registration system implementation, including code structure, validation logic, database design, and API specifications. + +## Table of Contents + +1. [Code Architecture](#code-architecture) +2. [Database Design](#database-design) +3. [API Specifications](#api-specifications) +4. [Validation Implementation](#validation-implementation) +5. [Error Handling](#error-handling) +6. [Security Considerations](#security-considerations) +7. [Performance Considerations](#performance-considerations) +8. [Testing Implementation](#testing-implementation) + +## Code Architecture + +### File Structure + +``` +src/ +โ”œโ”€โ”€ modules/ +โ”‚ โ”œโ”€โ”€ users/ +โ”‚ โ”‚ โ”œโ”€โ”€ entities/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ user.entity.ts # User entity with new fields +โ”‚ โ”‚ โ”œโ”€โ”€ controllers/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ user.controller.ts # Registration endpoints +โ”‚ โ”‚ โ”œโ”€โ”€ services/ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ user.service.ts # User business logic +โ”‚ โ”‚ โ””โ”€โ”€ tests/ +โ”‚ โ”‚ โ””โ”€โ”€ user-registration.spec.ts +โ”‚ โ””โ”€โ”€ auth/ +โ”‚ โ”œโ”€โ”€ dto/ +โ”‚ โ”‚ โ””โ”€โ”€ auth.dto.ts # DTOs with custom validation +โ”‚ โ”œโ”€โ”€ services/ +โ”‚ โ”‚ โ””โ”€โ”€ auth.service.ts # Authentication logic +โ”‚ โ””โ”€โ”€ tests/ +โ”‚ โ”œโ”€โ”€ role-validation.spec.ts +โ”‚ โ””โ”€โ”€ dto-validation.spec.ts +โ”œโ”€โ”€ types/ +โ”‚ โ”œโ”€โ”€ role.ts # Role enum definitions +โ”‚ โ””โ”€โ”€ auth-request.type.ts # Request type definitions +โ”œโ”€โ”€ migrations/ +โ”‚ โ””โ”€โ”€ 1751199237000-AddUserFields.ts # Database migration +โ””โ”€โ”€ docs/ + โ”œโ”€โ”€ user-registration.md # API documentation + โ”œโ”€โ”€ IMPLEMENTATION_GUIDE.md # Implementation guide + โ””โ”€โ”€ TECHNICAL_SPECIFICATION.md # This document +``` + +### Class Dependencies + +```typescript +// User Controller depends on: +UserController โ†’ UserService +UserController โ†’ AuthService + +// User Service depends on: +UserService โ†’ UserRepository +UserService โ†’ RoleRepository +UserService โ†’ UserRoleRepository + +// Auth Service depends on: +AuthService โ†’ UserRepository +AuthService โ†’ RoleRepository +AuthService โ†’ UserRoleRepository +AuthService โ†’ JwtService +``` + +## Database Design + +### Entity Relationships + +```typescript +@Entity('users') +export class User { + @PrimaryGeneratedColumn() + id: number; + + @Column({ unique: true, nullable: true }) + email?: string; + + @Column({ nullable: true }) + name?: string; + + @Column({ unique: true }) + walletAddress: string; + + // NEW FIELDS + @Column({ nullable: true }) + location?: string; + + @Column({ nullable: true }) + country?: string; + + @Column({ type: 'json', nullable: true }) + buyerData?: any; + + @Column({ type: 'json', nullable: true }) + sellerData?: any; + + // RELATIONSHIPS + @OneToMany(() => UserRole, (userRole) => userRole.user) + userRoles: UserRole[]; + + @OneToMany(() => Order, (order) => order.user) + orders: Order[]; + + @OneToMany(() => Notification, (notification) => notification.user) + notifications: Notification[]; + + @OneToMany(() => Wishlist, (wishlist) => wishlist.user) + wishlist: Wishlist[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} +``` + +### Database Migration + +```sql +-- Migration: 1751199237000-AddUserFields.ts +ALTER TABLE "users" +ADD COLUMN "location" character varying, +ADD COLUMN "country" character varying, +ADD COLUMN "buyerData" jsonb, +ADD COLUMN "sellerData" jsonb; +``` + +### Data Types + +- **location**: VARCHAR (max 100 characters) +- **country**: VARCHAR (max 100 characters) +- **buyerData**: JSONB (PostgreSQL JSON Binary) +- **sellerData**: JSONB (PostgreSQL JSON Binary) + +### Indexing Strategy + +```sql +-- Consider adding indexes for performance +CREATE INDEX idx_users_location ON users(location); +CREATE INDEX idx_users_country ON users(country); +CREATE INDEX idx_users_buyer_data ON users USING GIN(buyerData); +CREATE INDEX idx_users_seller_data ON users USING GIN(sellerData); +``` + +## API Specifications + +### Request/Response Models + +#### RegisterUserDto + +```typescript +export class RegisterUserDto { + @IsString() + @IsNotEmpty() + @Matches(/^G[A-Z2-7]{55}$/) + walletAddress: string; + + @IsString() + @IsNotEmpty() + @Matches(/^(buyer|seller)$/) + role: 'buyer' | 'seller'; + + @IsString() + @IsOptional() + name?: string; + + @IsEmail() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + location?: string; + + @IsString() + @IsOptional() + country?: string; + + @IsRoleSpecificData({ message: 'buyerData is only allowed for buyers' }) + @IsObject() + @IsOptional() + buyerData?: any; + + @IsRoleSpecificData({ message: 'sellerData is only allowed for sellers' }) + @IsObject() + @IsOptional() + sellerData?: any; +} +``` + +#### UserResponse + +```typescript +interface UserResponse { + id: number; + walletAddress: string; + name: string; + email: string; + role: string; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; + createdAt?: Date; + updatedAt?: Date; +} +``` + +### HTTP Status Codes + +- **200 OK**: Successful GET/PUT operations +- **201 Created**: Successful user registration +- **400 Bad Request**: Validation errors, missing required fields +- **401 Unauthorized**: Missing or invalid authentication +- **403 Forbidden**: Insufficient permissions +- **500 Internal Server Error**: Server-side errors + +### Error Response Format + +```json +{ + "success": false, + "message": "Error description", + "errors": [ + { + "field": "fieldName", + "message": "Field-specific error message" + } + ] +} +``` + +## Validation Implementation + +### Custom Validator Architecture + +```typescript +function IsRoleSpecificData(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: 'isRoleSpecificData', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + const obj = args.object as any; + const role = obj.role; + + if (propertyName === 'buyerData') { + // buyerData is only allowed for buyers + if (role !== 'buyer' && value !== undefined) { + return false; + } + } + + if (propertyName === 'sellerData') { + // sellerData is only allowed for sellers + if (role !== 'seller' && value !== undefined) { + return false; + } + } + + return true; + }, + defaultMessage(args: ValidationArguments) { + if (args.property === 'buyerData') { + return 'buyerData is only allowed for buyers'; + } + if (args.property === 'sellerData') { + return 'sellerData is only allowed for sellers'; + } + return 'Invalid role-specific data'; + } + } + }); + }; +} +``` + +### Validation Flow + +1. **Input Validation**: DTO-level validation using class-validator +2. **Custom Validation**: Role-specific data validation +3. **Business Logic Validation**: Service-layer validation +4. **Database Constraints**: Database-level validation + +### Validation Rules Matrix + +| Field | Buyer Role | Seller Role | Validation | +|-------|------------|-------------|------------| +| walletAddress | โœ… Required | โœ… Required | Stellar format | +| role | โœ… Required | โœ… Required | Enum: buyer/seller | +| buyerData | โœ… Required | โŒ Forbidden | Object | +| sellerData | โŒ Forbidden | โœ… Required | Object | +| name | โšช Optional | โšช Optional | String (2-50) | +| email | โšช Optional | โšช Optional | Email format | +| location | โšช Optional | โšช Optional | String (max 100) | +| country | โšช Optional | โšช Optional | String (max 100) | + +## Error Handling + +### Error Types + +```typescript +// Custom error classes +export class BadRequestError extends Error { + constructor(message: string) { + super(message); + this.name = 'BadRequestError'; + } +} + +export class UnauthorizedError extends Error { + constructor(message: string) { + super(message); + this.name = 'UnauthorizedError'; + } +} +``` + +### Error Handling Strategy + +1. **Validation Errors**: Caught at DTO level, return 400 +2. **Business Logic Errors**: Caught at service level, return 400 +3. **Authentication Errors**: Caught at guard level, return 401 +4. **Authorization Errors**: Caught at guard level, return 403 +5. **System Errors**: Caught at global level, return 500 + +### Error Logging + +```typescript +// Log errors with context +logger.error('User registration failed', { + walletAddress: data.walletAddress, + role: data.role, + error: error.message, + stack: error.stack +}); +``` + +## Security Considerations + +### Input Validation + +- **SQL Injection**: Prevented by TypeORM parameterized queries +- **XSS**: Input sanitization at DTO level +- **Data Validation**: Strict validation rules for all fields + +### Authentication + +- **JWT Tokens**: Secure token generation and validation +- **HttpOnly Cookies**: Prevents XSS token theft +- **Token Expiration**: Configurable token lifetime + +### Authorization + +- **Role-Based Access Control**: User roles determine permissions +- **Resource Ownership**: Users can only access their own data +- **Admin Override**: Admin users have elevated permissions + +### Data Protection + +- **Wallet Address**: Unique constraint prevents duplicate registrations +- **Email Validation**: Format and uniqueness validation +- **JSON Data**: Schema validation for buyerData/sellerData + +## Performance Considerations + +### Database Optimization + +- **Indexing**: Strategic indexes on frequently queried fields +- **JSONB**: Efficient storage and querying of JSON data +- **Connection Pooling**: TypeORM connection management + +### Caching Strategy + +- **User Data**: Cache frequently accessed user information +- **Role Data**: Cache role definitions and permissions +- **Validation Results**: Cache validation results for repeated requests + +### Query Optimization + +- **Selective Loading**: Load only required fields +- **Relationship Loading**: Eager vs lazy loading strategies +- **Pagination**: Implement pagination for large datasets + +## Testing Implementation + +### Test Structure + +```typescript +describe('User Registration', () => { + describe('Valid Scenarios', () => { + it('should register buyer successfully', async () => { + // Test implementation + }); + }); + + describe('Invalid Scenarios', () => { + it('should reject buyer with sellerData', async () => { + // Test implementation + }); + }); +}); +``` + +### Test Categories + +1. **Unit Tests**: Individual function testing +2. **Integration Tests**: Service interaction testing +3. **Controller Tests**: API endpoint testing +4. **DTO Tests**: Validation logic testing + +### Mock Strategy + +```typescript +// Mock external dependencies +const mockAuthService = { + registerWithWallet: jest.fn(), + updateUser: jest.fn(), +}; + +// Mock database repositories +const mockUserRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), +}; +``` + +### Test Coverage Goals + +- **Lines**: >90% +- **Functions**: >95% +- **Branches**: >85% +- **Statements**: >90% + +## Configuration + +### Environment Variables + +```bash +# Database +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=starshop +DATABASE_USERNAME=postgres +DATABASE_PASSWORD=password + +# JWT +JWT_SECRET=your-secret-key +JWT_EXPIRATION_TIME=1h + +# Server +PORT=3000 +NODE_ENV=development +``` + +### Validation Configuration + +```typescript +// Validation pipe configuration +app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }) +); +``` + +## Monitoring and Logging + +### Logging Strategy + +```typescript +// Structured logging +logger.info('User registered successfully', { + userId: user.id, + walletAddress: user.walletAddress, + role: user.role, + timestamp: new Date().toISOString() +}); +``` + +### Metrics Collection + +- **Registration Success Rate**: Track successful vs failed registrations +- **Validation Error Rates**: Monitor validation failure patterns +- **Response Times**: Track API endpoint performance +- **Error Patterns**: Identify common error scenarios + +### Health Checks + +```typescript +// Health check endpoint +@Get('health') +async healthCheck() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + database: await this.checkDatabaseConnection() + }; +} +``` + +## Deployment Considerations + +### Environment Setup + +1. **Development**: Local database, debug logging +2. **Staging**: Staging database, info logging +3. **Production**: Production database, error logging only + +### Database Migration Strategy + +1. **Backup**: Create database backup before migration +2. **Test**: Run migration on staging environment first +3. **Deploy**: Deploy to production during maintenance window +4. **Verify**: Confirm data integrity after migration + +### Rollback Plan + +1. **Database Rollback**: Revert migration if issues arise +2. **Code Rollback**: Deploy previous version if needed +3. **Data Recovery**: Restore from backup if necessary + +## Conclusion + +This technical specification provides a comprehensive overview of the user registration system implementation. The system is designed with: + +- **Robust validation** at multiple layers +- **Secure authentication** and authorization +- **Scalable database design** with proper indexing +- **Comprehensive testing** strategy +- **Production-ready** error handling and logging +- **Clear documentation** for maintenance and extension + +The implementation follows NestJS best practices and provides a solid foundation for future enhancements. diff --git a/docs/UUID-MIGRATION.md b/docs/UUID-MIGRATION.md new file mode 100644 index 0000000..89fc8d8 --- /dev/null +++ b/docs/UUID-MIGRATION.md @@ -0,0 +1,224 @@ +# UUID Migration and walletAddress as Primary Identifier + +## Overview + +This document outlines the migration from numeric IDs to UUIDs and the transition to using `walletAddress` as the primary identifier for all public API interactions. + +## Security Benefits + +- **Prevents ID enumeration attacks**: UUIDs are not sequential and cannot be easily guessed +- **Eliminates scraping vulnerabilities**: Public endpoints no longer expose internal database IDs +- **Enhanced privacy**: Users are identified by their blockchain wallet address instead of arbitrary numbers + +## Changes Made + +### 1. Database Schema Changes + +#### User Table +- `id` column changed from `SERIAL` to `UUID` with auto-generation +- `walletAddress` column now has a unique index for performance +- Foreign key relationships updated to use UUID + +#### Related Tables +- `user_roles.userId` โ†’ `UUID` +- `buyer_requests.userId` โ†’ `UUID` +- `reviews.userId` โ†’ `UUID` +- `carts.user_id` โ†’ Already `UUID` (compatible) +- `orders.user_id` โ†’ Already `UUID` (compatible) + +### 2. API Endpoint Changes + +#### Before (using numeric ID) +``` +PUT /users/update/:id +GET /users/:id +``` + +#### After (using walletAddress) +``` +PUT /users/update/:walletAddress +GET /users/:walletAddress +``` + +### 3. Entity Updates + +#### User Entity +```typescript +@Entity('users') +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; // Now UUID + + @Column({ unique: true }) + @Index() + walletAddress: string; // Primary identifier for API +} +``` + +#### Related Entities +- `UserRole.userId`: `string` (UUID) +- `BuyerRequest.userId`: `string` (UUID) +- `Review.userId`: `string` (UUID) + +### 4. Service Layer Changes + +#### UserService +- `updateUser(walletAddress: string, data)` instead of `updateUser(id: string, data)` +- `getUserByWalletAddress(walletAddress: string)` for public operations +- `getUserById(id: string)` retained for internal use only + +#### AuthService +- JWT tokens now include `walletAddress` as primary identifier +- `updateUser(walletAddress: string, data)` method updated +- Role assignment methods updated to use `walletAddress` + +### 5. Controller Updates + +#### UserController +- All public endpoints now use `walletAddress` parameter +- Response objects no longer include `id` field +- Authorization checks use `walletAddress` for user identification + +#### Authentication Flow +- JWT strategy updated to handle both `walletAddress` and `id` (backward compatibility) +- Request objects use `walletAddress` for user identification + +## Migration Process + +### 1. Database Migration +```bash +# Run migrations in order +npm run typeorm migration:run -- -d src/config/database.ts +``` + +### 2. Data Migration +- Existing numeric IDs are converted to UUIDs +- Foreign key relationships are updated +- Data integrity is maintained throughout the process + +### 3. Application Updates +- All services updated to use `walletAddress` as primary identifier +- Controllers updated to handle new parameter structure +- Tests updated to verify new behavior + +## API Response Format + +### Before +```json +{ + "success": true, + "data": { + "id": 123, + "walletAddress": "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890", + "name": "John Doe", + "email": "john@example.com" + } +} +``` + +### After +```json +{ + "success": true, + "data": { + "walletAddress": "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890", + "name": "John Doe", + "email": "john@example.com" + } +} +``` + +## Backward Compatibility + +### JWT Tokens +- Tokens with `id` field continue to work during migration +- New tokens use `walletAddress` as primary identifier +- JWT strategy handles both formats + +### Internal Operations +- `id` field retained for database relationships +- Internal services can still use `getUserById()` method +- External APIs exclusively use `walletAddress` + +## Testing + +### Unit Tests +- `src/modules/users/tests/user-update-api.spec.ts` - Comprehensive API testing +- Verifies all CRUD operations work with `walletAddress` +- Ensures UUID `id` is not exposed in responses + +### Integration Tests +- End-to-end testing of user update flows +- Authentication and authorization verification +- Database migration validation + +## Validation + +### walletAddress Format +- Stellar wallet addresses: `^G[A-Z2-7]{55}$` +- Ethereum addresses: `^0x[a-fA-F0-9]{40}$` +- Format validation in DTOs and services + +### Error Handling +- Invalid `walletAddress` format returns 400 Bad Request +- Duplicate `walletAddress` returns 409 Conflict +- User not found returns 404 Not Found + +## Performance Considerations + +### Indexing +- `walletAddress` column has unique index +- Foreign key relationships optimized for UUID lookups +- Query performance maintained through proper indexing + +### Caching +- JWT tokens include `walletAddress` for fast user resolution +- Database queries optimized for `walletAddress` lookups + +## Security Considerations + +### Access Control +- Users can only access their own profiles using `walletAddress` +- Admin users can access any profile +- Role-based access control maintained + +### Data Exposure +- Internal UUIDs never exposed to clients +- All public endpoints use `walletAddress` identifier +- Sensitive information properly protected + +## Rollback Plan + +### Database Rollback +```bash +# Revert migrations if needed +npm run typeorm migration:revert -- -d src/config/database.ts +``` + +### Application Rollback +- Revert entity changes +- Restore original controller methods +- Update service layer to use numeric IDs + +## Future Enhancements + +### Multi-Chain Support +- Support for different blockchain wallet formats +- Wallet address validation per blockchain type +- Cross-chain user identification + +### Enhanced Security +- Wallet signature verification for critical operations +- Multi-factor authentication integration +- Rate limiting per wallet address + +## Conclusion + +This migration significantly enhances the security posture of the StarShop backend by: + +1. **Eliminating ID enumeration vulnerabilities** +2. **Using blockchain-native identifiers** +3. **Maintaining backward compatibility** +4. **Improving API security** + +The transition to `walletAddress` as the primary identifier aligns with blockchain-first architecture while maintaining all existing functionality. diff --git a/docs/store-system.md b/docs/store-system.md new file mode 100644 index 0000000..f1e360b --- /dev/null +++ b/docs/store-system.md @@ -0,0 +1,509 @@ +# StarShop Store System + +## Overview + +The StarShop store system automatically creates a default store for every seller upon registration and supports multiple stores per seller. This system provides a comprehensive store management solution with rich features for store customization and administration. + +## Key Features + +- **๐Ÿ”„ Automatic Store Creation**: Default store created automatically when seller registers +- **๐Ÿช Multiple Stores**: Sellers can create and manage multiple stores +- **๐Ÿ“Š Rich Store Data**: Comprehensive store information including contact, address, policies +- **๐Ÿ” Advanced Search**: Search stores by name, category, and location +- **๐Ÿ›ก๏ธ Role-Based Access**: Only sellers can create and manage stores +- **๐Ÿ“ˆ Store Statistics**: Performance metrics and analytics +- **โœ… Admin Controls**: Store approval and status management + +## Architecture + +### Store Entity + +```typescript +@Entity('stores') +export class Store { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ nullable: true }) + logo?: string; + + @Column({ nullable: true }) + banner?: string; + + @Column({ type: 'jsonb', nullable: true }) + contactInfo?: ContactInfo; + + @Column({ type: 'jsonb', nullable: true }) + address?: Address; + + @Column({ type: 'jsonb', nullable: true }) + businessHours?: BusinessHours; + + @Column({ type: 'jsonb', nullable: true }) + categories?: string[]; + + @Column({ type: 'jsonb', nullable: true }) + tags?: string[]; + + @Column({ type: 'decimal', precision: 3, scale: 2, nullable: true }) + rating?: number; + + @Column({ type: 'int', default: 0 }) + reviewCount: number; + + @Column({ type: 'jsonb', nullable: true }) + policies?: Policies; + + @Column({ type: 'jsonb', nullable: true }) + settings?: StoreSettings; + + @Column({ + type: 'enum', + enum: StoreStatus, + default: StoreStatus.PENDING_APPROVAL, + }) + status: StoreStatus; + + @Column({ type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ type: 'boolean', default: false }) + isFeatured: boolean; + + @ManyToOne(() => User, (user) => user.stores) + @JoinColumn({ name: 'seller_id' }) + seller: User; + + @Column() + sellerId: number; +} +``` + +### Store Status Enum + +```typescript +export enum StoreStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + SUSPENDED = 'suspended', + PENDING_APPROVAL = 'pending_approval', +} +``` + +## Automatic Store Creation + +### How It Works + +When a seller registers, the system automatically: + +1. **Creates the user account** with seller role +2. **Generates a default store** using seller data +3. **Links the store** to the seller account +4. **Sets initial status** to `PENDING_APPROVAL` + +### Default Store Generation + +```typescript +async createDefaultStore(sellerId: number, sellerData: any): Promise { + const seller = await this.userRepository.findOne({ + where: { id: sellerId }, + relations: ['userRoles'], + }); + + // Create default store based on seller data + const defaultStore = this.storeRepository.create({ + name: `${seller.name || 'My Store'}'s Store`, + description: sellerData?.businessDescription || 'Welcome to my store!', + categories: sellerData?.categories || [], + contactInfo: { + email: seller.email, + phone: sellerData?.phone, + website: sellerData?.website, + }, + address: { + city: seller.location, + country: seller.country, + }, + sellerId, + status: StoreStatus.PENDING_APPROVAL, + }); + + return await this.storeRepository.save(defaultStore); +} +``` + +### Integration with User Registration + +```typescript +// In AuthService.registerWithWallet() +// Create default store for sellers +if (data.role === 'seller') { + try { + await this.storeService.createDefaultStore(savedUser.id, data.sellerData); + } catch (error) { + console.error('Failed to create default store for seller:', error); + // Don't fail the registration if store creation fails + } +} +``` + +## Store Management + +### Creating Additional Stores + +Sellers can create multiple stores using the API: + +```bash +POST /api/v1/stores +Authorization: Bearer +Content-Type: application/json + +{ + "name": "My Second Store", + "description": "A specialized store for electronics", + "categories": ["electronics", "computers"], + "contactInfo": { + "phone": "+1234567890", + "email": "store2@example.com", + "website": "https://store2.example.com" + }, + "address": { + "street": "456 Tech Ave", + "city": "Tech City", + "state": "CA", + "country": "United States", + "postalCode": "90210" + } +} +``` + +### Store Operations + +| Operation | Endpoint | Method | Auth Required | Role Required | +|-----------|----------|--------|---------------|---------------| +| Create Store | `/stores` | POST | โœ… | Seller | +| Get My Stores | `/stores/my-stores` | GET | โœ… | Seller | +| Get Store | `/stores/:id` | GET | โŒ | None | +| Update Store | `/stores/:id` | PUT | โœ… | Seller | +| Delete Store | `/stores/:id` | DELETE | โœ… | Seller | +| Search Stores | `/stores/search` | GET | โŒ | None | +| Store Stats | `/stores/:id/stats` | GET | โœ… | Seller | +| Update Status | `/stores/:id/status` | PUT | โœ… | Admin | + +## Store Data Structure + +### Contact Information + +```typescript +interface ContactInfo { + phone?: string; + email?: string; + website?: string; + socialMedia?: { + facebook?: string; + twitter?: string; + instagram?: string; + linkedin?: string; + }; +} +``` + +### Address Information + +```typescript +interface Address { + street?: string; + city?: string; + state?: string; + country?: string; + postalCode?: string; + coordinates?: { + latitude?: number; + longitude?: number; + }; +} +``` + +### Business Hours + +```typescript +interface BusinessHours { + monday?: { open: string; close: string; closed: boolean }; + tuesday?: { open: string; close: string; closed: boolean }; + wednesday?: { open: string; close: string; closed: boolean }; + thursday?: { open: string; close: string; closed: boolean }; + friday?: { open: string; close: string; closed: boolean }; + saturday?: { open: string; close: string; closed: boolean }; + sunday?: { open: string; close: string; closed: boolean }; +} +``` + +### Store Policies + +```typescript +interface Policies { + returnPolicy?: string; + shippingPolicy?: string; + privacyPolicy?: string; + termsOfService?: string; +} +``` + +### Store Settings + +```typescript +interface StoreSettings { + autoApproveReviews?: boolean; + emailNotifications?: boolean; + smsNotifications?: boolean; + pushNotifications?: boolean; +} +``` + +## API Endpoints + +### Store Creation + +**Endpoint:** `POST /api/v1/stores` + +**Request Body:** +```json +{ + "name": "Store Name", + "description": "Store description", + "categories": ["category1", "category2"], + "contactInfo": { + "phone": "+1234567890", + "email": "store@example.com" + }, + "address": { + "city": "City Name", + "country": "Country Name" + } +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": 1, + "name": "Store Name", + "description": "Store description", + "status": "pending_approval", + "sellerId": 123, + "createdAt": "2024-01-01T00:00:00.000Z" + } +} +``` + +### Get Seller's Stores + +**Endpoint:** `GET /api/v1/stores/my-stores` + +**Response:** +```json +{ + "success": true, + "data": [ + { + "id": 1, + "name": "My First Store", + "status": "active", + "rating": 4.5, + "reviewCount": 25 + }, + { + "id": 2, + "name": "My Second Store", + "status": "pending_approval", + "rating": null, + "reviewCount": 0 + } + ] +} +``` + +### Store Search + +**Endpoint:** `GET /api/v1/stores/search?q=electronics&category=tech&location=New York` + +**Response:** +```json +{ + "success": true, + "data": [ + { + "id": 1, + "name": "Tech Store", + "categories": ["electronics", "tech"], + "address": { + "city": "New York", + "country": "United States" + }, + "rating": 4.8, + "status": "active" + } + ] +} +``` + +## Database Schema + +### Stores Table + +```sql +CREATE TABLE stores ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + description TEXT, + logo VARCHAR, + banner VARCHAR, + contactInfo JSONB, + address JSONB, + businessHours JSONB, + categories JSONB, + tags JSONB, + rating DECIMAL(3,2), + reviewCount INTEGER DEFAULT 0, + policies JSONB, + settings JSONB, + status VARCHAR CHECK (status IN ('active', 'inactive', 'suspended', 'pending_approval')) DEFAULT 'pending_approval', + isVerified BOOLEAN DEFAULT FALSE, + isFeatured BOOLEAN DEFAULT FALSE, + verifiedAt TIMESTAMP, + featuredAt TIMESTAMP, + sellerId INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + createdAt TIMESTAMP DEFAULT NOW(), + updatedAt TIMESTAMP DEFAULT NOW() +); +``` + +### Indexes + +```sql +-- Performance indexes +CREATE INDEX idx_stores_seller_id ON stores(sellerId); +CREATE INDEX idx_stores_status ON stores(status); +CREATE INDEX idx_stores_categories ON stores USING GIN(categories); +CREATE INDEX idx_stores_tags ON stores USING GIN(tags); +CREATE INDEX idx_stores_rating ON stores(rating); +CREATE INDEX idx_stores_created_at ON stores(createdAt); +``` + +## Store Lifecycle + +### 1. Registration Phase +- Seller registers with `role: 'seller'` +- System automatically creates default store +- Store status: `PENDING_APPROVAL` + +### 2. Approval Phase +- Admin reviews store information +- Admin can approve, reject, or request changes +- Store status changes to `ACTIVE` upon approval + +### 3. Active Phase +- Store is visible to customers +- Can receive orders and reviews +- Seller can manage store settings + +### 4. Management Phase +- Seller can create additional stores +- Update store information +- Manage store policies and settings + +## Security & Access Control + +### Role-Based Permissions + +- **Sellers**: Can create, read, update, delete their own stores +- **Buyers**: Can only view active stores +- **Admins**: Can manage all stores, update statuses + +### Data Validation + +- All store data is validated using DTOs +- Input sanitization prevents XSS attacks +- SQL injection protection via TypeORM + +### Store Ownership + +- Sellers can only access their own stores +- Store operations are restricted by `sellerId` +- Admin operations require admin role verification + +## Performance Considerations + +### Database Optimization + +- **Indexing**: Strategic indexes on frequently queried fields +- **JSONB**: Efficient storage and querying of flexible data +- **Pagination**: Support for large store datasets + +### Caching Strategy + +- **Store Data**: Cache frequently accessed store information +- **Search Results**: Cache search queries and results +- **Store Lists**: Cache store listings with TTL + +### Query Optimization + +- **Selective Loading**: Load only required fields +- **Relationship Loading**: Efficient loading of seller data +- **Search Optimization**: Full-text search capabilities + +## Monitoring & Analytics + +### Store Metrics + +- **Registration Rate**: New stores created per day +- **Approval Rate**: Stores approved vs pending +- **Performance**: Store rating and review trends +- **Growth**: Multiple store adoption rate + +### Health Checks + +- **Store Status**: Monitor store approval workflow +- **Data Integrity**: Verify store-seller relationships +- **Performance**: Track store search and retrieval times + +## Future Enhancements + +### Planned Features + +1. **Store Templates**: Pre-built store configurations +2. **Advanced Analytics**: Detailed store performance metrics +3. **Store Verification**: Enhanced verification process +4. **Multi-language Support**: International store localization +5. **Store Networks**: Store chain management +6. **Automated Approval**: AI-powered store review system + +### Integration Opportunities + +- **Payment Systems**: Store-specific payment processing +- **Inventory Management**: Product catalog integration +- **Customer Reviews**: Enhanced review and rating system +- **Marketing Tools**: Store promotion and advertising +- **Analytics Dashboard**: Comprehensive store insights + +## Conclusion + +The StarShop store system provides a robust foundation for multi-store seller management with: + +- โœ… **Automatic store creation** for new sellers +- โœ… **Multiple store support** per seller +- โœ… **Comprehensive store data** management +- โœ… **Advanced search and filtering** capabilities +- โœ… **Role-based access control** and security +- โœ… **Performance optimization** and scalability +- โœ… **Extensible architecture** for future enhancements + +This system enables sellers to establish their online presence immediately upon registration while maintaining the flexibility to expand their business with multiple specialized stores. diff --git a/docs/user-registration.md b/docs/user-registration.md new file mode 100644 index 0000000..927de52 --- /dev/null +++ b/docs/user-registration.md @@ -0,0 +1,142 @@ +# User Registration API + +## Overview +The user registration endpoint allows users to register as either a buyer or seller with additional profile information including location, country, and role-specific data. + +## Endpoint +`POST /users` + +## Request Body + +### Required Fields +- `walletAddress` (string): Stellar wallet address (must start with G and be 56 characters long) +- `role` (string): User role - must be either "buyer" or "seller" + +### Optional Fields +- `name` (string): User display name +- `email` (string): User email address +- `location` (string): User location (e.g., "New York") +- `country` (string): User country (e.g., "United States") +- `buyerData` (object): Buyer-specific data (only allowed if role is "buyer") +- `sellerData` (object): Seller-specific data (only allowed if role is "seller") + +### Validation Rules +- **Buyers**: Can only have `buyerData`, cannot have `sellerData` +- **Sellers**: Can only have `sellerData`, cannot have `buyerData` +- **buyerData**: Required for buyer role, must be an object +- **sellerData**: Required for seller role, must be an object +- **Cross-role data**: Will cause the entire request to be rejected with a 400 error + +## Examples + +### Register as a Buyer +```json +{ + "walletAddress": "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890", + "role": "buyer", + "name": "John Doe", + "email": "john.doe@example.com", + "location": "New York", + "country": "United States", + "buyerData": {} +} +``` + +### Register as a Seller +```json +{ + "walletAddress": "GXYZABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890", + "role": "seller", + "name": "Jane Smith", + "email": "jane.smith@example.com", + "location": "Los Angeles", + "country": "United States", + "sellerData": { + "businessName": "Tech Store", + "categories": ["electronics", "computers"], + "rating": 4.5, + "businessAddress": "456 Tech Ave, Los Angeles, CA 90210" + } +} +``` + +## Response + +### Success Response (201 Created) +```json +{ + "success": true, + "data": { + "user": { + "id": 1, + "walletAddress": "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890", + "name": "John Doe", + "email": "john.doe@example.com", + "role": "buyer", + "location": "New York", + "country": "United States", + "buyerData": {}, + "sellerData": null + }, + "expiresIn": 3600 + } +} +``` + +### Error Responses + +#### 400 Bad Request - Invalid Wallet Address +```json +{ + "success": false, + "message": "Invalid Stellar wallet address format" +} +``` + +#### 400 Bad Request - Missing Required Data +```json +{ + "success": false, + "message": "Buyer data is required for buyer role" +} +``` + +#### 400 Bad Request - Invalid Role Data +```json +{ + "success": false, + "message": "buyerData is only allowed for buyers" +} +``` + +#### 400 Bad Request - Duplicate Wallet Address +```json +{ + "success": false, + "message": "Wallet address already registered" +} +``` + +## Notes + +1. **Role-specific Data Validation**: + - Buyers can only provide `buyerData` (required) + - Sellers can only provide `sellerData` (required) + - Cross-role data is strictly forbidden and will result in validation errors + - **Validation happens at the DTO level** - requests with forbidden data will be rejected entirely + +2. **Authentication**: + - A JWT token is automatically generated and set as an HttpOnly cookie + - The token expires in 1 hour by default + +3. **Database**: + - The role is stored in the user_roles table as before + - Location and country are stored as strings + - Buyer and seller data are stored as JSONB for flexibility + +4. **Validation**: + - Wallet address must be a valid Stellar address format + - Email must be a valid email format + - Role must be either "buyer" or "seller" + - Role-specific data validation prevents data mixing at the DTO level + - All optional fields have reasonable length limits diff --git a/package.json b/package.json index dddd11b..affaef2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:ci": "jest --ci --coverage --watchAll=false", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", "dev": "nest start --watch", "build": "nest build", "start": "nest start", @@ -19,10 +22,9 @@ "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", "format": "prettier --write \"src/**/*.ts\"", - "prepare": "husky install", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "format:check": "prettier --check \"src/**/*.ts\"", + "pre-commit": "npm run lint && npm run format && npm run test:ci", + "prepare": "husky install" }, "lint-staged": { "*.ts": [ @@ -107,6 +109,7 @@ "ts-node": "^10.9.1", "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "zod": "^3.22.4" } } diff --git a/setup-enhanced-rules.sh b/setup-enhanced-rules.sh new file mode 100644 index 0000000..6ff09e7 --- /dev/null +++ b/setup-enhanced-rules.sh @@ -0,0 +1,180 @@ +#!/bin/bash + +# =========================================== +# StarShop Backend Enhanced Rules Setup +# =========================================== +# This script sets up enhanced contribution rules and quality gates + +set -e + +echo "๐Ÿš€ Setting up enhanced contribution rules for StarShop Backend..." + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if we're in the right directory +if [ ! -f "package.json" ]; then + print_error "package.json not found. Please run this script from the project root." + exit 1 +fi + +print_status "Installing required dependencies..." + +# Install Zod for environment validation +npm install zod + +# Install additional dev dependencies if not present +npm install --save-dev jest-watch-typeahead + +print_success "Dependencies installed" + +# Make husky pre-commit hook executable +print_status "Setting up pre-commit hooks..." +chmod +x .husky/pre-commit + +print_success "Pre-commit hooks configured" + +# Create .env file if it doesn't exist +if [ ! -f ".env" ]; then + print_status "Creating .env file from template..." + cp .env.example .env 2>/dev/null || print_warning "Could not copy .env.example (file may not exist)" + print_success ".env file created" +else + print_warning ".env file already exists, skipping creation" +fi + +# Create .env.test file if it doesn't exist +if [ ! -f ".env.test" ]; then + print_status "Creating .env.test file..." + cat > .env.test << 'EOF' +# Test Environment Configuration +NODE_ENV=test +PORT=3001 +DB_HOST=localhost +DB_PORT=5432 +DB_USERNAME=test_user +DB_PASSWORD=test_password +DB_DATABASE=starshop_test +DB_SSL=false +JWT_SECRET=test-jwt-secret-key-for-testing-only +JWT_EXPIRATION_TIME=1h +AWS_ACCESS_KEY_ID=test_access_key +AWS_SECRET_ACCESS_KEY=test_secret_key +AWS_REGION=us-east-1 +AWS_BUCKET_NAME=test_bucket +CLOUDINARY_CLOUD_NAME=test_cloud +CLOUDINARY_API_KEY=test_api_key +CLOUDINARY_API_SECRET=test_api_secret +SUPABASE_URL=https://test-project.supabase.co +SUPABASE_SERVICE_ROLE_KEY=test_service_role_key +PUSHER_APP_ID=test_app_id +PUSHER_KEY=test_key +PUSHER_SECRET=test_secret +PUSHER_CLUSTER=us2 +EOF + print_success ".env.test file created" +else + print_warning ".env.test file already exists, skipping creation" +fi + +# Update jest configuration +print_status "Updating Jest configuration..." +if [ -f "jest.config.js" ]; then + cp jest.config.js jest.config.js.backup + print_status "Backed up existing jest.config.js" +fi + +# Copy enhanced jest config +cp jest.config.enhanced.js jest.config.js +print_success "Jest configuration updated" + +# Run initial linting and formatting +print_status "Running initial code quality checks..." + +# Check if there are any linting issues +if npm run lint > /dev/null 2>&1; then + print_success "Linting passed" +else + print_warning "Linting issues found. Run 'npm run lint:fix' to fix them." +fi + +# Check formatting +if npm run format:check > /dev/null 2>&1; then + print_success "Code formatting is correct" +else + print_warning "Code formatting issues found. Run 'npm run format' to fix them." +fi + +# Check for direct process.env usage +print_status "Checking for direct process.env usage..." +if grep -r "process\.env\." src/ --exclude-dir=node_modules --exclude="*.spec.ts" --exclude="*.test.ts" > /dev/null 2>&1; then + print_warning "Direct process.env usage detected. Please update to use centralized config." + echo "Files with direct process.env usage:" + grep -r "process\.env\." src/ --exclude-dir=node_modules --exclude="*.spec.ts" --exclude="*.test.ts" -l +else + print_success "No direct process.env usage found" +fi + +# Check for console.log statements +print_status "Checking for console.log statements..." +if grep -r "console\.log" src/ --exclude-dir=node_modules --exclude="*.spec.ts" --exclude="*.test.ts" > /dev/null 2>&1; then + print_warning "console.log statements found. Please remove them before committing." + echo "Files with console.log statements:" + grep -r "console\.log" src/ --exclude-dir=node_modules --exclude="*.spec.ts" --exclude="*.test.ts" -l +else + print_success "No console.log statements found" +fi + +# Run tests +print_status "Running tests..." +if npm run test:ci > /dev/null 2>&1; then + print_success "All tests passed" +else + print_warning "Some tests failed. Please check the test output." +fi + +print_success "Enhanced rules setup completed!" + +echo "" +echo "๐Ÿ“‹ Next Steps:" +echo "1. Review and update your .env file with actual values" +echo "2. Run 'npm run lint:fix' to fix any linting issues" +echo "3. Run 'npm run format' to format your code" +echo "4. Run 'npm run test:ci' to ensure all tests pass" +echo "5. Update any direct process.env usage to use centralized config" +echo "6. Remove any console.log statements" +echo "" +echo "๐Ÿ”ง Available Commands:" +echo "- npm run lint # Check code quality" +echo "- npm run lint:fix # Fix linting issues" +echo "- npm run format # Format code" +echo "- npm run format:check # Check formatting" +echo "- npm run test:ci # Run tests with coverage" +echo "- npm run pre-commit # Run all pre-commit checks" +echo "" +echo "๐Ÿ“š Documentation:" +echo "- CONTRIBUTION_RULES_ENHANCED.md - Enhanced contribution rules" +echo "- .cursorrules - Cursor AI specific rules" +echo "" +print_success "Setup complete! Happy coding! ๐ŸŽ‰" diff --git a/src/app.module.ts b/src/app.module.ts index ccb6823..68bc3f6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,8 @@ import { OrdersModule } from './modules/orders/orders.module'; import { BuyerRequestsModule } from './modules/buyer-requests/buyer-requests.module'; import { OffersModule } from './modules/offers/offers.module'; import { SupabaseModule } from './modules/supabase/supabase.module'; +import { AppCacheModule } from './cache/cache.module'; +import { StoresModule } from './modules/stores/stores.module'; // Entities import { User } from './modules/users/entities/user.entity'; @@ -36,6 +38,7 @@ import { CouponUsage } from './modules/coupons/entities/coupon-usage.entity'; import { BuyerRequest } from './modules/buyer-requests/entities/buyer-request.entity'; import { Offer } from './modules/offers/entities/offer.entity'; import { OfferAttachment } from './modules/offers/entities/offer-attachment.entity'; +import { Store } from './modules/stores/entities/store.entity'; import { Escrow } from './modules/escrows/entities/escrow.entity'; import { Milestone } from './modules/escrows/entities/milestone.entity'; import { EscrowsModule } from './modules/escrows/escrows.module'; @@ -44,6 +47,7 @@ import { EscrowsModule } from './modules/escrows/escrows.module'; imports: [ ConfigModule.forRoot({ isGlobal: true }), ScheduleModule.forRoot(), + AppCacheModule, TypeOrmModule.forRoot({ type: 'postgres', url: process.env.DATABASE_URL, @@ -66,10 +70,11 @@ import { EscrowsModule } from './modules/escrows/escrows.module'; BuyerRequest, Offer, OfferAttachment, + Store, Escrow, Milestone, ], - synchronize: process.env.NODE_ENV !== 'production', + synchronize: false, logging: process.env.NODE_ENV === 'development', }), SharedModule, @@ -86,7 +91,8 @@ import { EscrowsModule } from './modules/escrows/escrows.module'; BuyerRequestsModule, OffersModule, SupabaseModule, - EscrowsModule, + StoresModule, + EscrowsModule, ], }) -export class AppModule {} +export class AppModule { } diff --git a/src/cache/cache.module.ts b/src/cache/cache.module.ts new file mode 100644 index 0000000..829a397 --- /dev/null +++ b/src/cache/cache.module.ts @@ -0,0 +1,41 @@ +import { Module } from '@nestjs/common'; +import { CacheModule } from '@nestjs/cache-manager'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import * as redisStore from 'cache-manager-redis-yet'; +import { CacheService } from './cache.service'; +import { CacheController } from './controllers/cache.controller'; + +@Module({ + imports: [ + CacheModule.registerAsync({ + isGlobal: true, + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => { + const redisUrl = configService.get('REDIS_URL'); + const ttl = parseInt(configService.get('CACHE_TTL_SECONDS') ?? '60', 10); + const prefix = configService.get('CACHE_PREFIX') ?? 'app:'; + + if (!redisUrl) { + throw new Error('REDIS_URL environment variable is required for caching'); + } + + return { + store: redisStore, + url: redisUrl, + ttl, + prefix, + retryStrategy: (times: number) => { + const delay = Math.min(times * 50, 2000); + return delay; + }, + maxRetriesPerRequest: 3, + }; + }, + inject: [ConfigService], + }), + ], + controllers: [CacheController], + providers: [CacheService], + exports: [CacheModule, CacheService], +}) +export class AppCacheModule {} diff --git a/src/cache/cache.service.ts b/src/cache/cache.service.ts new file mode 100644 index 0000000..9959c17 --- /dev/null +++ b/src/cache/cache.service.ts @@ -0,0 +1,170 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; + +@Injectable() +export class CacheService { + private readonly logger = new Logger(CacheService.name); + private readonly prefix: string; + private readonly debugMode: boolean; + + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + private configService: ConfigService, + ) { + this.prefix = this.configService.get('CACHE_PREFIX') ?? 'app:'; + this.debugMode = this.configService.get('CACHE_DEBUG') ?? false; + } + + /** + * Generate a cache key with proper naming convention + */ + private generateKey(entity: string, action: string, params?: Record): string { + const baseKey = `${this.prefix}${entity}:${action}`; + + if (!params || Object.keys(params).length === 0) { + return baseKey; + } + + // Create a hash of the parameters to ensure consistent key generation + const paramsString = JSON.stringify(params); + const hash = crypto.createHash('md5').update(paramsString).digest('hex'); + + return `${baseKey}:${hash}`; + } + + /** + * Get data from cache + */ + async get(entity: string, action: string, params?: Record): Promise { + const key = this.generateKey(entity, action, params); + + try { + const data = await this.cacheManager.get(key); + + if (this.debugMode) { + if (data) { + this.logger.debug(`Cache HIT: ${key}`); + } else { + this.logger.debug(`Cache MISS: ${key}`); + } + } + + return data; + } catch (error) { + this.logger.error(`Cache get error for key ${key}:`, error); + return null; + } + } + + /** + * Set data in cache with custom TTL + */ + async set( + entity: string, + action: string, + data: T, + ttl?: number, + params?: Record + ): Promise { + const key = this.generateKey(entity, action, params); + + try { + await this.cacheManager.set(key, data, ttl); + + if (this.debugMode) { + this.logger.debug(`Cache SET: ${key} (TTL: ${ttl}s)`); + } + } catch (error) { + this.logger.error(`Cache set error for key ${key}:`, error); + } + } + + /** + * Delete specific cache entry + */ + async delete(entity: string, action: string, params?: Record): Promise { + const key = this.generateKey(entity, action, params); + + try { + await this.cacheManager.del(key); + + if (this.debugMode) { + this.logger.debug(`Cache DELETE: ${key}`); + } + } catch (error) { + this.logger.error(`Cache delete error for key ${key}:`, error); + } + } + + /** + * Invalidate all cache entries for an entity + */ + async invalidateEntity(entity: string): Promise { + try { + // Note: This is a simplified approach. In production, you might want to use + // Redis SCAN command to find and delete all keys with the entity prefix + const pattern = `${this.prefix}${entity}:*`; + + if (this.debugMode) { + this.logger.debug(`Cache INVALIDATE ENTITY: ${entity} (pattern: ${pattern})`); + } + + // For now, we'll rely on TTL expiration. In a more sophisticated setup, + // you could implement pattern-based deletion using Redis SCAN + } catch (error) { + this.logger.error(`Cache invalidate entity error for ${entity}:`, error); + } + } + + /** + * Invalidate cache entries for a specific action on an entity + */ + async invalidateAction(entity: string, action: string): Promise { + try { + const pattern = `${this.prefix}${entity}:${action}:*`; + + if (this.debugMode) { + this.logger.debug(`Cache INVALIDATE ACTION: ${entity}:${action} (pattern: ${pattern})`); + } + + // Similar to invalidateEntity, this would use Redis SCAN in production + } catch (error) { + this.logger.error(`Cache invalidate action error for ${entity}:${action}:`, error); + } + } + + /** + * Clear entire cache + */ + async reset(): Promise { + try { + await this.cacheManager.reset(); + + if (this.debugMode) { + this.logger.debug('Cache RESET: All cache cleared'); + } + } catch (error) { + this.logger.error('Cache reset error:', error); + } + } + + /** + * Get cache statistics (if available) + */ + async getStats(): Promise> { + try { + // This would return Redis INFO command results in production + return { + prefix: this.prefix, + debugMode: this.debugMode, + timestamp: new Date().toISOString(), + }; + } catch (error) { + this.logger.error('Cache stats error:', error); + return {}; + } + } +} diff --git a/src/cache/controllers/cache.controller.ts b/src/cache/controllers/cache.controller.ts new file mode 100644 index 0000000..046519e --- /dev/null +++ b/src/cache/controllers/cache.controller.ts @@ -0,0 +1,56 @@ +import { Controller, Post, Get, Delete, UseGuards, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { CacheService } from '../cache.service'; +import { AuthGuard } from '../../modules/shared/guards/auth.guard'; +import { RolesGuard } from '../../modules/shared/guards/roles.guard'; +import { Roles } from '../../modules/shared/decorators/roles.decorator'; + +@ApiTags('Cache Management') +@Controller('cache') +@UseGuards(AuthGuard, RolesGuard) +@ApiBearerAuth() +export class CacheController { + constructor(private readonly cacheService: CacheService) {} + + @Get('stats') + @Roles('admin') + @ApiOperation({ summary: 'Get cache statistics' }) + @ApiResponse({ status: 200, description: 'Cache statistics retrieved successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden - Admin access required' }) + async getStats() { + return await this.cacheService.getStats(); + } + + @Post('reset') + @Roles('admin') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Clear entire cache' }) + @ApiResponse({ status: 200, description: 'Cache cleared successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden - Admin access required' }) + async resetCache() { + await this.cacheService.reset(); + return { message: 'Cache cleared successfully' }; + } + + @Delete('entity/:entity') + @Roles('admin') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Invalidate cache for specific entity' }) + @ApiResponse({ status: 200, description: 'Entity cache invalidated successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden - Admin access required' }) + async invalidateEntity(entity: string) { + await this.cacheService.invalidateEntity(entity); + return { message: `Cache invalidated for entity: ${entity}` }; + } + + @Delete('entity/:entity/action/:action') + @Roles('admin') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Invalidate cache for specific entity action' }) + @ApiResponse({ status: 200, description: 'Entity action cache invalidated successfully' }) + @ApiResponse({ status: 403, description: 'Forbidden - Admin access required' }) + async invalidateAction(entity: string, action: string) { + await this.cacheService.invalidateAction(entity, action); + return { message: `Cache invalidated for entity: ${entity}, action: ${action}` }; + } +} diff --git a/src/cache/decorators/cache.decorator.ts b/src/cache/decorators/cache.decorator.ts new file mode 100644 index 0000000..a24a170 --- /dev/null +++ b/src/cache/decorators/cache.decorator.ts @@ -0,0 +1,50 @@ +import { SetMetadata } from '@nestjs/common'; + +export const CACHE_KEY_METADATA = 'cache_key_metadata'; +export const CACHE_TTL_METADATA = 'cache_ttl_metadata'; + +export interface CacheOptions { + key: string; + ttl?: number; + entity?: string; + action?: string; +} + +/** + * Decorator to mark a method for caching + */ +export const Cacheable = (options: CacheOptions) => { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + SetMetadata(CACHE_KEY_METADATA, { + key: options.key, + entity: options.entity, + action: options.action, + })(target, propertyKey, descriptor); + + if (options.ttl) { + SetMetadata(CACHE_TTL_METADATA, options.ttl)(target, propertyKey, descriptor); + } + + return descriptor; + }; +}; + +/** + * Decorator to mark a method that should invalidate cache + */ +export const CacheInvalidate = (entity: string, action?: string) => { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + SetMetadata('cache_invalidate', { entity, action })(target, propertyKey, descriptor); + return descriptor; + }; +}; + +/** + * Decorator to mark a method that should clear all cache + */ +export const CacheClear = () => { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + SetMetadata('cache_clear', true)(target, propertyKey, descriptor); + return descriptor; + }; +}; diff --git a/src/cache/index.ts b/src/cache/index.ts new file mode 100644 index 0000000..67bced8 --- /dev/null +++ b/src/cache/index.ts @@ -0,0 +1,4 @@ +export * from './cache.module'; +export * from './cache.service'; +export * from './decorators/cache.decorator'; +export * from './interceptors/cache.interceptor'; diff --git a/src/cache/interceptors/cache.interceptor.ts b/src/cache/interceptors/cache.interceptor.ts new file mode 100644 index 0000000..6703416 --- /dev/null +++ b/src/cache/interceptors/cache.interceptor.ts @@ -0,0 +1,68 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Inject, +} from '@nestjs/common'; +import { Observable, of } from 'rxjs'; +import { tap, map } from 'rxjs/operators'; +import { Reflector } from '@nestjs/core'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { CacheService } from '../cache.service'; +import { CACHE_KEY_METADATA, CACHE_TTL_METADATA } from '../decorators/cache.decorator'; + +@Injectable() +export class CacheInterceptor implements NestInterceptor { + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + private reflector: Reflector, + private cacheService: CacheService, + ) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const request = context.switchToHttp().getRequest(); + const handler = context.getHandler(); + + // Check if method is cacheable + const cacheKeyMetadata = this.reflector.get(CACHE_KEY_METADATA, handler); + const cacheTtlMetadata = this.reflector.get(CACHE_TTL_METADATA, handler); + + if (!cacheKeyMetadata) { + return next.handle(); + } + + // Generate cache key based on method parameters + const cacheKey = this.generateCacheKey(cacheKeyMetadata, request); + + // Try to get from cache first + const cachedData = await this.cacheManager.get(cacheKey); + if (cachedData) { + return of(cachedData); + } + + // If not in cache, execute the method and cache the result + return next.handle().pipe( + tap(async (data) => { + const ttl = cacheTtlMetadata || 60; // Default TTL of 60 seconds + await this.cacheManager.set(cacheKey, data, ttl); + }), + ); + } + + private generateCacheKey(metadata: any, request: any): string { + const { key, entity, action } = metadata; + + // Extract parameters from request + const params = { + query: request.query, + params: request.params, + body: request.body, + user: request.user?.id, // Include user ID if authenticated + }; + + // Use the cache service to generate a proper key + return this.cacheService['generateKey'](entity || 'default', action || key, params); + } +} diff --git a/src/cache/tests/cache.service.spec.ts b/src/cache/tests/cache.service.spec.ts new file mode 100644 index 0000000..a017c40 --- /dev/null +++ b/src/cache/tests/cache.service.spec.ts @@ -0,0 +1,195 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { ConfigService } from '@nestjs/config'; +import { CacheService } from '../cache.service'; + +describe('CacheService', () => { + let service: CacheService; + let mockCacheManager: any; + let mockConfigService: any; + + beforeEach(async () => { + mockCacheManager = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + reset: jest.fn(), + }; + + mockConfigService = { + get: jest.fn((key: string) => { + switch (key) { + case 'CACHE_PREFIX': + return 'test:'; + case 'CACHE_DEBUG': + return false; + default: + return null; + } + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CacheService, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + service = module.get(CacheService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('get', () => { + it('should get data from cache', async () => { + const mockData = { id: 1, name: 'Test Product' }; + mockCacheManager.get.mockResolvedValue(mockData); + + const result = await service.get('product', 'detail', { id: 1 }); + + expect(result).toEqual(mockData); + expect(mockCacheManager.get).toHaveBeenCalledWith('test:product:detail:5d41402abc4b2a76b9719d911017c592'); + }); + + it('should return null when cache miss', async () => { + mockCacheManager.get.mockResolvedValue(null); + + const result = await service.get('product', 'list'); + + expect(result).toBeNull(); + expect(mockCacheManager.get).toHaveBeenCalledWith('test:product:list'); + }); + + it('should handle cache errors gracefully', async () => { + mockCacheManager.get.mockRejectedValue(new Error('Cache error')); + + const result = await service.get('product', 'detail', { id: 1 }); + + expect(result).toBeNull(); + }); + }); + + describe('set', () => { + it('should set data in cache', async () => { + const data = { id: 1, name: 'Test Product' }; + mockCacheManager.set.mockResolvedValue(undefined); + + await service.set('product', 'detail', data, 300, { id: 1 }); + + expect(mockCacheManager.set).toHaveBeenCalledWith( + 'test:product:detail:5d41402abc4b2a76b9719d911017c592', + data, + 300 + ); + }); + + it('should handle cache set errors gracefully', async () => { + const data = { id: 1, name: 'Test Product' }; + mockCacheManager.set.mockRejectedValue(new Error('Cache error')); + + await expect(service.set('product', 'detail', data, 300, { id: 1 })).resolves.not.toThrow(); + }); + }); + + describe('delete', () => { + it('should delete cache entry', async () => { + mockCacheManager.del.mockResolvedValue(undefined); + + await service.delete('product', 'detail', { id: 1 }); + + expect(mockCacheManager.del).toHaveBeenCalledWith('test:product:detail:5d41402abc4b2a76b9719d911017c592'); + }); + + it('should handle cache delete errors gracefully', async () => { + mockCacheManager.del.mockRejectedValue(new Error('Cache error')); + + await expect(service.delete('product', 'detail', { id: 1 })).resolves.not.toThrow(); + }); + }); + + describe('invalidateEntity', () => { + it('should log invalidation attempt', async () => { + const loggerSpy = jest.spyOn(service['logger'], 'debug'); + + await service.invalidateEntity('product'); + + expect(loggerSpy).toHaveBeenCalledWith('Cache INVALIDATE ENTITY: product (pattern: test:product:*)'); + }); + }); + + describe('invalidateAction', () => { + it('should log action invalidation attempt', async () => { + const loggerSpy = jest.spyOn(service['logger'], 'debug'); + + await service.invalidateAction('product', 'list'); + + expect(loggerSpy).toHaveBeenCalledWith('Cache INVALIDATE ACTION: product:list (pattern: test:product:list:*)'); + }); + }); + + describe('reset', () => { + it('should reset entire cache', async () => { + mockCacheManager.reset.mockResolvedValue(undefined); + + await service.reset(); + + expect(mockCacheManager.reset).toHaveBeenCalled(); + }); + + it('should handle cache reset errors gracefully', async () => { + mockCacheManager.reset.mockRejectedValue(new Error('Cache error')); + + await expect(service.reset()).resolves.not.toThrow(); + }); + }); + + describe('getStats', () => { + it('should return cache statistics', async () => { + const stats = await service.getStats(); + + expect(stats).toEqual({ + prefix: 'test:', + debugMode: false, + timestamp: expect.any(String), + }); + }); + }); + + describe('key generation', () => { + it('should generate consistent keys for same parameters', async () => { + const params1 = { category: 1, sort: 'name' }; + const params2 = { category: 1, sort: 'name' }; + + const key1 = service['generateKey']('product', 'list', params1); + const key2 = service['generateKey']('product', 'list', params2); + + expect(key1).toBe(key2); + }); + + it('should generate different keys for different parameters', async () => { + const params1 = { category: 1 }; + const params2 = { category: 2 }; + + const key1 = service['generateKey']('product', 'list', params1); + const key2 = service['generateKey']('product', 'list', params2); + + expect(key1).not.toBe(key2); + }); + + it('should generate simple key when no parameters', async () => { + const key = service['generateKey']('product', 'list'); + + expect(key).toBe('test:product:list'); + }); + }); +}); diff --git a/src/config/index.ts b/src/config/index.ts index 85ac590..4a57a3b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -7,7 +7,7 @@ export const config = { username: process.env.DB_USERNAME || 'postgres', password: process.env.DB_PASSWORD || 'password', name: process.env.DB_DATABASE || 'starshop', - synchronize: process.env.NODE_ENV !== 'production', + synchronize: false, logging: process.env.NODE_ENV !== 'production', ssl: process.env.DB_SSL === 'true', }, diff --git a/src/dtos/UserDTO.ts b/src/dtos/UserDTO.ts index 9e77d87..e4ef13d 100644 --- a/src/dtos/UserDTO.ts +++ b/src/dtos/UserDTO.ts @@ -6,7 +6,55 @@ import { Matches, MinLength, MaxLength, + IsObject, + registerDecorator, + ValidationOptions, + ValidationArguments, } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +// Custom validator to ensure role-specific data rules +function IsRoleSpecificData(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: 'isRoleSpecificData', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + const obj = args.object as any; + const role = obj.role; + + if (propertyName === 'buyerData') { + // buyerData is only allowed for buyers + if (role !== 'buyer' && value !== undefined) { + return false; + } + } + + if (propertyName === 'sellerData') { + // sellerData is only allowed for sellers + if (role !== 'seller' && value !== undefined) { + return false; + } + } + + return true; + }, + defaultMessage(args: ValidationArguments) { + if (args.property === 'buyerData') { + return 'buyerData is only allowed for buyers'; + } + if (args.property === 'sellerData') { + return 'sellerData is only allowed for sellers'; + } + return 'Invalid role-specific data'; + } + } + }); + }; +} export class CreateUserDto { @IsNotEmpty() @@ -25,6 +73,32 @@ export class CreateUserDto { @IsNotEmpty() @IsEnum(['buyer', 'seller', 'admin'], { message: 'Role must be buyer, seller, or admin' }) role: 'buyer' | 'seller' | 'admin'; + + @IsOptional() + @MaxLength(100, { message: 'Location is too long' }) + location?: string; + + @IsOptional() + @MaxLength(100, { message: 'Country is too long' }) + country?: string; + + @ApiPropertyOptional({ + description: 'Buyer-specific data (only allowed if role is buyer)', + example: { preferences: ['electronics', 'books'] }, + }) + @IsRoleSpecificData({ message: 'buyerData is only allowed for buyers' }) + @IsObject({ message: 'Buyer data must be an object' }) + @IsOptional() + buyerData?: any; + + @ApiPropertyOptional({ + description: 'Seller-specific data (only allowed if role is seller)', + example: { businessName: 'Tech Store', categories: ['electronics'] }, + }) + @IsRoleSpecificData({ message: 'sellerData is only allowed for sellers' }) + @IsObject({ message: 'Seller data must be an object' }) + @IsOptional() + sellerData?: any; } export class UpdateUserDto { diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index 84c1c14..63917e2 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -9,7 +9,7 @@ export const authMiddleware = (req: Request, res: Response, next: NextFunction) export const requireRole = (roleName: Role) => { return (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - if (!req.user || !req.user.role.includes(roleName)) { + if (!req.user || !req.user.role.some(role => role === roleName)) { return res.status(403).json({ message: 'Forbidden' }); } next(); diff --git a/src/migrations/1751199237000-AddUserFields.ts b/src/migrations/1751199237000-AddUserFields.ts new file mode 100644 index 0000000..22df2ff --- /dev/null +++ b/src/migrations/1751199237000-AddUserFields.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserFields1751199237000 implements MigrationInterface { + name = 'AddUserFields1751199237000'; + + public async up(queryRunner: QueryRunner): Promise { + // Add new columns to users table + await queryRunner.query(` + ALTER TABLE "users" + ADD COLUMN "location" character varying, + ADD COLUMN "country" character varying, + ADD COLUMN "buyerData" jsonb, + ADD COLUMN "sellerData" jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove the columns + await queryRunner.query(` + ALTER TABLE "users" + DROP COLUMN "location", + DROP COLUMN "country", + DROP COLUMN "buyerData", + DROP COLUMN "sellerData" + `); + } +} diff --git a/src/migrations/1751199237000-MigrateUserIdToUUID.ts b/src/migrations/1751199237000-MigrateUserIdToUUID.ts new file mode 100644 index 0000000..0e36d3a --- /dev/null +++ b/src/migrations/1751199237000-MigrateUserIdToUUID.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MigrateUserIdToUUID1751199237000 implements MigrationInterface { + name = 'MigrateUserIdToUUID1751199237000'; + + public async up(queryRunner: QueryRunner): Promise { + // First, add a new UUID column + await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "id_new" UUID DEFAULT gen_random_uuid()`); + + // Update existing records to have unique UUIDs + await queryRunner.query(`UPDATE "users" SET "id_new" = gen_random_uuid() WHERE "id_new" IS NULL`); + + // Drop the old id column and rename the new one + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "id_new" TO "id"`); + + // Make the new id column the primary key + await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id")`); + + // Ensure walletAddress is unique and indexed + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_users_walletAddress" ON "users" ("walletAddress")`); + + // Update related tables that reference user id + // Note: This migration assumes other tables will be updated separately + // to use UUID foreign keys + } + + public async down(queryRunner: QueryRunner): Promise { + // Revert to SERIAL id + await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "id_old" SERIAL`); + + // Drop the UUID primary key constraint + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433"`); + + // Rename columns + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "id"`); + await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "id_old" TO "id"`); + + // Restore the SERIAL primary key + await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id")`); + + // Drop the walletAddress index + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_users_walletAddress"`); + } +} diff --git a/src/migrations/1751199238000-CreateStoresTable.ts b/src/migrations/1751199238000-CreateStoresTable.ts new file mode 100644 index 0000000..15de254 --- /dev/null +++ b/src/migrations/1751199238000-CreateStoresTable.ts @@ -0,0 +1,217 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +export class CreateStoresTable1751199238000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create stores table + await queryRunner.createTable( + new Table({ + name: 'stores', + columns: [ + { + name: 'id', + type: 'serial', + isPrimary: true, + isGenerated: true, + generationStrategy: 'increment', + }, + { + name: 'name', + type: 'varchar', + isNullable: false, + }, + { + name: 'description', + type: 'text', + isNullable: true, + }, + { + name: 'logo', + type: 'varchar', + isNullable: true, + }, + { + name: 'banner', + type: 'varchar', + isNullable: true, + }, + { + name: 'contactInfo', + type: 'jsonb', + isNullable: true, + }, + { + name: 'address', + type: 'jsonb', + isNullable: true, + }, + { + name: 'businessHours', + type: 'jsonb', + isNullable: true, + }, + { + name: 'categories', + type: 'jsonb', + isNullable: true, + }, + { + name: 'tags', + type: 'jsonb', + isNullable: true, + }, + { + name: 'rating', + type: 'decimal', + precision: 3, + scale: 2, + isNullable: true, + }, + { + name: 'reviewCount', + type: 'integer', + default: 0, + isNullable: false, + }, + { + name: 'policies', + type: 'jsonb', + isNullable: true, + }, + { + name: 'settings', + type: 'jsonb', + isNullable: true, + }, + { + name: 'status', + type: 'enum', + enum: ['active', 'inactive', 'suspended', 'pending_approval'], + default: "'pending_approval'", + isNullable: false, + }, + { + name: 'isVerified', + type: 'boolean', + default: false, + isNullable: false, + }, + { + name: 'isFeatured', + type: 'boolean', + default: false, + isNullable: false, + }, + { + name: 'verifiedAt', + type: 'timestamp', + isNullable: true, + }, + { + name: 'featuredAt', + type: 'timestamp', + isNullable: true, + }, + { + name: 'sellerId', + type: 'integer', + isNullable: false, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'now()', + isNullable: false, + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'now()', + isNullable: false, + }, + ], + }), + true + ); + + // Create foreign key for seller relationship + await queryRunner.createForeignKey( + 'stores', + new TableForeignKey({ + columnNames: ['sellerId'], + referencedColumnNames: ['id'], + referencedTableName: 'users', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }) + ); + + // Create indexes for better performance + await queryRunner.createIndex( + 'stores', + new TableIndex({ + name: 'IDX_STORES_SELLER_ID', + columnNames: ['sellerId'], + }) + ); + + await queryRunner.createIndex( + 'stores', + new TableIndex({ + name: 'IDX_STORES_STATUS', + columnNames: ['status'], + }) + ); + + await queryRunner.createIndex( + 'stores', + new TableIndex({ + name: 'IDX_STORES_CATEGORIES', + columnNames: ['categories'], + }) + ); + + await queryRunner.createIndex( + 'stores', + new TableIndex({ + name: 'IDX_STORES_TAGS', + columnNames: ['tags'], + }) + ); + + await queryRunner.createIndex( + 'stores', + new TableIndex({ + name: 'IDX_STORES_RATING', + columnNames: ['rating'], + }) + ); + + await queryRunner.createIndex( + 'stores', + new TableIndex({ + name: 'IDX_STORES_CREATED_AT', + columnNames: ['createdAt'], + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop foreign keys first + const table = await queryRunner.getTable('stores'); + const foreignKey = table.foreignKeys.find(fk => fk.columnNames.indexOf('sellerId') !== -1); + if (foreignKey) { + await queryRunner.dropForeignKey('stores', foreignKey); + } + + // Drop indexes + await queryRunner.dropIndex('stores', 'IDX_STORES_SELLER_ID'); + await queryRunner.dropIndex('stores', 'IDX_STORES_STATUS'); + await queryRunner.dropIndex('stores', 'IDX_STORES_CATEGORIES'); + await queryRunner.dropIndex('stores', 'IDX_STORES_TAGS'); + await queryRunner.dropIndex('stores', 'IDX_STORES_RATING'); + await queryRunner.dropIndex('stores', 'IDX_STORES_CREATED_AT'); + + // Drop table + await queryRunner.dropTable('stores'); + } +} diff --git a/src/migrations/1751199238000-UpdateForeignKeysToUUID.ts b/src/migrations/1751199238000-UpdateForeignKeysToUUID.ts new file mode 100644 index 0000000..67bf634 --- /dev/null +++ b/src/migrations/1751199238000-UpdateForeignKeysToUUID.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateForeignKeysToUUID1751199238000 implements MigrationInterface { + name = 'UpdateForeignKeysToUUID1751199238000'; + + public async up(queryRunner: QueryRunner): Promise { + // Update user_roles table + await queryRunner.query(`ALTER TABLE "user_roles" ALTER COLUMN "userId" TYPE UUID USING "userId"::uuid`); + + // Update buyer_requests table + await queryRunner.query(`ALTER TABLE "buyer_requests" ALTER COLUMN "userId" TYPE UUID USING "userId"::uuid`); + + // Update reviews table + await queryRunner.query(`ALTER TABLE "reviews" ALTER COLUMN "userId" TYPE UUID USING "userId"::uuid`); + + // Note: carts and orders already use UUID for user_id + + // Add foreign key constraints if they don't exist + await queryRunner.query(` + ALTER TABLE "user_roles" + ADD CONSTRAINT "FK_user_roles_user" + FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE + `); + + await queryRunner.query(` + ALTER TABLE "buyer_requests" + ADD CONSTRAINT "FK_buyer_requests_user" + FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE + `); + + await queryRunner.query(` + ALTER TABLE "reviews" + ADD CONSTRAINT "FK_reviews_user" + FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE + `); + + await queryRunner.query(` + ALTER TABLE "carts" + ADD CONSTRAINT "FK_carts_user" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + `); + + await queryRunner.query(` + ALTER TABLE "orders" + ADD CONSTRAINT "FK_orders_user" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop foreign key constraints + await queryRunner.query(`ALTER TABLE "user_roles" DROP CONSTRAINT IF EXISTS "FK_user_roles_user"`); + await queryRunner.query(`ALTER TABLE "buyer_requests" DROP CONSTRAINT IF EXISTS "FK_buyer_requests_user"`); + await queryRunner.query(`ALTER TABLE "reviews" DROP CONSTRAINT IF EXISTS "FK_reviews_user"`); + await queryRunner.query(`ALTER TABLE "carts" DROP CONSTRAINT IF EXISTS "FK_carts_user"`); + await queryRunner.query(`ALTER TABLE "orders" DROP CONSTRAINT IF EXISTS "FK_orders_user"`); + + // Revert column types to integer (this will require data migration in a real scenario) + await queryRunner.query(`ALTER TABLE "user_roles" ALTER COLUMN "userId" TYPE INTEGER USING "userId"::integer`); + await queryRunner.query(`ALTER TABLE "buyer_requests" ALTER COLUMN "userId" TYPE INTEGER USING "userId"::integer`); + await queryRunner.query(`ALTER TABLE "reviews" ALTER COLUMN "userId" TYPE INTEGER USING "userId"::integer`); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 38be080..163f0a1 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -17,6 +17,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { RolesGuard } from './guards/roles.guard'; import { UsersModule } from '../users/users.module'; +import { StoresModule } from '../stores/stores.module'; @Module({ imports: [ @@ -31,6 +32,7 @@ import { UsersModule } from '../users/users.module'; inject: [ConfigService], }), forwardRef(() => UsersModule), + StoresModule, ], controllers: [AuthController, RoleController], providers: [AuthService, RoleService, JwtAuthGuard, RolesGuard, JwtStrategy], diff --git a/src/modules/auth/controllers/auth.controller.ts b/src/modules/auth/controllers/auth.controller.ts index e69bc28..f075423 100644 --- a/src/modules/auth/controllers/auth.controller.ts +++ b/src/modules/auth/controllers/auth.controller.ts @@ -106,7 +106,6 @@ export class AuthController { success: true, data: { user: { - id: result.user.id, walletAddress: result.user.walletAddress, name: result.user.name, email: result.user.email, @@ -151,6 +150,7 @@ export class AuthController { role: registerDto.role, name: registerDto.name, email: registerDto.email, + country: registerDto.country?.toUpperCase(), }); // Set JWT token using the helper function @@ -162,7 +162,6 @@ export class AuthController { success: true, data: { user: { - id: result.user.id, walletAddress: result.user.walletAddress, name: result.user.name, email: result.user.email, @@ -206,11 +205,11 @@ export class AuthController { return { success: true, data: { - id: user.id, walletAddress: user.walletAddress, name: user.name, email: user.email, role: user.userRoles?.[0]?.role?.name || 'buyer', + country: user?.country || null, createdAt: user.createdAt, updatedAt: user.updatedAt, }, diff --git a/src/modules/auth/controllers/role.controller.ts b/src/modules/auth/controllers/role.controller.ts index fda2dd0..d3b6b6c 100644 --- a/src/modules/auth/controllers/role.controller.ts +++ b/src/modules/auth/controllers/role.controller.ts @@ -9,10 +9,10 @@ export class RoleController { @Post('assign') @UseGuards(JwtAuthGuard) async assignRole( - @Body() body: { userId: number; roleName: number } + @Body() body: { walletAddress: string; roleName: string } ): Promise<{ success: boolean }> { - const { userId, roleName } = body; - await this.roleService.assignRoleToUser(userId.toString(), roleName.toString()); + const { walletAddress, roleName } = body; + await this.roleService.assignRoleToUser(walletAddress, roleName); return { success: true }; } diff --git a/src/modules/auth/dto/auth-response.dto.ts b/src/modules/auth/dto/auth-response.dto.ts index 5c90cb2..dc9c68a 100644 --- a/src/modules/auth/dto/auth-response.dto.ts +++ b/src/modules/auth/dto/auth-response.dto.ts @@ -3,7 +3,7 @@ import { ApiProperty } from '@nestjs/swagger'; export class ChallengeResponseDto { @ApiProperty({ description: 'Success status', - example: true + example: true, }) success: boolean; @@ -12,8 +12,8 @@ export class ChallengeResponseDto { example: { challenge: 'Please sign this message to authenticate: 1234567890', walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890', - timestamp: 1640995200000 - } + timestamp: 1640995200000, + }, }) data: { challenge: string; @@ -23,33 +23,27 @@ export class ChallengeResponseDto { } export class UserDto { - @ApiProperty({ - description: 'User ID', - example: 1 - }) - id: number; - @ApiProperty({ description: 'Stellar wallet address', - example: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890' + example: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890', }) walletAddress: string; @ApiProperty({ description: 'User display name', - example: 'John Doe' + example: 'John Doe', }) name: string; @ApiProperty({ description: 'User email address', - example: 'john.doe@example.com' + example: 'john.doe@example.com', }) email: string; @ApiProperty({ description: 'User role', - example: 'buyer' + example: 'buyer', }) role: string; } @@ -57,7 +51,7 @@ export class UserDto { export class AuthResponseDto { @ApiProperty({ description: 'Success status', - example: true + example: true, }) success: boolean; @@ -65,14 +59,13 @@ export class AuthResponseDto { description: 'Authentication data', example: { user: { - id: 1, walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890', name: 'John Doe', email: 'john.doe@example.com', - role: 'buyer' + role: 'buyer', }, - expiresIn: 3600 - } + expiresIn: 3600, + }, }) data: { user: UserDto; @@ -83,28 +76,29 @@ export class AuthResponseDto { export class UserResponseDto { @ApiProperty({ description: 'Success status', - example: true + example: true, }) success: boolean; @ApiProperty({ description: 'User data', example: { - id: 1, walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890', name: 'John Doe', email: 'john.doe@example.com', role: 'buyer', + // Optional fields + country: 'US', createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z' - } + updatedAt: '2024-01-01T00:00:00.000Z', + }, }) data: { - id: number; walletAddress: string; name: string; email: string; role: string; + country?: string | null; createdAt: Date; updatedAt: Date; }; @@ -113,13 +107,13 @@ export class UserResponseDto { export class LogoutResponseDto { @ApiProperty({ description: 'Success status', - example: true + example: true, }) success: boolean; @ApiProperty({ description: 'Logout message', - example: 'Logged out successfully' + example: 'Logged out successfully', }) message: string; -} \ No newline at end of file +} diff --git a/src/modules/auth/dto/auth.dto.ts b/src/modules/auth/dto/auth.dto.ts index adef4ee..1d9e50e 100644 --- a/src/modules/auth/dto/auth.dto.ts +++ b/src/modules/auth/dto/auth.dto.ts @@ -1,5 +1,62 @@ -import { IsString, IsOptional, Matches, IsNotEmpty, IsEmail } from 'class-validator'; +import { + IsString, + IsOptional, + Matches, + IsNotEmpty, + IsEmail, + IsObject, + IsEnum, + Validate, + registerDecorator, + ValidationOptions, + ValidationArguments +} from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type, Transform } from 'class-transformer'; +import { CountryCode } from '@/modules/users/enums/country-code.enum'; + +// Custom validator to ensure role-specific data rules +function IsRoleSpecificData(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: 'isRoleSpecificData', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: any, args: ValidationArguments) { + const obj = args.object as any; + const role = obj.role; + + if (propertyName === 'buyerData') { + // buyerData is only allowed for buyers + if (role !== 'buyer' && value !== undefined) { + return false; + } + } + + if (propertyName === 'sellerData') { + // sellerData is only allowed for sellers + if (role !== 'seller' && value !== undefined) { + return false; + } + } + + return true; + }, + defaultMessage(args: ValidationArguments) { + if (args.property === 'buyerData') { + return 'buyerData is only allowed for buyers'; + } + if (args.property === 'sellerData') { + return 'sellerData is only allowed for sellers'; + } + return 'Invalid role-specific data'; + } + } + }); + }; +} export class StellarWalletLoginDto { @ApiProperty({ @@ -49,6 +106,44 @@ export class RegisterUserDto { @IsEmail() @IsOptional() email?: string; + + @ApiProperty({ + description: "Country code of the buyer request", + example: "US", + enum: CountryCode, + enumName: 'CountryCode' + }) + @Transform(({ value }) => value?.toUpperCase()) + @IsOptional() + @IsString() + @IsEnum(CountryCode, { message: 'Country must be a valid ISO 3166-1 alpha-2 country code' }) + country?: string; + + @ApiPropertyOptional({ + description: 'User location', + example: 'New York', + }) + @IsString() + @IsOptional() + location?: string; + + @ApiPropertyOptional({ + description: 'Buyer-specific data (only allowed if role is buyer)', + example: { preferences: ['electronics', 'books'] }, + }) + @IsRoleSpecificData({ message: 'buyerData is only allowed for buyers' }) + @IsObject() + @IsOptional() + buyerData?: any; + + @ApiPropertyOptional({ + description: 'Seller-specific data (only allowed if role is seller)', + example: { businessName: 'Tech Store', categories: ['electronics'], rating: 4.5 }, + }) + @IsRoleSpecificData({ message: 'sellerData is only allowed for sellers' }) + @IsObject() + @IsOptional() + sellerData?: any; } export class UpdateUserDto { @@ -67,6 +162,44 @@ export class UpdateUserDto { @IsEmail() @IsOptional() email?: string; + + @ApiPropertyOptional({ + description: 'User location', + example: 'New York', + }) + @IsString() + @IsOptional() + location?: string; + + @ApiPropertyOptional({ + description: "Country code of the buyer request", + example: "US", + enum: CountryCode, + enumName: 'CountryCode' + }) + @Transform(({ value }) => value?.toUpperCase()) + @IsOptional() + @IsString() + @IsEnum(CountryCode, { message: 'Country must be a valid ISO 3166-1 alpha-2 country code' }) + country?: string; + + @ApiPropertyOptional({ + description: 'Buyer-specific data (only allowed if role is buyer)', + example: { preferences: ['electronics', 'books'] }, + }) + @IsRoleSpecificData({ message: 'buyerData is only allowed for buyers' }) + @IsObject() + @IsOptional() + buyerData?: any; + + @ApiPropertyOptional({ + description: 'Seller-specific data (only allowed if role is seller)', + example: { businessName: 'Tech Store', categories: ['electronics'], rating: 4.5 }, + }) + @IsRoleSpecificData({ message: 'sellerData is only allowed for sellers' }) + @IsObject() + @IsOptional() + sellerData?: any; } export class ChallengeDto { diff --git a/src/modules/auth/entities/user-role.entity.ts b/src/modules/auth/entities/user-role.entity.ts index 711b5e6..6d46822 100644 --- a/src/modules/auth/entities/user-role.entity.ts +++ b/src/modules/auth/entities/user-role.entity.ts @@ -7,8 +7,8 @@ export class UserRole { @PrimaryGeneratedColumn() id: number; - @Column() - userId: number; + @Column({ type: 'uuid' }) + userId: string; @Column() roleId: number; diff --git a/src/modules/auth/middleware/authorize-roles.middleware.ts b/src/modules/auth/middleware/authorize-roles.middleware.ts index 24eb165..4244979 100644 --- a/src/modules/auth/middleware/authorize-roles.middleware.ts +++ b/src/modules/auth/middleware/authorize-roles.middleware.ts @@ -9,7 +9,7 @@ export const authorizeRoles = (allowedRoles: Role[]) => { throw new UnauthorizedException('User not authenticated'); } - const userRoles = req.user.role; + const userRoles = req.user.role as Role[]; const hasAllowedRole = userRoles.some((role) => allowedRoles.includes(role)); if (!hasAllowedRole) { diff --git a/src/modules/auth/middleware/jwt-auth.middleware.ts b/src/modules/auth/middleware/jwt-auth.middleware.ts index afa6417..1a1b089 100644 --- a/src/modules/auth/middleware/jwt-auth.middleware.ts +++ b/src/modules/auth/middleware/jwt-auth.middleware.ts @@ -48,7 +48,7 @@ export const jwtAuthMiddleware = async (req: Request, res: Response, next: NextF walletAddress: user.walletAddress, name: user.name, role: user.userRoles?.map((ur) => ur.role.name as Role) || [decoded.role as Role], - }; + } as any; next(); } catch (error) { diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 4f9b725..7ff19d0 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -11,6 +11,9 @@ import { BadRequestError, UnauthorizedError } from '../../../utils/errors'; import { sign } from 'jsonwebtoken'; import { config } from '../../../config'; import { Keypair } from 'stellar-sdk'; +import { CountryCode } from '../../../modules/users/enums/country-code.enum'; +import { StoreService } from '../../stores/services/store.service'; + type RoleName = 'buyer' | 'seller' | 'admin'; @@ -28,7 +31,8 @@ export class AuthService { @Inject(forwardRef(() => UserService)) private readonly userService: UserService, private readonly jwtService: JwtService, - private readonly roleService: RoleService + private readonly roleService: RoleService, + private readonly storeService: StoreService, ) {} /** @@ -49,6 +53,7 @@ export class AuthService { process.env.NODE_ENV === 'development' && signature === 'base64-encoded-signature-string-here' ) { + // eslint-disable-next-line no-console console.log('Development mode: Bypassing signature verification for testing'); return true; } @@ -59,6 +64,7 @@ export class AuthService { return keypair.verify(messageBuffer, signatureBuffer); } catch (error) { + // eslint-disable-next-line no-console console.error('Signature verification error:', error); return false; } @@ -72,29 +78,51 @@ export class AuthService { role: 'buyer' | 'seller'; name?: string; email?: string; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; }): Promise<{ user: User; token: string; expiresIn: number }> { + // Validate that buyers can't have seller data and sellers can't have buyer data + if (data.role === 'buyer' && data.sellerData !== undefined) { + throw new BadRequestError('Buyers cannot have seller data'); + } + if (data.role === 'seller' && data.buyerData !== undefined) { + throw new BadRequestError('Sellers cannot have buyer data'); + } + // Check if user already exists const existingUser = await this.userRepository.findOne({ where: { walletAddress: data.walletAddress }, relations: ['userRoles', 'userRoles.role'], }); + // If user is not a buyer made country validations + if (!this.isBuyer(data)) { + data.country = null; + } + if (existingUser) { // Update existing user instead of throwing error existingUser.name = data.name || existingUser.name; existingUser.email = data.email || existingUser.email; + existingUser.location = data.location || existingUser.location; + existingUser.country = data.country || existingUser.country; + existingUser.buyerData = data.buyerData || existingUser.buyerData; + existingUser.sellerData = data.sellerData || existingUser.sellerData; + + const dataToValidate = { role: data.role, country: data.country }; + if(!this.isBuyer(dataToValidate)){ + existingUser.country = null; + } + + existingUser.country = data.country || existingUser.country; const updatedUser = await this.userRepository.save(existingUser); - // Generate JWT token const role = updatedUser.userRoles?.[0]?.role?.name || 'buyer'; - const token = sign( - { id: updatedUser.id, walletAddress: updatedUser.walletAddress, role }, - config.jwtSecret, - { - expiresIn: '1h', - } - ); + // Generate JWT token + const token = this.generateJwtToken(updatedUser, role); return { user: updatedUser, token, expiresIn: 3600 }; } @@ -104,39 +132,85 @@ export class AuthService { walletAddress: data.walletAddress, name: data.name, email: data.email, + country: data?.country || null, + location: data.location, + country: data.country, + buyerData: data.buyerData, + sellerData: data.sellerData, }); const savedUser = await this.userRepository.save(user); - // Assign user role + // Assign user role to user_roles table const userRole = await this.roleRepository.findOne({ where: { name: data.role } }); - if (userRole) { - const userRoleEntity = this.userRoleRepository.create({ - userId: savedUser.id, - roleId: userRole.id, - user: savedUser, - role: userRole, - }); - await this.userRoleRepository.save(userRoleEntity); + if (!userRole) { + throw new BadRequestError(`Role ${data.role} does not exist`); + } + const userRoleEntity = this.userRoleRepository.create({ + userId: savedUser.id, + roleId: userRole.id, + user: savedUser, + role: userRole, + }); + await this.userRoleRepository.save(userRoleEntity); + + // Create default store for sellers + if (data.role === 'seller') { + try { + await this.storeService.createDefaultStore(savedUser.id, data.sellerData); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to create default store for seller:', error); + // Don't fail the registration if store creation fails + } } // Generate JWT token - const token = sign( - { id: savedUser.id, walletAddress: savedUser.walletAddress, role: data.role }, + const token = this.generateJwtToken(savedUser, userRole.name); + + return { user: savedUser, token, expiresIn: 3600 }; + } + + /** + * Generate JWT token for user + */ + private generateJwtToken(user: User, role: string): string { + return sign( + { id: user.id, walletAddress: user.walletAddress, role }, config.jwtSecret, { expiresIn: '1h', - } + }, ); + } - return { user: savedUser, token, expiresIn: 3600 }; + /** + * Check if the user is a buyer and validate fields of buyer registration + */ + private isBuyer(data: { + role: 'buyer' | 'seller'; + country?: string; + }) { + if (data.role !== 'buyer') { + return false; + } + + if (!data.country) { + throw new BadRequestError('Country is required for buyer registration'); + } + + if (!Object.values(CountryCode).includes(data.country as CountryCode)) { + throw new BadRequestError('Country must be a valid ISO 3166-1 alpha-2 country code'); + } + + return true; } /** * Login with Stellar wallet (no signature required) */ async loginWithWallet( - walletAddress: string + walletAddress: string, ): Promise<{ user: User; token: string; expiresIn: number }> { // Find user const user = await this.userRepository.findOne({ @@ -162,7 +236,7 @@ export class AuthService { */ async getUserById(id: string): Promise { const user = await this.userRepository.findOne({ - where: { id: Number(id) }, + where: { id }, relations: ['userRoles', 'userRoles.role'], }); @@ -174,10 +248,21 @@ export class AuthService { } /** - * Update user information + * Update user information (usar walletAddress como identificador primario) + * Mantiene todo lo de develop (location, country, buyerData, sellerData, etc.) */ - async updateUser(userId: number, updateData: { name?: string; email?: string }): Promise { - const user = await this.userRepository.findOne({ where: { id: userId } }); + async updateUser( + walletAddress: string, + updateData: { + name?: string; + email?: string; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; + }, + ): Promise { + const user = await this.userRepository.findOne({ where: { walletAddress } }); if (!user) { throw new BadRequestError('User not found'); @@ -187,7 +272,42 @@ export class AuthService { Object.assign(user, updateData); await this.userRepository.save(user); - return this.getUserById(String(userId)); + return this.getUserByWalletAddress(walletAddress); + } + + /** + * (Compat) Update user by numeric ID โ€” conserva compatibilidad con develop + * Preferir updateUser(walletAddress, โ€ฆ) + */ + async updateUserById( + userId: number, + updateData: { + name?: string; + email?: string; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; + }, + ): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new BadRequestError('User not found'); + } + Object.assign(user, updateData); + await this.userRepository.save(user); + return this.getUserByWalletAddress(user.walletAddress); + } + + async getUserByWalletAddress(walletAddress: string): Promise { + const user = await this.userRepository.findOne({ + where: { walletAddress }, + relations: ['userRoles', 'userRoles.role'], + }); + if (!user) { + throw new BadRequestError('User not found'); + } + return user; } async authenticateUser(walletAddress: string): Promise<{ access_token: string }> { @@ -233,8 +353,8 @@ export class AuthService { return { access_token: this.jwtService.sign(payload) }; } - async assignRole(userId: number, roleName: RoleName): Promise { - const user = await this.userService.getUserById(String(userId)); + async assignRole(walletAddress: string, roleName: RoleName): Promise { + const user = await this.userService.getUserByWalletAddress(walletAddress); if (!user) { throw new UnauthorizedException('User not found'); } @@ -245,7 +365,7 @@ export class AuthService { } // Remove existing roles - await this.userRoleRepository.delete({ userId }); + await this.userRoleRepository.delete({ userId: user.id }); // Create new user role relationship const userRole = this.userRoleRepository.create({ @@ -256,17 +376,17 @@ export class AuthService { }); await this.userRoleRepository.save(userRole); - return this.userService.getUserById(String(userId)); + return this.userService.getUserByWalletAddress(walletAddress); } - async removeRole(userId: number): Promise { - const user = await this.userService.getUserById(String(userId)); + async removeRole(walletAddress: string): Promise { + const user = await this.userService.getUserByWalletAddress(walletAddress); if (!user) { throw new UnauthorizedException('User not found'); } - await this.userRoleRepository.delete({ userId }); + await this.userRoleRepository.delete({ userId: user.id }); - return this.userService.getUserById(String(userId)); + return this.userService.getUserByWalletAddress(walletAddress); } } diff --git a/src/modules/auth/services/role.service.ts b/src/modules/auth/services/role.service.ts index 7654e6c..9499f4c 100644 --- a/src/modules/auth/services/role.service.ts +++ b/src/modules/auth/services/role.service.ts @@ -47,30 +47,30 @@ export class RoleService { throw new Error(`Role ${roleName} not found`); } await this.userRoleRepository.save({ - userId: parseInt(userId), + userId, roleId: role.id, }); } - async removeRoleFromUser(userId: number, roleId: number): Promise { + async removeRoleFromUser(userId: string, roleId: number): Promise { await this.userRoleRepository.delete({ userId, roleId }); } async getUserRoles(userId: string): Promise { const userRoles = await this.userRoleRepository.find({ - where: { userId: parseInt(userId) }, + where: { userId }, relations: ['role'], }); return userRoles.map((ur) => ur.role); } - async hasRole(userId: number, roleName: RoleName): Promise { - const userRoles = await this.getUserRoles(userId.toString()); + async hasRole(userId: string, roleName: RoleName): Promise { + const userRoles = await this.getUserRoles(userId); return userRoles.some((role) => role.name === roleName); } - async hasAnyRole(userId: number, roleNames: RoleName[]): Promise { - const userRoles = await this.getUserRoles(userId.toString()); + async hasAnyRole(userId: string, roleNames: RoleName[]): Promise { + const userRoles = await this.getUserRoles(userId); return userRoles.some((role) => roleNames.includes(role.name)); } } diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts index 3a55ceb..827ee64 100644 --- a/src/modules/auth/strategies/jwt.strategy.ts +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -24,12 +24,23 @@ export class JwtStrategy extends PassportStrategy(Strategy) { async validate(payload: any) { try { - const user = await this.authService.getUserById(payload.id); + // Try to get user by walletAddress first (preferred method) + let user; + if (payload.walletAddress) { + user = await this.authService.getUserByWalletAddress(payload.walletAddress); + } else if (payload.id) { + // Fallback to id for backward compatibility during migration + user = await this.authService.getUserById(payload.id); + } else { + throw new UnauthorizedException('Invalid token payload'); + } + if (!user) { throw new UnauthorizedException('User not found'); } + return { - id: user.id, + id: user.id, // Keep UUID for internal use walletAddress: user.walletAddress, role: user.userRoles?.[0]?.role?.name || 'buyer', }; diff --git a/src/modules/auth/tests/auth.service.spec.ts b/src/modules/auth/tests/auth.service.spec.ts index 765781d..52ac241 100644 --- a/src/modules/auth/tests/auth.service.spec.ts +++ b/src/modules/auth/tests/auth.service.spec.ts @@ -5,6 +5,7 @@ import { JwtService } from '@nestjs/jwt'; import { Keypair } from 'stellar-sdk'; import { BadRequestError, UnauthorizedError } from '../../../utils/errors'; import { User } from '../../users/entities/user.entity'; +import { Role as UserRoleEnum } from '../../../types/role'; // Mock dependencies jest.mock('../../users/services/user.service'); @@ -42,6 +43,7 @@ describe('AuthService', () => { const mockUserRepository = { findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any; const mockRoleRepository = { findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any; const mockUserRoleRepository = { create: jest.fn(), save: jest.fn() } as any; + const mockStoreService = { createDefaultStore: jest.fn() } as any; authService = new AuthService( mockUserRepository, @@ -49,7 +51,8 @@ describe('AuthService', () => { mockUserRoleRepository, userService, jwtService, - roleService + roleService, + mockStoreService ); }); @@ -116,6 +119,10 @@ describe('AuthService', () => { walletAddress: mockWalletAddress, name: 'Test User', email: 'test@example.com', + location: 'Test City', + country: 'Test Country', + buyerData: {}, + sellerData: null, userRoles: [{ role: { name: 'buyer' } }] as any, }; @@ -153,7 +160,12 @@ describe('AuthService', () => { walletAddress: mockWalletAddress, name: 'New User', email: 'new@example.com', + location: 'Test City', + country: 'Test Country', + buyerData: {}, + sellerData: null, userRoles: [{ role: { name: 'buyer' } }] as any, + country: 'US', }; beforeEach(() => { @@ -168,13 +180,38 @@ describe('AuthService', () => { create: jest.fn().mockReturnValue(mockNewUser), save: jest.fn().mockResolvedValue(mockNewUser), }; + + const mockRoleRepository = { + findOne: jest.fn().mockResolvedValue({ + id: 1, + name: 'buyer' + }), + }; + + // Add mock for userRoleRepository + const mockUserRoleRepository = { + create: jest.fn().mockReturnValue({ + id: 1, + userId: 1, + roleId: 1 + }), + save: jest.fn().mockResolvedValue({ + id: 1, + userId: 1, + roleId: 1 + }), + }; + (authService as any).userRepository = mockUserRepository; + (authService as any).roleRepository = mockRoleRepository; + (authService as any).userRoleRepository = mockUserRoleRepository; const result = await authService.registerWithWallet({ walletAddress: mockWalletAddress, - role: 'buyer', + role: UserRoleEnum.BUYER, name: 'New User', email: 'new@example.com', + country: 'CR' }); expect(result.user).toEqual(mockNewUser); @@ -191,7 +228,7 @@ describe('AuthService', () => { await expect( authService.registerWithWallet({ walletAddress: mockWalletAddress, - role: 'buyer', + role: UserRoleEnum.BUYER, name: 'New User', }) ).rejects.toThrow(BadRequestError); @@ -204,9 +241,10 @@ describe('AuthService', () => { await expect( authService.registerWithWallet({ walletAddress: mockWalletAddress, - role: 'buyer', + country: 'CR', + role: UserRoleEnum.BUYER, }) - ).rejects.toThrow(UnauthorizedError); + ).rejects.toThrow(BadRequestError); }); }); diff --git a/src/modules/buyer-requests/dto/buyer-request-response.dto.ts b/src/modules/buyer-requests/dto/buyer-request-response.dto.ts index 523e462..ee3ffa4 100644 --- a/src/modules/buyer-requests/dto/buyer-request-response.dto.ts +++ b/src/modules/buyer-requests/dto/buyer-request-response.dto.ts @@ -8,12 +8,12 @@ export interface BuyerRequestResponseDto { budgetMax: number categoryId: number status: BuyerRequestStatus - userId: number + userId: string expiresAt?: Date createdAt: Date updatedAt: Date user?: { - id: number + id: string name: string walletAddress: string } diff --git a/src/modules/buyer-requests/entities/buyer-request.entity.ts b/src/modules/buyer-requests/entities/buyer-request.entity.ts index 291ae68..b807c94 100644 --- a/src/modules/buyer-requests/entities/buyer-request.entity.ts +++ b/src/modules/buyer-requests/entities/buyer-request.entity.ts @@ -51,8 +51,8 @@ export class BuyerRequest { }) status: BuyerRequestStatus; - @Column() - userId: number; + @Column({ type: 'uuid' }) + userId: string; @Column({ type: 'timestamp', nullable: true }) expiresAt: Date; diff --git a/src/modules/buyer-requests/services/buyer-requests.service.ts b/src/modules/buyer-requests/services/buyer-requests.service.ts index 6bd5fcb..eaddb63 100644 --- a/src/modules/buyer-requests/services/buyer-requests.service.ts +++ b/src/modules/buyer-requests/services/buyer-requests.service.ts @@ -24,7 +24,7 @@ export class BuyerRequestsService { async create( createBuyerRequestDto: CreateBuyerRequestDto, - userId: number + userId: string ): Promise { const { budgetMin, budgetMax, expiresAt } = createBuyerRequestDto; @@ -135,7 +135,7 @@ export class BuyerRequestsService { async update( id: number, updateBuyerRequestDto: UpdateBuyerRequestDto, - userId: number + userId: string ): Promise { const buyerRequest = await this.buyerRequestRepository.findOne({ where: { id }, @@ -186,7 +186,7 @@ export class BuyerRequestsService { return this.mapToResponseDto(updated); } - async remove(id: number, userId: number): Promise { + async remove(id: number, userId: string): Promise { const buyerRequest = await this.buyerRequestRepository.findOne({ where: { id }, }); @@ -286,7 +286,7 @@ export class BuyerRequestsService { /** * Manually close a buyer request (buyer-only access) */ - async closeRequest(id: number, userId: number): Promise { + async closeRequest(id: number, userId: string): Promise { const buyerRequest = await this.buyerRequestRepository.findOne({ where: { id }, relations: ['user'], diff --git a/src/modules/files/tests/file.controller.spec.ts b/src/modules/files/tests/file.controller.spec.ts index e8ecaaf..c0e9786 100644 --- a/src/modules/files/tests/file.controller.spec.ts +++ b/src/modules/files/tests/file.controller.spec.ts @@ -87,10 +87,13 @@ describe('FileController', () => { const mockUser = { id: 1, walletAddress: '0x123', - role: [Role.USER], name: 'Test User', email: 'test@example.com', password: 'hashed_password', + location: 'Test City', + country: 'Test Country', + buyerData: {}, + sellerData: null, orders: [], userRoles: [], notifications: [], diff --git a/src/modules/files/tests/file.service.spec.ts b/src/modules/files/tests/file.service.spec.ts index ff188c7..868f5c8 100644 --- a/src/modules/files/tests/file.service.spec.ts +++ b/src/modules/files/tests/file.service.spec.ts @@ -52,10 +52,15 @@ describe('FileService', () => { name: 'Test User', email: 'test@example.com', password: 'hashed_password', + location: 'Test City', + country: 'Test Country', + buyerData: {}, + sellerData: null, orders: [], userRoles: [], notifications: [], wishlist: [], + stores: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -108,10 +113,15 @@ describe('FileService', () => { name: 'Test User', email: 'test@example.com', password: 'hashed_password', + location: 'Test City', + country: 'Test Country', + buyerData: {}, + sellerData: null, orders: [], userRoles: [], notifications: [], wishlist: [], + stores: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -166,10 +176,15 @@ describe('FileService', () => { name: 'Test User', email: 'test@example.com', password: 'hashed_password', + location: 'Test City', + country: 'Test Country', + buyerData: {}, + sellerData: null, orders: [], userRoles: [], notifications: [], wishlist: [], + stores: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -217,10 +232,15 @@ describe('FileService', () => { name: 'Test User', email: 'test@example.com', password: 'hashed_password', + location: 'Test City', + country: 'Test Country', + buyerData: {}, + sellerData: null, orders: [], userRoles: [], notifications: [], wishlist: [], + stores: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -283,6 +303,10 @@ describe('FileService', () => { name: 'Test User', email: 'test@example.com', password: 'hashed_password', + location: 'Test City', + country: 'Test Country', + buyerData: {}, + sellerData: null, orders: [], userRoles: [], notifications: [], @@ -323,6 +347,10 @@ describe('FileService', () => { name: 'Test User', email: 'test@example.com', password: 'hashed_password', + location: 'Test City', + country: 'Test Country', + buyerData: {}, + sellerData: null, orders: [], userRoles: [], notifications: [], diff --git a/src/modules/files/tests/test-utils.ts b/src/modules/files/tests/test-utils.ts index c881292..3a37f3e 100644 --- a/src/modules/files/tests/test-utils.ts +++ b/src/modules/files/tests/test-utils.ts @@ -4,6 +4,10 @@ export const mockUser = { walletAddress: '0x123456789abcdef', name: 'Test User', email: 'test@example.com', + location: 'Test City', + country: 'Test Country', + buyerData: {}, + sellerData: null, }; // Helper function to create mock file objects for testing diff --git a/src/modules/products/products.module.ts b/src/modules/products/products.module.ts index c2e632c..721458d 100644 --- a/src/modules/products/products.module.ts +++ b/src/modules/products/products.module.ts @@ -4,9 +4,10 @@ import { ProductController } from './controllers/product.controller'; import { ProductService } from './services/product.service'; import { Product } from './entities/product.entity'; import { SharedModule } from '../shared/shared.module'; +import { AppCacheModule } from '../../cache/cache.module'; @Module({ - imports: [TypeOrmModule.forFeature([Product]), SharedModule], + imports: [TypeOrmModule.forFeature([Product]), SharedModule, AppCacheModule], controllers: [ProductController], providers: [ProductService], exports: [ProductService], diff --git a/src/modules/products/services/product.service.ts b/src/modules/products/services/product.service.ts index fc5ec4a..d53e57f 100644 --- a/src/modules/products/services/product.service.ts +++ b/src/modules/products/services/product.service.ts @@ -4,6 +4,8 @@ import { ProductType } from '../../productTypes/entities/productTypes.entity'; import AppDataSource from '../../../config/ormconfig'; import { AppDataSource as DatabaseAppDataSource } from '../../../config/database'; import { NotFoundError } from '../../../utils/errors'; +import { CacheService } from '../../../cache/cache.service'; +import { Cacheable, CacheInvalidate } from '../../../cache/decorators/cache.decorator'; export interface ProductFilters { category?: number; @@ -44,7 +46,7 @@ export class ProductService { private repository: Repository; private productRepository: Repository; - constructor() { + constructor(private cacheService: CacheService) { this.repository = AppDataSource.getRepository(Product); this.productRepository = DatabaseAppDataSource.getRepository(Product); } @@ -61,12 +63,17 @@ export class ProductService { try { const response = await this.repository.save(product); if (!response?.id) throw new Error('Database error'); + + // Invalidate product cache after creation + await this.cacheService.invalidateEntity('product'); + return response; } catch (error) { throw new Error('Database error'); } } + @Cacheable({ key: 'products', entity: 'product', action: 'list' }) async getAll(filters?: { category?: number; minPrice?: number; @@ -132,23 +139,39 @@ export class ProductService { return await query.getMany(); } + @Cacheable({ key: 'product', entity: 'product', action: 'detail' }) async getById(id: number): Promise { return await this.repository.findOne({ where: { id }, relations: ['productType', 'variants'] }); } + @CacheInvalidate('product') async update(id: number, data: Partial): Promise { const product = await this.getById(id); if (!product) return null; Object.assign(product, data); - return await this.repository.save(product); + const updatedProduct = await this.repository.save(product); + + // Invalidate specific product cache + await this.cacheService.delete('product', 'detail', { id }); + + return updatedProduct; } + @CacheInvalidate('product') async delete(id: number): Promise { const result = await this.repository.delete(id); - return result.affected === 1; + + if (result.affected === 1) { + // Invalidate specific product cache + await this.cacheService.delete('product', 'detail', { id }); + return true; + } + + return false; } + @Cacheable({ key: 'products', entity: 'product', action: 'paginated' }) async getAllProducts( options: GetAllProductsOptions ): Promise<{ products: Product[]; total: number }> { @@ -179,6 +202,7 @@ export class ProductService { return { products, total }; } + @Cacheable({ key: 'product', entity: 'product', action: 'detail' }) async getProductById(id: number): Promise { const product = await this.productRepository.findOne({ where: { id } }); if (!product) { @@ -187,19 +211,36 @@ export class ProductService { return product; } + @CacheInvalidate('product') async createProduct(data: CreateProductData): Promise { const product = this.productRepository.create(data); - return this.productRepository.save(product); + const savedProduct = await this.productRepository.save(product); + + // Invalidate product list cache + await this.cacheService.invalidateAction('product', 'list'); + await this.cacheService.invalidateAction('product', 'paginated'); + + return savedProduct; } + @CacheInvalidate('product') async updateProduct(id: number, data: UpdateProductData): Promise { const product = await this.getProductById(id); Object.assign(product, data); - return this.productRepository.save(product); + const updatedProduct = await this.productRepository.save(product); + + // Invalidate specific product cache + await this.cacheService.delete('product', 'detail', { id }); + + return updatedProduct; } + @CacheInvalidate('product') async deleteProduct(id: number): Promise { const product = await this.getProductById(id); await this.productRepository.remove(product); + + // Invalidate specific product cache + await this.cacheService.delete('product', 'detail', { id }); } } diff --git a/src/modules/reviews/controllers/review.controller.ts b/src/modules/reviews/controllers/review.controller.ts index 3fa1336..a82ca7e 100644 --- a/src/modules/reviews/controllers/review.controller.ts +++ b/src/modules/reviews/controllers/review.controller.ts @@ -13,7 +13,7 @@ export class ReviewController { async createReview(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { - const userId = Number(req.user.id); + const userId = req.user.id; if (!userId) { throw new BadRequestError('User ID is required'); } @@ -78,7 +78,7 @@ export class ReviewController { async deleteReview(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { - const userId = Number(req.user.id); + const userId = req.user.id; if (!userId) { throw new BadRequestError('User ID is required'); } diff --git a/src/modules/reviews/dto/review.dto.ts b/src/modules/reviews/dto/review.dto.ts index d51f71f..02f1311 100644 --- a/src/modules/reviews/dto/review.dto.ts +++ b/src/modules/reviews/dto/review.dto.ts @@ -7,7 +7,7 @@ export class CreateReviewDTO { export class ReviewResponseDTO { id: string; - userId: number; + userId: string; productId: number; rating: number; comment?: string; diff --git a/src/modules/reviews/entities/review.entity.ts b/src/modules/reviews/entities/review.entity.ts index 48b5e90..aa8f687 100644 --- a/src/modules/reviews/entities/review.entity.ts +++ b/src/modules/reviews/entities/review.entity.ts @@ -16,8 +16,8 @@ export class Review { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ name: 'userId' }) - userId: number; + @Column({ name: 'userId', type: 'uuid' }) + userId: string; @ManyToOne(() => User) @JoinColumn({ name: 'userId' }) diff --git a/src/modules/reviews/services/review.service.ts b/src/modules/reviews/services/review.service.ts index 168228d..1e02e68 100644 --- a/src/modules/reviews/services/review.service.ts +++ b/src/modules/reviews/services/review.service.ts @@ -18,7 +18,7 @@ export class ReviewService { } async createReview( - userId: number, + userId: string, productId: number, rating: number, comment?: string @@ -33,7 +33,7 @@ export class ReviewService { } try { - await this.userService.getUserById(String(userId)); + await this.userService.getUserById(userId); } catch (error) { throw new NotFoundError(`User with ID ${userId} not found`); } @@ -92,7 +92,7 @@ export class ReviewService { }; } - async deleteReview(userId: number, reviewId: string): Promise { + async deleteReview(userId: string, reviewId: string): Promise { const review = await this.repository.findOne({ where: { id: reviewId }, }); diff --git a/src/modules/shared/middleware/auth.middleware.ts b/src/modules/shared/middleware/auth.middleware.ts index 7abc9fe..087f25e 100644 --- a/src/modules/shared/middleware/auth.middleware.ts +++ b/src/modules/shared/middleware/auth.middleware.ts @@ -37,9 +37,9 @@ export class AuthMiddleware implements NestMiddleware { return Role.SELLER; case 'buyer': case 'user': - return Role.USER; + return Role.BUYER; default: - return Role.USER; + return Role.BUYER; } } @@ -61,7 +61,9 @@ export class AuthMiddleware implements NestMiddleware { } const userRoles = await this.roleService.getUserRoles(decoded.id); - req.user = { ...decoded, role: userRoles.map((role) => this.mapRoleToEnum(role.name)) }; + // Map all user roles to Role enum values + const mappedRoles = userRoles.map(ur => this.mapRoleToEnum(ur.name)); + req.user = { ...decoded, role: mappedRoles }; next(); } catch (error) { @@ -80,7 +82,7 @@ export const requireRole = ( ): ((req: AuthenticatedRequest, res: Response, next: NextFunction) => void) => { return (req: AuthenticatedRequest, res: Response, next: NextFunction) => { const requiredRole = new AuthMiddleware(null, null).mapRoleToEnum(roleName); - if (!req.user || !req.user.role.includes(requiredRole)) { + if (!req.user || !req.user.role.some(role => role === requiredRole)) { throw new ReferenceError('Insufficient permissions'); } next(); diff --git a/src/modules/shared/middleware/session.middleware.ts b/src/modules/shared/middleware/session.middleware.ts index a1a5ae3..babd235 100644 --- a/src/modules/shared/middleware/session.middleware.ts +++ b/src/modules/shared/middleware/session.middleware.ts @@ -39,7 +39,7 @@ export const sessionMiddleware = async (req: Request, res: Response, next: NextF id: user.id, walletAddress: user.walletAddress, role: user.userRoles.map((ur) => ur.role.name as Role), - }; + } as any; next(); } catch (error) { diff --git a/src/modules/shared/types/auth-request.type.ts b/src/modules/shared/types/auth-request.type.ts index f1f5a27..2c5cc37 100644 --- a/src/modules/shared/types/auth-request.type.ts +++ b/src/modules/shared/types/auth-request.type.ts @@ -8,6 +8,10 @@ export interface AuthenticatedRequest extends Request { name?: string; email?: string; role: Role[]; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; createdAt?: Date; updatedAt?: Date; }; diff --git a/src/modules/stores/controllers/store.controller.ts b/src/modules/stores/controllers/store.controller.ts new file mode 100644 index 0000000..a3325b9 --- /dev/null +++ b/src/modules/stores/controllers/store.controller.ts @@ -0,0 +1,221 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + Req, + HttpCode, + HttpStatus, + ParseIntPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { StoreService } from '../services/store.service'; +import { CreateStoreDto, UpdateStoreDto, StoreResponseDto } from '../dto/store.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { Role } from '../../../types/role'; +import { AuthenticatedRequest } from '../../../types/auth-request.type'; + +@ApiTags('stores') +@Controller('stores') +export class StoreController { + constructor(private readonly storeService: StoreService) {} + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.SELLER) + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a new store' }) + @ApiResponse({ + status: 201, + description: 'Store created successfully', + type: StoreResponseDto, + }) + @HttpCode(HttpStatus.CREATED) + async createStore( + @Body() createStoreDto: CreateStoreDto, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: boolean; data: StoreResponseDto }> { + const sellerId = req.user.id as number; + const store = await this.storeService.createStore(sellerId, createStoreDto); + + return { + success: true, + data: store, + }; + } + + @Get('my-stores') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.SELLER) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get all stores for the authenticated seller' }) + @ApiResponse({ + status: 200, + description: 'Stores retrieved successfully', + type: [StoreResponseDto], + }) + async getMyStores( + @Req() req: AuthenticatedRequest, + ): Promise<{ success: boolean; data: StoreResponseDto[] }> { + const sellerId = req.user.id as number; + const stores = await this.storeService.getSellerStores(sellerId); + + return { + success: true, + data: stores, + }; + } + + @Get(':id') + @ApiOperation({ summary: 'Get a specific store by ID' }) + @ApiResponse({ + status: 200, + description: 'Store retrieved successfully', + type: StoreResponseDto, + }) + async getStoreById( + @Param('id', ParseIntPipe) id: number, + ): Promise<{ success: boolean; data: StoreResponseDto }> { + const store = await this.storeService.getStoreById(id); + + return { + success: true, + data: store, + }; + } + + @Put(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.SELLER) + @ApiBearerAuth() + @ApiOperation({ summary: 'Update a store' }) + @ApiResponse({ + status: 200, + description: 'Store updated successfully', + type: StoreResponseDto, + }) + async updateStore( + @Param('id', ParseIntPipe) id: number, + @Body() updateStoreDto: UpdateStoreDto, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: boolean; data: StoreResponseDto }> { + const sellerId = req.user.id as number; + const store = await this.storeService.updateStore(id, sellerId, updateStoreDto); + + return { + success: true, + data: store, + }; + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.SELLER) + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete a store' }) + @ApiResponse({ + status: 200, + description: 'Store deleted successfully', + }) + @HttpCode(HttpStatus.OK) + async deleteStore( + @Param('id', ParseIntPipe) id: number, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: boolean; message: string }> { + const sellerId = req.user.id as number; + await this.storeService.deleteStore(id, sellerId); + + return { + success: true, + message: 'Store deleted successfully', + }; + } + + @Get() + @ApiOperation({ summary: 'Get all active stores' }) + @ApiResponse({ + status: 200, + description: 'Stores retrieved successfully', + type: [StoreResponseDto], + }) + async getActiveStores(): Promise<{ success: boolean; data: StoreResponseDto[] }> { + const stores = await this.storeService.getActiveStores(); + + return { + success: true, + data: stores, + }; + } + + @Get('search') + @ApiOperation({ summary: 'Search stores' }) + @ApiResponse({ + status: 200, + description: 'Stores retrieved successfully', + type: [StoreResponseDto], + }) + async searchStores( + @Query('q') query?: string, + @Query('category') category?: string, + @Query('location') location?: string, + ): Promise<{ success: boolean; data: StoreResponseDto[] }> { + const stores = await this.storeService.searchStores(query, category, location); + + return { + success: true, + data: stores, + }; + } + + @Get(':id/stats') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.SELLER) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get store statistics' }) + @ApiResponse({ + status: 200, + description: 'Store statistics retrieved successfully', + }) + async getStoreStats( + @Param('id', ParseIntPipe) id: number, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: boolean; data: any }> { + const sellerId = req.user.id as number; + const stats = await this.storeService.getStoreStats(id, sellerId); + + return { + success: true, + data: stats, + }; + } + + // Admin endpoints + @Put(':id/status') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Update store status (admin only)' }) + @ApiResponse({ + status: 200, + description: 'Store status updated successfully', + type: StoreResponseDto, + }) + async updateStoreStatus( + @Param('id', ParseIntPipe) id: number, + @Body('status') status: string, + ): Promise<{ success: boolean; data: StoreResponseDto }> { + const store = await this.storeService.updateStoreStatus(id, status as any); + + return { + success: true, + data: store, + }; + } +} diff --git a/src/modules/stores/dto/store.dto.ts b/src/modules/stores/dto/store.dto.ts new file mode 100644 index 0000000..5f09374 --- /dev/null +++ b/src/modules/stores/dto/store.dto.ts @@ -0,0 +1,295 @@ +import { IsString, IsOptional, IsArray, IsUrl, IsNumber, IsBoolean, IsEnum, ValidateNested, IsObject } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StoreStatus } from '../entities/store.entity'; + +export class ContactInfoDto { + @ApiPropertyOptional({ description: 'Store phone number' }) + @IsOptional() + @IsString() + phone?: string; + + @ApiPropertyOptional({ description: 'Store email address' }) + @IsOptional() + @IsString() + email?: string; + + @ApiPropertyOptional({ description: 'Store website URL' }) + @IsOptional() + @IsUrl() + website?: string; + + @ApiPropertyOptional({ description: 'Social media links' }) + @IsOptional() + @IsObject() + socialMedia?: { + facebook?: string; + twitter?: string; + instagram?: string; + linkedin?: string; + }; +} + +export class AddressDto { + @ApiPropertyOptional({ description: 'Street address' }) + @IsOptional() + @IsString() + street?: string; + + @ApiPropertyOptional({ description: 'City' }) + @IsOptional() + @IsString() + city?: string; + + @ApiPropertyOptional({ description: 'State/Province' }) + @IsOptional() + @IsString() + state?: string; + + @ApiPropertyOptional({ description: 'Country' }) + @IsOptional() + @IsString() + country?: string; + + @ApiPropertyOptional({ description: 'Postal code' }) + @IsOptional() + @IsString() + postalCode?: string; + + @ApiPropertyOptional({ description: 'Geographic coordinates' }) + @IsOptional() + @IsObject() + coordinates?: { + latitude?: number; + longitude?: number; + }; +} + +export class BusinessHoursDto { + @ApiPropertyOptional({ description: 'Monday business hours' }) + @IsOptional() + @IsObject() + monday?: { open: string; close: string; closed: boolean }; + + @ApiPropertyOptional({ description: 'Tuesday business hours' }) + @IsOptional() + @IsObject() + tuesday?: { open: string; close: string; closed: boolean }; + + @ApiPropertyOptional({ description: 'Wednesday business hours' }) + @IsOptional() + @IsObject() + wednesday?: { open: string; close: string; closed: boolean }; + + @ApiPropertyOptional({ description: 'Thursday business hours' }) + @IsOptional() + @IsObject() + thursday?: { open: string; close: string; closed: boolean }; + + @ApiPropertyOptional({ description: 'Friday business hours' }) + @IsOptional() + @IsObject() + friday?: { open: string; close: string; closed: boolean }; + + @ApiPropertyOptional({ description: 'Saturday business hours' }) + @IsOptional() + @IsObject() + saturday?: { open: string; close: string; closed: boolean }; + + @ApiPropertyOptional({ description: 'Sunday business hours' }) + @IsOptional() + @IsObject() + sunday?: { open: string; close: string; closed: boolean }; +} + +export class PoliciesDto { + @ApiPropertyOptional({ description: 'Return policy' }) + @IsOptional() + @IsString() + returnPolicy?: string; + + @ApiPropertyOptional({ description: 'Shipping policy' }) + @IsOptional() + @IsString() + shippingPolicy?: string; + + @ApiPropertyOptional({ description: 'Privacy policy' }) + @IsOptional() + @IsString() + privacyPolicy?: string; + + @ApiPropertyOptional({ description: 'Terms of service' }) + @IsOptional() + @IsString() + termsOfService?: string; +} + +export class StoreSettingsDto { + @ApiPropertyOptional({ description: 'Auto-approve reviews' }) + @IsOptional() + @IsBoolean() + autoApproveReviews?: boolean; + + @ApiPropertyOptional({ description: 'Email notifications' }) + @IsOptional() + @IsBoolean() + emailNotifications?: boolean; + + @ApiPropertyOptional({ description: 'SMS notifications' }) + @IsOptional() + @IsBoolean() + smsNotifications?: boolean; + + @ApiPropertyOptional({ description: 'Push notifications' }) + @IsOptional() + @IsBoolean() + pushNotifications?: boolean; +} + +export class CreateStoreDto { + @ApiProperty({ description: 'Store name' }) + @IsString() + name: string; + + @ApiPropertyOptional({ description: 'Store description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Store logo URL' }) + @IsOptional() + @IsUrl() + logo?: string; + + @ApiPropertyOptional({ description: 'Store banner URL' }) + @IsOptional() + @IsUrl() + banner?: string; + + @ApiPropertyOptional({ description: 'Contact information' }) + @IsOptional() + @ValidateNested() + @Type(() => ContactInfoDto) + contactInfo?: ContactInfoDto; + + @ApiPropertyOptional({ description: 'Store address' }) + @IsOptional() + @ValidateNested() + @Type(() => AddressDto) + address?: AddressDto; + + @ApiPropertyOptional({ description: 'Business hours' }) + @IsOptional() + @ValidateNested() + @Type(() => BusinessHoursDto) + businessHours?: BusinessHoursDto; + + @ApiPropertyOptional({ description: 'Store categories' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + categories?: string[]; + + @ApiPropertyOptional({ description: 'Store tags' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ description: 'Store policies' }) + @IsOptional() + @ValidateNested() + @Type(() => PoliciesDto) + policies?: PoliciesDto; + + @ApiPropertyOptional({ description: 'Store settings' }) + @IsOptional() + @ValidateNested() + @Type(() => StoreSettingsDto) + settings?: StoreSettingsDto; +} + +export class UpdateStoreDto extends CreateStoreDto { + @ApiPropertyOptional({ description: 'Store status' }) + @IsOptional() + @IsEnum(StoreStatus) + status?: StoreStatus; + + @ApiPropertyOptional({ description: 'Verification status' }) + @IsOptional() + @IsBoolean() + isVerified?: boolean; + + @ApiPropertyOptional({ description: 'Featured status' }) + @IsOptional() + @IsBoolean() + isFeatured?: boolean; +} + +export class StoreResponseDto { + @ApiProperty() + id: number; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + description?: string; + + @ApiPropertyOptional() + logo?: string; + + @ApiPropertyOptional() + banner?: string; + + @ApiPropertyOptional() + contactInfo?: ContactInfoDto; + + @ApiPropertyOptional() + address?: AddressDto; + + @ApiPropertyOptional() + businessHours?: BusinessHoursDto; + + @ApiPropertyOptional() + categories?: string[]; + + @ApiPropertyOptional() + tags?: string[]; + + @ApiPropertyOptional() + rating?: number; + + @ApiProperty() + reviewCount: number; + + @ApiPropertyOptional() + policies?: PoliciesDto; + + @ApiPropertyOptional() + settings?: StoreSettingsDto; + + @ApiProperty() + status: StoreStatus; + + @ApiProperty() + isVerified: boolean; + + @ApiProperty() + isFeatured: boolean; + + @ApiPropertyOptional() + verifiedAt?: Date; + + @ApiPropertyOptional() + featuredAt?: Date; + + @ApiProperty() + sellerId: number; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; +} diff --git a/src/modules/stores/entities/store.entity.ts b/src/modules/stores/entities/store.entity.ts new file mode 100644 index 0000000..7ff599a --- /dev/null +++ b/src/modules/stores/entities/store.entity.ts @@ -0,0 +1,134 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +export enum StoreStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + SUSPENDED = 'suspended', + PENDING_APPROVAL = 'pending_approval', +} + +@Entity('stores') +export class Store { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ nullable: true }) + logo?: string; + + @Column({ nullable: true }) + banner?: string; + + @Column({ type: 'jsonb', nullable: true }) + contactInfo?: { + phone?: string; + email?: string; + website?: string; + socialMedia?: { + facebook?: string; + twitter?: string; + instagram?: string; + linkedin?: string; + }; + }; + + @Column({ type: 'jsonb', nullable: true }) + address?: { + street?: string; + city?: string; + state?: string; + country?: string; + postalCode?: string; + coordinates?: { + latitude?: number; + longitude?: number; + }; + }; + + @Column({ type: 'jsonb', nullable: true }) + businessHours?: { + monday?: { open: string; close: string; closed: boolean }; + tuesday?: { open: string; close: string; closed: boolean }; + wednesday?: { open: string; close: string; closed: boolean }; + thursday?: { open: string; close: string; closed: boolean }; + friday?: { open: string; close: string; closed: boolean }; + saturday?: { open: string; close: string; closed: boolean }; + sunday?: { open: string; close: string; closed: boolean }; + }; + + @Column({ type: 'jsonb', nullable: true }) + categories?: string[]; + + @Column({ type: 'jsonb', nullable: true }) + tags?: string[]; + + @Column({ type: 'decimal', precision: 3, scale: 2, nullable: true }) + rating?: number; + + @Column({ type: 'int', default: 0 }) + reviewCount: number; + + @Column({ type: 'jsonb', nullable: true }) + policies?: { + returnPolicy?: string; + shippingPolicy?: string; + privacyPolicy?: string; + termsOfService?: string; + }; + + @Column({ type: 'jsonb', nullable: true }) + settings?: { + autoApproveReviews?: boolean; + emailNotifications?: boolean; + smsNotifications?: boolean; + pushNotifications?: boolean; + }; + + @Column({ + type: 'enum', + enum: StoreStatus, + default: StoreStatus.PENDING_APPROVAL, + }) + status: StoreStatus; + + @Column({ type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ type: 'boolean', default: false }) + isFeatured: boolean; + + @Column({ type: 'timestamp', nullable: true }) + verifiedAt?: Date; + + @Column({ type: 'timestamp', nullable: true }) + featuredAt?: Date; + + // Relationships + @ManyToOne(() => User, (user) => user.stores) + @JoinColumn({ name: 'seller_id' }) + seller: User; + + @Column() + sellerId: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/stores/services/store.service.ts b/src/modules/stores/services/store.service.ts new file mode 100644 index 0000000..cc36b05 --- /dev/null +++ b/src/modules/stores/services/store.service.ts @@ -0,0 +1,234 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Store, StoreStatus } from '../entities/store.entity'; +import { User } from '../../users/entities/user.entity'; +import { CreateStoreDto, UpdateStoreDto } from '../dto/store.dto'; + +@Injectable() +export class StoreService { + constructor( + @InjectRepository(Store) + private storeRepository: Repository, + @InjectRepository(User) + private userRepository: Repository, + ) {} + + /** + * Create a default store for a seller + */ + async createDefaultStore(sellerId: number, sellerData: any): Promise { + const seller = await this.userRepository.findOne({ + where: { id: sellerId }, + relations: ['userRoles'], + }); + + if (!seller) { + throw new NotFoundException('Seller not found'); + } + + // Check if seller already has a default store + const existingStore = await this.storeRepository.findOne({ + where: { sellerId, name: `${seller.name || 'My Store'}'s Store` }, + }); + + if (existingStore) { + return existingStore; + } + + // Create default store based on seller data + const defaultStore = this.storeRepository.create({ + name: `${seller.name || 'My Store'}'s Store`, + description: sellerData?.businessDescription || 'Welcome to my store!', + categories: sellerData?.categories || [], + contactInfo: { + email: seller.email, + phone: sellerData?.phone, + website: sellerData?.website, + }, + address: { + city: seller.location, + country: seller.country, + }, + sellerId, + status: StoreStatus.PENDING_APPROVAL, + }); + + return await this.storeRepository.save(defaultStore); + } + + /** + * Create a new store for a seller + */ + async createStore(sellerId: number, createStoreDto: CreateStoreDto): Promise { + const seller = await this.userRepository.findOne({ + where: { id: sellerId }, + relations: ['userRoles'], + }); + + if (!seller) { + throw new NotFoundException('Seller not found'); + } + + // Check if seller has seller role + const hasSellerRole = seller.userRoles.some(ur => ur.role.name === 'seller'); + if (!hasSellerRole) { + throw new BadRequestException('Only sellers can create stores'); + } + + const store = this.storeRepository.create({ + ...createStoreDto, + sellerId, + status: StoreStatus.PENDING_APPROVAL, + }); + + return await this.storeRepository.save(store); + } + + /** + * Get all stores for a seller + */ + async getSellerStores(sellerId: number): Promise { + return await this.storeRepository.find({ + where: { sellerId }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Get a specific store by ID + */ + async getStoreById(storeId: number): Promise { + const store = await this.storeRepository.findOne({ + where: { id: storeId }, + relations: ['seller'], + }); + + if (!store) { + throw new NotFoundException('Store not found'); + } + + return store; + } + + /** + * Update a store + */ + async updateStore(storeId: number, sellerId: number, updateStoreDto: UpdateStoreDto): Promise { + const store = await this.storeRepository.findOne({ + where: { id: storeId, sellerId }, + }); + + if (!store) { + throw new NotFoundException('Store not found or access denied'); + } + + Object.assign(store, updateStoreDto); + return await this.storeRepository.save(store); + } + + /** + * Delete a store + */ + async deleteStore(storeId: number, sellerId: number): Promise { + const store = await this.storeRepository.findOne({ + where: { id: storeId, sellerId }, + }); + + if (!store) { + throw new NotFoundException('Store not found or access denied'); + } + + await this.storeRepository.remove(store); + } + + /** + * Get all active stores + */ + async getActiveStores(): Promise { + return await this.storeRepository.find({ + where: { status: StoreStatus.ACTIVE }, + relations: ['seller'], + order: { rating: 'DESC', reviewCount: 'DESC' }, + }); + } + + /** + * Search stores by category, location, or name + */ + async searchStores(query: string, category?: string, location?: string): Promise { + const queryBuilder = this.storeRepository + .createQueryBuilder('store') + .leftJoinAndSelect('store.seller', 'seller') + .where('store.status = :status', { status: StoreStatus.ACTIVE }); + + if (query) { + queryBuilder.andWhere( + '(store.name ILIKE :query OR store.description ILIKE :query)', + { query: `%${query}%` } + ); + } + + if (category) { + queryBuilder.andWhere('store.categories @> :category', { category: [category] }); + } + + if (location) { + queryBuilder.andWhere( + '(store.address->>\'city\' ILIKE :location OR store.address->>\'country\' ILIKE :location)', + { location: `%${location}%` } + ); + } + + return await queryBuilder + .orderBy('store.rating', 'DESC') + .addOrderBy('store.reviewCount', 'DESC') + .getMany(); + } + + /** + * Update store status (admin only) + */ + async updateStoreStatus(storeId: number, status: StoreStatus): Promise { + const store = await this.storeRepository.findOne({ + where: { id: storeId }, + }); + + if (!store) { + throw new NotFoundException('Store not found'); + } + + store.status = status; + + if (status === StoreStatus.ACTIVE && !store.verifiedAt) { + store.verifiedAt = new Date(); + } + + return await this.storeRepository.save(store); + } + + /** + * Get store statistics + */ + async getStoreStats(storeId: number, sellerId: number): Promise { + const store = await this.storeRepository.findOne({ + where: { id: storeId, sellerId }, + }); + + if (!store) { + throw new NotFoundException('Store not found or access denied'); + } + + // Here you would typically aggregate data from orders, reviews, etc. + // For now, returning basic store info + return { + id: store.id, + name: store.name, + status: store.status, + rating: store.rating, + reviewCount: store.reviewCount, + createdAt: store.createdAt, + verifiedAt: store.verifiedAt, + }; + } +} diff --git a/src/modules/stores/stores.module.ts b/src/modules/stores/stores.module.ts new file mode 100644 index 0000000..1a1bcb9 --- /dev/null +++ b/src/modules/stores/stores.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { StoreController } from './controllers/store.controller'; +import { StoreService } from './services/store.service'; +import { Store } from './entities/store.entity'; +import { User } from '../users/entities/user.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Store, User])], + controllers: [StoreController], + providers: [StoreService], + exports: [StoreService], +}) +export class StoresModule {} diff --git a/src/modules/users/controllers/user.controller.ts b/src/modules/users/controllers/user.controller.ts index 4369651..e82beea 100644 --- a/src/modules/users/controllers/user.controller.ts +++ b/src/modules/users/controllers/user.controller.ts @@ -10,7 +10,6 @@ import { UseGuards, HttpStatus, HttpCode, - ParseIntPipe, } from '@nestjs/common'; import { Request, Response } from 'express'; import { UserService } from '../services/user.service'; @@ -23,11 +22,14 @@ import { Roles } from '../../auth/decorators/roles.decorator'; import { Role } from '../../../types/role'; interface UserResponse { - id: number; walletAddress: string; name: string; email: string; role: string; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; createdAt?: Date; updatedAt?: Date; } @@ -60,6 +62,7 @@ export class UserController { */ @Post() @HttpCode(HttpStatus.CREATED) + async createUser( @Body() registerDto: RegisterUserDto, @Res({ passthrough: true }) res: Response @@ -69,6 +72,10 @@ export class UserController { role: registerDto.role, name: registerDto.name, email: registerDto.email, + location: registerDto.location, + country: registerDto.country, + buyerData: registerDto.buyerData, + sellerData: registerDto.sellerData, }); // Set JWT token in HttpOnly cookie @@ -83,11 +90,14 @@ export class UserController { success: true, data: { user: { - id: result.user.id, walletAddress: result.user.walletAddress, name: result.user.name, email: result.user.email, role: result.user.userRoles?.[0]?.role?.name || 'buyer', + location: result.user.location, + country: result.user.country, + buyerData: result.user.buyerData, + sellerData: result.user.sellerData, }, expiresIn: result.expiresIn, }, @@ -96,68 +106,74 @@ export class UserController { /** * Update user information - * PUT /users/update/:id + * PUT /users/update/:walletAddress */ - @Put('update/:id') + @Put('update/:walletAddress') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) async updateUser( - @Param('id', ParseIntPipe) userId: number, + @Param('walletAddress') walletAddress: string, @Body() updateDto: UpdateUserDto, @Req() req: Request ): Promise { - const currentUserId = req.user?.id; + const currentUserWalletAddress = req.user?.walletAddress; const currentUserRole = req.user?.role?.[0]; // Check if user is updating their own profile or is admin - if (userId !== currentUserId && currentUserRole !== 'admin') { + if (walletAddress !== currentUserWalletAddress && currentUserRole !== 'admin') { throw new UnauthorizedError('You can only update your own profile'); } - const updatedUser = await this.authService.updateUser(userId, updateDto); + const updatedUser = await this.userService.updateUser(walletAddress, updateDto); return { success: true, data: { - id: updatedUser.id, walletAddress: updatedUser.walletAddress, name: updatedUser.name, email: updatedUser.email, role: updatedUser.userRoles?.[0]?.role?.name || 'buyer', + location: updatedUser.location, + country: updatedUser.country, + buyerData: updatedUser.buyerData, + sellerData: updatedUser.sellerData, updatedAt: updatedUser.updatedAt, }, }; } /** - * Get user by ID (admin only or own profile) - * GET /users/:id + * Get user by wallet address (admin only or own profile) + * GET /users/:walletAddress */ - @Get(':id') + @Get(':walletAddress') @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) - async getUserById( - @Param('id', ParseIntPipe) userId: number, + async getUserByWalletAddress( + @Param('walletAddress') walletAddress: string, @Req() req: Request ): Promise { - const currentUserId = req.user?.id; + const currentUserWalletAddress = req.user?.walletAddress; const currentUserRole = req.user?.role?.[0]; // Check if user is accessing their own profile or is admin - if (userId !== currentUserId && currentUserRole !== 'admin') { + if (walletAddress !== currentUserWalletAddress && currentUserRole !== 'admin') { throw new UnauthorizedError('Access denied'); } - const user = await this.userService.getUserById(String(userId)); + const user = await this.userService.getUserByWalletAddress(walletAddress); return { success: true, data: { - id: user.id, walletAddress: user.walletAddress, name: user.name, email: user.email, role: user.userRoles?.[0]?.role?.name || 'buyer', + location: user.location, + country: user.country, + buyerData: user.buyerData, + sellerData: user.sellerData, createdAt: user.createdAt, updatedAt: user.updatedAt, }, @@ -178,11 +194,14 @@ export class UserController { return { success: true, data: users.map((user) => ({ - id: user.id, walletAddress: user.walletAddress, name: user.name, email: user.email, role: user.userRoles?.[0]?.role?.name || 'buyer', + location: user.location, + country: user.country, + buyerData: user.buyerData, + sellerData: user.sellerData, createdAt: user.createdAt, updatedAt: user.updatedAt, })), diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index f191c38..3c11478 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -5,16 +5,19 @@ import { OneToMany, CreateDateColumn, UpdateDateColumn, + Index, } from 'typeorm'; import { Order } from '../../orders/entities/order.entity'; import { UserRole } from '../../auth/entities/user-role.entity'; import { Notification } from '../../notifications/entities/notification.entity'; import { Wishlist } from '../../wishlist/entities/wishlist.entity'; +import { CountryCode } from '../enums/country-code.enum'; +import { Store } from '../../stores/entities/store.entity'; @Entity('users') export class User { - @PrimaryGeneratedColumn() - id: number; + @PrimaryGeneratedColumn('uuid') + id: string; @Column({ unique: true, nullable: true }) email?: string; @@ -23,8 +26,25 @@ export class User { name?: string; @Column({ unique: true }) + @Index() walletAddress: string; + + @Column({ length: 2, nullable: true, enum: CountryCode }) + country?: string; + + @Column({ nullable: true }) + location?: string; + + @Column({ nullable: true }) + country?: string; + + @Column({ type: 'json', nullable: true }) + buyerData?: any; + + @Column({ type: 'json', nullable: true }) + sellerData?: any; + @OneToMany(() => Order, (order) => order.user) orders: Order[]; @@ -37,6 +57,9 @@ export class User { @OneToMany(() => Wishlist, (wishlist) => wishlist.user) wishlist: Wishlist[]; + @OneToMany(() => Store, (store) => store.seller) + stores: Store[]; + @CreateDateColumn() createdAt: Date; diff --git a/src/modules/users/enums/country-code.enum.ts b/src/modules/users/enums/country-code.enum.ts new file mode 100644 index 0000000..eb8bc52 --- /dev/null +++ b/src/modules/users/enums/country-code.enum.ts @@ -0,0 +1,260 @@ +/** + * Important notes: + * + * This list follows the ISO 3166-1 standard updated to 2024. + * It includes all officially assigned codes (249 in total). + * Special codes like EU (European Union) or XK (Kosovo) are not included because they are not official ISO 3166-1 codes (even if used in some contexts). + * Each entry has a comment with the associated country/territory name (optional, you can remove them if not needed). + */ + +export enum CountryCode { + AD = 'AD', // Andorra + AE = 'AE', // United Arab Emirates + AF = 'AF', // Afghanistan + AG = 'AG', // Antigua and Barbuda + AI = 'AI', // Anguilla + AL = 'AL', // Albania + AM = 'AM', // Armenia + AO = 'AO', // Angola + AQ = 'AQ', // Antarctica + AR = 'AR', // Argentina + AS = 'AS', // American Samoa + AT = 'AT', // Austria + AU = 'AU', // Australia + AW = 'AW', // Aruba + AX = 'AX', // ร…land Islands + AZ = 'AZ', // Azerbaijan + BA = 'BA', // Bosnia and Herzegovina + BB = 'BB', // Barbados + BD = 'BD', // Bangladesh + BE = 'BE', // Belgium + BF = 'BF', // Burkina Faso + BG = 'BG', // Bulgaria + BH = 'BH', // Bahrain + BI = 'BI', // Burundi + BJ = 'BJ', // Benin + BL = 'BL', // Saint Barthรฉlemy + BM = 'BM', // Bermuda + BN = 'BN', // Brunei + BO = 'BO', // Bolivia + BQ = 'BQ', // Caribbean Netherlands + BR = 'BR', // Brazil + BS = 'BS', // Bahamas + BT = 'BT', // Bhutan + BV = 'BV', // Bouvet Island + BW = 'BW', // Botswana + BY = 'BY', // Belarus + BZ = 'BZ', // Belize + CA = 'CA', // Canada + CC = 'CC', // Cocos (Keeling) Islands + CD = 'CD', // Congo (DRC) + CF = 'CF', // Central African Republic + CG = 'CG', // Congo + CH = 'CH', // Switzerland + CI = 'CI', // Cรดte d'Ivoire + CK = 'CK', // Cook Islands + CL = 'CL', // Chile + CM = 'CM', // Cameroon + CN = 'CN', // China + CO = 'CO', // Colombia + CR = 'CR', // Costa Rica + CU = 'CU', // Cuba + CV = 'CV', // Cape Verde + CW = 'CW', // Curaรงao + CX = 'CX', // Christmas Island + CY = 'CY', // Cyprus + CZ = 'CZ', // Czechia + DE = 'DE', // Germany + DJ = 'DJ', // Djibouti + DK = 'DK', // Denmark + DM = 'DM', // Dominica + DO = 'DO', // Dominican Republic + DZ = 'DZ', // Algeria + EC = 'EC', // Ecuador + EE = 'EE', // Estonia + EG = 'EG', // Egypt + EH = 'EH', // Western Sahara + ER = 'ER', // Eritrea + ES = 'ES', // Spain + ET = 'ET', // Ethiopia + FI = 'FI', // Finland + FJ = 'FJ', // Fiji + FK = 'FK', // Falkland Islands + FM = 'FM', // Micronesia + FO = 'FO', // Faroe Islands + FR = 'FR', // France + GA = 'GA', // Gabon + GB = 'GB', // United Kingdom + GD = 'GD', // Grenada + GE = 'GE', // Georgia + GF = 'GF', // French Guiana + GG = 'GG', // Guernsey + GH = 'GH', // Ghana + GI = 'GI', // Gibraltar + GL = 'GL', // Greenland + GM = 'GM', // Gambia + GN = 'GN', // Guinea + GP = 'GP', // Guadeloupe + GQ = 'GQ', // Equatorial Guinea + GR = 'GR', // Greece + GS = 'GS', // South Georgia and the South Sandwich Islands + GT = 'GT', // Guatemala + GU = 'GU', // Guam + GW = 'GW', // Guinea-Bissau + GY = 'GY', // Guyana + HK = 'HK', // Hong Kong + HM = 'HM', // Heard Island and McDonald Islands + HN = 'HN', // Honduras + HR = 'HR', // Croatia + HT = 'HT', // Haiti + HU = 'HU', // Hungary + ID = 'ID', // Indonesia + IE = 'IE', // Ireland + IL = 'IL', // Israel + IM = 'IM', // Isle of Man + IN = 'IN', // India + IO = 'IO', // British Indian Ocean Territory + IQ = 'IQ', // Iraq + IR = 'IR', // Iran + IS = 'IS', // Iceland + IT = 'IT', // Italy + JE = 'JE', // Jersey + JM = 'JM', // Jamaica + JO = 'JO', // Jordan + JP = 'JP', // Japan + KE = 'KE', // Kenya + KG = 'KG', // Kyrgyzstan + KH = 'KH', // Cambodia + KI = 'KI', // Kiribati + KM = 'KM', // Comoros + KN = 'KN', // Saint Kitts and Nevis + KP = 'KP', // North Korea + KR = 'KR', // South Korea + KW = 'KW', // Kuwait + KY = 'KY', // Cayman Islands + KZ = 'KZ', // Kazakhstan + LA = 'LA', // Laos + LB = 'LB', // Lebanon + LC = 'LC', // Saint Lucia + LI = 'LI', // Liechtenstein + LK = 'LK', // Sri Lanka + LR = 'LR', // Liberia + LS = 'LS', // Lesotho + LT = 'LT', // Lithuania + LU = 'LU', // Luxembourg + LV = 'LV', // Latvia + LY = 'LY', // Libya + MA = 'MA', // Morocco + MC = 'MC', // Monaco + MD = 'MD', // Moldova + ME = 'ME', // Montenegro + MF = 'MF', // Saint Martin + MG = 'MG', // Madagascar + MH = 'MH', // Marshall Islands + MK = 'MK', // North Macedonia + ML = 'ML', // Mali + MM = 'MM', // Myanmar + MN = 'MN', // Mongolia + MO = 'MO', // Macao + MP = 'MP', // Northern Mariana Islands + MQ = 'MQ', // Martinique + MR = 'MR', // Mauritania + MS = 'MS', // Montserrat + MT = 'MT', // Malta + MU = 'MU', // Mauritius + MV = 'MV', // Maldives + MW = 'MW', // Malawi + MX = 'MX', // Mexico + MY = 'MY', // Malaysia + MZ = 'MZ', // Mozambique + NA = 'NA', // Namibia + NC = 'NC', // New Caledonia + NE = 'NE', // Niger + NF = 'NF', // Norfolk Island + NG = 'NG', // Nigeria + NI = 'NI', // Nicaragua + NL = 'NL', // Netherlands + NO = 'NO', // Norway + NP = 'NP', // Nepal + NR = 'NR', // Nauru + NU = 'NU', // Niue + NZ = 'NZ', // New Zealand + OM = 'OM', // Oman + PA = 'PA', // Panama + PE = 'PE', // Peru + PF = 'PF', // French Polynesia + PG = 'PG', // Papua New Guinea + PH = 'PH', // Philippines + PK = 'PK', // Pakistan + PL = 'PL', // Poland + PM = 'PM', // Saint Pierre and Miquelon + PN = 'PN', // Pitcairn Islands + PR = 'PR', // Puerto Rico + PS = 'PS', // Palestine + PT = 'PT', // Portugal + PW = 'PW', // Palau + PY = 'PY', // Paraguay + QA = 'QA', // Qatar + RE = 'RE', // Rรฉunion + RO = 'RO', // Romania + RS = 'RS', // Serbia + RU = 'RU', // Russia + RW = 'RW', // Rwanda + SA = 'SA', // Saudi Arabia + SB = 'SB', // Solomon Islands + SC = 'SC', // Seychelles + SD = 'SD', // Sudan + SE = 'SE', // Sweden + SG = 'SG', // Singapore + SH = 'SH', // Saint Helena + SI = 'SI', // Slovenia + SJ = 'SJ', // Svalbard and Jan Mayen + SK = 'SK', // Slovakia + SL = 'SL', // Sierra Leone + SM = 'SM', // San Marino + SN = 'SN', // Senegal + SO = 'SO', // Somalia + SR = 'SR', // Suriname + SS = 'SS', // South Sudan + ST = 'ST', // Sรฃo Tomรฉ and Prรญncipe + SV = 'SV', // El Salvador + SX = 'SX', // Sint Maarten + SY = 'SY', // Syria + SZ = 'SZ', // Eswatini + TC = 'TC', // Turks and Caicos Islands + TD = 'TD', // Chad + TF = 'TF', // French Southern Territories + TG = 'TG', // Togo + TH = 'TH', // Thailand + TJ = 'TJ', // Tajikistan + TK = 'TK', // Tokelau + TL = 'TL', // Timor-Leste + TM = 'TM', // Turkmenistan + TN = 'TN', // Tunisia + TO = 'TO', // Tonga + TR = 'TR', // Turkey + TT = 'TT', // Trinidad and Tobago + TV = 'TV', // Tuvalu + TW = 'TW', // Taiwan + TZ = 'TZ', // Tanzania + UA = 'UA', // Ukraine + UG = 'UG', // Uganda + UM = 'UM', // United States Minor Outlying Islands + US = 'US', // United States + UY = 'UY', // Uruguay + UZ = 'UZ', // Uzbekistan + VA = 'VA', // Vatican City + VC = 'VC', // Saint Vincent and the Grenadines + VE = 'VE', // Venezuela + VG = 'VG', // British Virgin Islands + VI = 'VI', // U.S. Virgin Islands + VN = 'VN', // Vietnam + VU = 'VU', // Vanuatu + WF = 'WF', // Wallis and Futuna + WS = 'WS', // Samoa + YE = 'YE', // Yemen + YT = 'YT', // Mayotte + ZA = 'ZA', // South Africa + ZM = 'ZM', // Zambia + ZW = 'ZW' // Zimbabwe +} \ No newline at end of file diff --git a/src/modules/users/services/user.service.ts b/src/modules/users/services/user.service.ts index f82ab33..a033c23 100644 --- a/src/modules/users/services/user.service.ts +++ b/src/modules/users/services/user.service.ts @@ -12,6 +12,10 @@ export class UserService { name?: string; email?: string; role: 'buyer' | 'seller' | 'admin'; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; }): Promise { const existing = await this.userRepository.findOne({ where: { walletAddress: data.walletAddress }, @@ -20,14 +24,34 @@ export class UserService { throw new BadRequestError('Wallet address already registered'); } + // Validate role-specific data + if (data.role === 'buyer' && data.buyerData === undefined) { + throw new BadRequestError('Buyer data is required for buyer role'); + } + if (data.role === 'seller' && data.sellerData === undefined) { + throw new BadRequestError('Seller data is required for seller role'); + } + + // Validate that buyers can't have seller data and sellers can't have buyer data + if (data.role === 'buyer' && data.sellerData !== undefined) { + throw new BadRequestError('Buyers cannot have seller data'); + } + if (data.role === 'seller' && data.buyerData !== undefined) { + throw new BadRequestError('Sellers cannot have buyer data'); + } + const user = this.userRepository.create({ walletAddress: data.walletAddress, name: data.name, email: data.email, + location: data.location, + country: data.country, + buyerData: data.buyerData, + sellerData: data.sellerData, }); const saved = await this.userRepository.save(user); - // assign role + // assign role to user_roles table const roleRepo = AppDataSource.getRepository(Role); const userRoleRepo = AppDataSource.getRepository(UserRole); const role = await roleRepo.findOne({ where: { name: data.role } }); @@ -43,9 +67,20 @@ export class UserService { return saved; } + async getUserByWalletAddress(walletAddress: string): Promise { + const user = await this.userRepository.findOne({ + where: { walletAddress }, + relations: ['userRoles', 'userRoles.role'], + }); + if (!user) { + throw new BadRequestError('User not found'); + } + return user; + } + async getUserById(id: string): Promise { const user = await this.userRepository.findOne({ - where: { id: parseInt(id) }, + where: { id }, relations: ['userRoles', 'userRoles.role'], }); if (!user) { @@ -58,7 +93,67 @@ export class UserService { return this.userRepository.find({ relations: ['userRoles', 'userRoles.role'] }); } - async updateUser(id: string, data: { name?: string; email?: string }): Promise { + /** + * Update user using walletAddress as primary identifier + */ + async updateUser( + walletAddress: string, + data: { + name?: string; + email?: string; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; + }, + ): Promise { + const user = await this.getUserByWalletAddress(walletAddress); + + if (data.email) { + const existingUser = await this.userRepository.findOne({ where: { email: data.email } }); + if (existingUser && existingUser.id !== user.id) { + throw new BadRequestError('Email already in use'); + } + user.email = data.email; + } + + if (data.name) { + user.name = data.name; + } + + if (data.location !== undefined) { + user.location = data.location; + } + + if (data.country !== undefined) { + user.country = data.country; + } + + if (data.buyerData !== undefined) { + user.buyerData = data.buyerData; + } + + if (data.sellerData !== undefined) { + user.sellerData = data.sellerData; + } + + return this.userRepository.save(user); + } + + /** + * Compat method: Update by user ID (mantiene compatibilidad con develop) + */ + async updateUserById( + id: string, + data: { + name?: string; + email?: string; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; + }, + ): Promise { const user = await this.getUserById(id); if (data.email) { @@ -73,12 +168,28 @@ export class UserService { user.name = data.name; } + if (data.location !== undefined) { + user.location = data.location; + } + + if (data.country !== undefined) { + user.country = data.country; + } + + if (data.buyerData !== undefined) { + user.buyerData = data.buyerData; + } + + if (data.sellerData !== undefined) { + user.sellerData = data.sellerData; + } + return this.userRepository.save(user); } - async getUserOrders(id: string): Promise { + async getUserOrders(walletAddress: string): Promise { const user = await this.userRepository.findOne({ - where: { id: parseInt(id) }, + where: { walletAddress }, relations: ['orders'], }); @@ -89,9 +200,9 @@ export class UserService { return user.orders; } - async getUserWishlist(id: string): Promise { + async getUserWishlist(walletAddress: string): Promise { const user = await this.userRepository.findOne({ - where: { id: parseInt(id) }, + where: { walletAddress }, relations: ['wishlist'], }); diff --git a/src/modules/users/tests/user-update-api.spec.ts b/src/modules/users/tests/user-update-api.spec.ts new file mode 100644 index 0000000..12b9695 --- /dev/null +++ b/src/modules/users/tests/user-update-api.spec.ts @@ -0,0 +1,273 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UserController } from '../controllers/user.controller'; +import { UserService } from '../services/user.service'; +import { AuthService } from '../../auth/services/auth.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { UnauthorizedError } from '../../../utils/errors'; + +describe('UserController - Update API Tests', () => { + let controller: UserController; + let userService: UserService; + let authService: AuthService; + + const mockUserService = { + updateUser: jest.fn(), + getUserByWalletAddress: jest.fn(), + getUsers: jest.fn(), + }; + + const mockAuthService = { + registerWithWallet: jest.fn(), + }; + + const mockJwtAuthGuard = { + canActivate: jest.fn(), + }; + + const mockRolesGuard = { + canActivate: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UserController], + providers: [ + { provide: UserService, useValue: mockUserService }, + { provide: AuthService, useValue: mockAuthService }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue(mockJwtAuthGuard) + .overrideGuard(RolesGuard) + .useValue(mockRolesGuard) + .compile(); + + controller = module.get(UserController); + userService = module.get(UserService); + authService = module.get(AuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('PUT /users/update/:walletAddress', () => { + const mockWalletAddress = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890'; + const mockUpdateDto = { + name: 'Updated Name', + email: 'updated@example.com', + }; + + const mockUser = { + id: '550e8400-e29b-41d4-a716-446655440000', // UUID + walletAddress: mockWalletAddress, + name: 'Updated Name', + email: 'updated@example.com', + userRoles: [{ role: { name: 'buyer' } }], + updatedAt: new Date(), + }; + + const mockRequest = { + user: { + id: '550e8400-e29b-41d4-a716-446655440000', + walletAddress: mockWalletAddress, + role: ['buyer'], + }, + }; + + it('should successfully update user profile when user updates their own profile', async () => { + mockUserService.updateUser.mockResolvedValue(mockUser); + + const result = await controller.updateUser(mockWalletAddress, mockUpdateDto, mockRequest as any); + + expect(mockUserService.updateUser).toHaveBeenCalledWith(mockWalletAddress, mockUpdateDto); + expect(result).toEqual({ + success: true, + data: { + walletAddress: mockWalletAddress, + name: 'Updated Name', + email: 'updated@example.com', + role: 'buyer', + updatedAt: mockUser.updatedAt, + }, + }); + }); + + it('should allow admin to update any user profile', async () => { + const adminRequest = { + user: { + id: '550e8400-e29b-41d4-a716-446655440001', + walletAddress: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890', + role: ['admin'], + }, + }; + + mockUserService.updateUser.mockResolvedValue(mockUser); + + const result = await controller.updateUser(mockWalletAddress, mockUpdateDto, adminRequest as any); + + expect(mockUserService.updateUser).toHaveBeenCalledWith(mockWalletAddress, mockUpdateDto); + expect(result.success).toBe(true); + }); + + it('should throw UnauthorizedError when user tries to update another user profile', async () => { + const otherUserRequest = { + user: { + id: '550e8400-e29b-41d4-a716-446655440001', + walletAddress: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890', + role: ['buyer'], + }, + }; + + await expect( + controller.updateUser(mockWalletAddress, mockUpdateDto, otherUserRequest as any) + ).rejects.toThrow(UnauthorizedError); + + expect(mockUserService.updateUser).not.toHaveBeenCalled(); + }); + + it('should handle partial updates correctly', async () => { + const partialUpdateDto = { name: 'New Name Only' }; + const partialUser = { ...mockUser, name: 'New Name Only' }; + + mockUserService.updateUser.mockResolvedValue(partialUser); + + const result = await controller.updateUser(mockWalletAddress, partialUpdateDto, mockRequest as any); + + expect(mockUserService.updateUser).toHaveBeenCalledWith(mockWalletAddress, partialUpdateDto); + expect(result.data.name).toBe('New Name Only'); + expect(result.data.email).toBe('updated@example.com'); // Should retain existing value + }); + + it('should handle email-only updates correctly', async () => { + const emailOnlyUpdateDto = { email: 'newemail@example.com' }; + const emailOnlyUser = { ...mockUser, email: 'newemail@example.com' }; + + mockUserService.updateUser.mockResolvedValue(emailOnlyUser); + + const result = await controller.updateUser(mockWalletAddress, emailOnlyUpdateDto, mockRequest as any); + + expect(mockUserService.updateUser).toHaveBeenCalledWith(mockWalletAddress, emailOnlyUpdateDto); + expect(result.data.email).toBe('newemail@example.com'); + expect(result.data.name).toBe('Updated Name'); // Should retain existing value + }); + }); + + describe('GET /users/:walletAddress', () => { + const mockWalletAddress = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890'; + + const mockUser = { + id: '550e8400-e29b-41d4-a716-446655440000', + walletAddress: mockWalletAddress, + name: 'Test User', + email: 'test@example.com', + userRoles: [{ role: { name: 'buyer' } }], + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-02'), + }; + + it('should return user profile when user accesses their own profile', async () => { + const mockRequest = { + user: { + id: '550e8400-e29b-41d4-a716-446655440000', + walletAddress: mockWalletAddress, + role: ['buyer'], + }, + }; + + mockUserService.getUserByWalletAddress.mockResolvedValue(mockUser); + + const result = await controller.getUserByWalletAddress(mockWalletAddress, mockRequest as any); + + expect(mockUserService.getUserByWalletAddress).toHaveBeenCalledWith(mockWalletAddress); + expect(result).toEqual({ + success: true, + data: { + walletAddress: mockWalletAddress, + name: 'Test User', + email: 'test@example.com', + role: 'buyer', + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }, + }); + }); + + it('should allow admin to access any user profile', async () => { + const adminRequest = { + user: { + id: '550e8400-e29b-41d4-a716-446655440001', + walletAddress: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890', + role: ['admin'], + }, + }; + + mockUserService.getUserByWalletAddress.mockResolvedValue(mockUser); + + const result = await controller.getUserByWalletAddress(mockWalletAddress, adminRequest as any); + + expect(result.success).toBe(true); + expect(result.data.walletAddress).toBe(mockWalletAddress); + }); + + it('should throw UnauthorizedError when user tries to access another user profile', async () => { + const otherUserRequest = { + user: { + id: '550e8400-e29b-41d4-a716-446655440001', + walletAddress: 'GBCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890', + role: ['buyer'], + }, + }; + + await expect( + controller.getUserByWalletAddress(mockWalletAddress, otherUserRequest as any) + ).rejects.toThrow(UnauthorizedError); + + expect(mockUserService.getUserByWalletAddress).not.toHaveBeenCalled(); + }); + }); + + describe('UUID and walletAddress handling', () => { + it('should not expose UUID id in API responses', async () => { + const mockUser = { + id: '550e8400-e29b-41d4-a716-446655440000', // UUID + walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890', + name: 'Test User', + email: 'test@example.com', + userRoles: [{ role: { name: 'buyer' } }], + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockRequest = { + user: { + id: '550e8400-e29b-41d4-a716-446655440000', + walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123456789012345678901234567890', + role: ['buyer'], + }, + }; + + mockUserService.getUserByWalletAddress.mockResolvedValue(mockUser); + + const result = await controller.getUserByWalletAddress(mockUser.walletAddress, mockRequest as any); + + // Verify that UUID id is not exposed in the response + expect(result.data).not.toHaveProperty('id'); + expect(result.data.walletAddress).toBe(mockUser.walletAddress); + }); + + it('should use walletAddress as the primary identifier in routes', () => { + // This test verifies that the controller methods are designed to use walletAddress + expect(controller.updateUser).toBeDefined(); + expect(controller.getUserByWalletAddress).toBeDefined(); + + // The method signatures should use walletAddress parameter + const updateMethod = controller.updateUser.toString(); + const getMethod = controller.getUserByWalletAddress.toString(); + + expect(updateMethod).toContain('walletAddress'); + expect(getMethod).toContain('walletAddress'); + }); + }); +}); diff --git a/src/modules/wishlist/common/types/auth-request.type.ts b/src/modules/wishlist/common/types/auth-request.type.ts index 6822d2c..21b6e96 100644 --- a/src/modules/wishlist/common/types/auth-request.type.ts +++ b/src/modules/wishlist/common/types/auth-request.type.ts @@ -8,6 +8,10 @@ export interface AuthRequest extends Request { name?: string; email?: string; role: Role[]; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; createdAt?: Date; updatedAt?: Date; }; diff --git a/src/modules/wishlist/tests/wishlist.controller.spec.ts b/src/modules/wishlist/tests/wishlist.controller.spec.ts index 84ca350..1686dcc 100644 --- a/src/modules/wishlist/tests/wishlist.controller.spec.ts +++ b/src/modules/wishlist/tests/wishlist.controller.spec.ts @@ -82,7 +82,7 @@ describe('WishlistController', () => { user: { id: userId, walletAddress: 'test-wallet', - role: [Role.USER], + role: [Role.BUYER], }, }) as unknown as AuthRequest; @@ -99,7 +99,7 @@ describe('WishlistController', () => { user: { id: userId, walletAddress: 'test-wallet', - role: [Role.USER], + role: [Role.BUYER], }, }) as unknown as AuthRequest; @@ -119,7 +119,7 @@ describe('WishlistController', () => { user: { id: userId, walletAddress: 'test-wallet', - role: [Role.USER], + role: [Role.BUYER], }, }) as unknown as AuthRequest; @@ -137,7 +137,7 @@ describe('WishlistController', () => { user: { id: userId, walletAddress: 'test-wallet', - role: [Role.USER], + role: [Role.BUYER], }, }) as unknown as AuthRequest; const wishlistItems = [new Wishlist()]; diff --git a/src/types/auth-request.type.ts b/src/types/auth-request.type.ts index 8788764..924d2c0 100644 --- a/src/types/auth-request.type.ts +++ b/src/types/auth-request.type.ts @@ -2,7 +2,7 @@ import { Request } from 'express'; import { Role } from './role'; export interface AppUser { - id: string; + id: string; // UUID walletAddress: string; name?: string; email?: string; @@ -13,7 +13,7 @@ export interface AppUser { export interface AuthenticatedRequest extends Request { user: { - id: string | number; + id: string; // UUID walletAddress: string; name?: string; email?: string; diff --git a/src/types/express.d.ts b/src/types/express.d.ts index af7ff75..db91eb5 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -22,6 +22,10 @@ declare module 'express-serve-static-core' { name?: string; email?: string; role: Role[]; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; createdAt?: Date; updatedAt?: Date; }; @@ -38,6 +42,10 @@ declare global { name?: string; email?: string; role: Role[]; + location?: string; + country?: string; + buyerData?: any; + sellerData?: any; createdAt?: Date; updatedAt?: Date; }