diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..48836ed8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,126 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.local +.env.production + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Nx generated files +.nx/cache +.nx/workspace-data + +# Build outputs +dist/ +build/ +tmp/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Git +.git +.gitignore +README.md + +# Docker +.dockerignore +Dockerfile +docker-compose*.yml + +# Database files +*.sqlite +*.db + +# Test files and coverage +coverage/ +.nyc_output/ +test-results/ +playwright-report/ + +# Storybook +storybook-static/ + +# Cypress +cypress/videos/ +cypress/screenshots/ + +# Temporary files +*.tmp +temp/ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ed7ce673..1061d42a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,22 +1,34 @@ -### â„šī¸ Issue +## Description -Closes +Briefly describe the changes and why they are needed. -### 📝 Description +## Changes Made -Write a short summary of what you added. Why is it important? Any member of C4C should be able to read this and understand your contribution -- not just your team members. +- [ ] Backend changes +- [ ] Frontend changes +- [ ] Database schema changes +- [ ] Configuration updates +- [ ] Other -Briefly list the changes made to the code: -1. Added support for this. -2. And removed redunant use of that. -3. Also this was included for reasons. +## Testing & Verification -### âœ”ī¸ Verification +- [ ] Unit tests pass +- [ ] Manual testing completed +- [ ] No breaking changes -What steps did you take to verify your changes work? These should be clear enough for someone to be able to clone the branch and follow the steps themselves. +**Verification Steps:** + + -Provide screenshots of any new components, styling changes, or pages. +## Screenshots (if relevant) -### đŸ•ī¸ (Optional) Future Work / Notes + -Did you notice anything ugly during the course of this ticket? Any bugs, design challenges, or unexpected behavior? Write it down so we can clean it up in a future ticket! +## Future Improvements/Notes + + + + +## Related Issues + +Closes # diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 00000000..5020e122 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,313 @@ +# Docker Development Setup + +## Overview + +**Docker** is like a shipping container for software. Instead of worrying about "works on my machine" problems, Docker packages your entire application with everything it needs to run - code, dependencies, and settings - into portable containers. + +**Why Docker?** + +- Consistent Environment: Everyone runs the same setup +- Easy Setup: No complex local installations +- Isolated Services: Database, backend, and tools run separately +- Resource Efficient: Start/stop services as needed +- Team Friendly: Same environment for all developers + +## Prerequisites + +### Required Software + +- **Docker Desktop** - The main Docker application ([download here](https://www.docker.com/products/docker-desktop/)) +- **Git** - For downloading the project code +- **Node.js & Yarn** - For running project scripts + +### Quick Check + +1. Open Docker Desktop and make sure it's running +2. Open a terminal/command prompt +3. Run `docker --version` to verify Docker is installed + +## Quick Start + +Follow these steps to get your development environment running: + +### Step 1: Get the Project Code + +```bash +git clone +cd fcc +yarn install +``` + +### Step 2: Start Services + +```bash +# Start development services (recommended) +yarn docker:up:dev + +# Or start all services +yarn docker:up:all + +# Alternative: Using Docker Compose directly +docker-compose -f docker-compose.dev.yml up -d postgres adminer backend-dev +``` + +### Step 3: Set Up the Database + +```bash +# Run migrations on development backend +yarn docker:migrate:dev + +# Alternative: Manual command +docker-compose -f docker-compose.dev.yml exec backend-dev sh -c "cd /app && npm run migration:run" +``` + +### Step 4: Verify Everything Works + +- **API**: Visit `http://localhost:3001/api` (development server) +- **Database Viewer**: Visit `http://localhost:8080` (Adminer) +- **Login to Adminer**: System: `PostgreSQL`, Server: `postgres`, Username: `postgres`, Password: `postgres`, Database: `fcc_dev` + +## Services Overview + +### Backend Development Server (Port 3001) + +**Purpose:** Runs your API code with live reloading + +**Use cases:** Development, testing new features, running migrations + +**Features:** + +- Hot Reload: Changes appear instantly without restarting +- Source Access: Can read your TypeScript files +- Development Tools: Better error messages and debugging + +### Backend Production Server (Port 3000) + +**Purpose:** Runs optimized, compiled version of your API + +**Use cases:** Testing production builds, performance testing + +**Features:** + +- Fast: Optimized for speed +- Secure: Production-ready settings +- Compiled: Runs pre-built JavaScript (not source code) + +### PostgreSQL Database (Port 5432) + +**Purpose:** Stores all your application data + +**Database Credentials:** + +- System: `PostgreSQL` +- Host: `postgres` +- Port: `5432` +- Database: `fcc_dev` +- Username: `postgres` +- Password: `postgres` + +**Storage:** Uses `postgres_data` volume to persist database files +**Features:** + +- Persistent: Data survives container restarts via `postgres_data` volume +- Health Checks: Automatically verifies it's working +- Auto Setup: Creates initial database structure + +### Adminer (Port 8080) + +**Purpose:** Web interface to view and edit database data +**Use cases:** Checking data, running quick queries, debugging + +## Docker Volumes + +**What are volumes?** Special folders that persist data outside containers + +**Why do we have 2 volumes?** + +### 1. postgres_data Volume + +- **Purpose:** Stores your database files permanently +- **Location:** `/var/lib/postgresql/data` inside PostgreSQL container +- **Why needed:** Database data must survive container restarts/crashes +- **Size:** Usually small unless you have lots of data + +### 2. backend_logs Volume + +- **Purpose:** Stores application log files +- **Location:** `/app/logs` inside backend containers +- **Why needed:** Logs help debug issues and track application behavior +- **Size:** Usually very small + +**Volume Creation Messages:** + +```bash +Volume "fcc_postgres_data" Created # Database storage +Volume "fcc_backend_logs" Created # Application logs +``` + +## Commands + +### Starting & Stopping Services + +```bash +# Start all services +yarn docker:up + +# Shorthand commands +yarn docker:up:dev # Start only dev services (postgres, adminer, backend-dev) +yarn docker:down # Stop everything +yarn docker:restart # Restart all services +``` + +### Checking Status & Logs + +```bash +# See which services are running +docker-compose -f docker-compose.dev.yml ps + +# View logs from all services +yarn docker:logs + +# View logs from specific service +docker-compose -f docker-compose.dev.yml logs -f backend-dev +``` + +## Database Operations & Migrations + +```bash +# RECOMMENDED: Run migrations on development backend +yarn docker:migrate:dev + +# Alternative: Run migrations on production backend (not recommended for dev) +yarn docker:migrate +``` + +### Why One-off Migrations Are Powerful + +**What happens behind the scenes:** + +```bash +# 1. Build fresh image with source code +docker build --file apps/backend/Dockerfile --target build -t fcc-backend-build . + +# 2. Run temporary container (auto-deleted when done) +docker run --rm --network fcc_fcc-network \ + -e NX_DB_HOST=postgres \ + -e NX_DB_PORT=5432 \ + -e NX_DB_USERNAME=postgres \ + -e NX_DB_PASSWORD=postgres \ + -e NX_DB_DATABASE=fcc_dev \ + fcc-backend-build npm run migration:run +``` + +**Key Benefits:** + +- Isolated: Doesn't touch your running containers +- Clean: Fresh build every time - no cached issues +- Recoverable: Works when other containers fail +- CI/CD Ready: Perfect for automated deployments +- Testable: Safe to test migrations without affecting dev environment +- Zero Cleanup: Container disappears automatically + +**Perfect for:** + +- Container crashes or corruption +- Automated deployment pipelines +- Testing migrations safely +- Recovery from migration failures +- Ensuring clean migration runs + +## Maintenance & Cleanup + +```bash +# Build/rebuild Docker images +yarn docker:build + +# Clean up everything (containers, volumes, networks) +yarn docker:clean +``` + +### Getting Inside Containers + +```bash +# Access development backend (for debugging, running commands) +docker-compose -f docker-compose.dev.yml exec backend-dev sh + +# Access production backend (rarely needed) +docker-compose -f docker-compose.dev.yml exec backend sh + +# Access database (advanced users) +docker-compose -f docker-compose.dev.yml exec postgres bash +``` + +## Troubleshooting + +### "Command not found" or "docker: command not found" + +**Problem:** Docker isn't installed or not in PATH + +**Solution:** + +1. Install Docker Desktop +2. Restart your terminal/command prompt +3. Try `docker --version` to verify + +### "Port already in use" + +**Problem:** Another application is using ports 3000, 3001, 5432, or 8080 + +**Solution:** + +1. Find what's using the port: `netstat -ano | findstr :3000` (Windows) or `lsof -i :3000` (Mac/Linux) +2. Stop the conflicting application or change Docker ports in `docker-compose.dev.yml` + +### "No space left on device" + +**Problem:** Docker is using too much disk space + +**Solution:** + +```bash +# Clean up unused Docker resources +docker system prune -a +yarn docker:clean +``` + +### Migration Errors + +**Problem:** Database migrations failing + +**Solution:** + +1. Make sure you're using `backend-dev` (not `backend`) for migrations +2. Check database is healthy first +3. Verify migration files exist in `apps/backend/src/migrations/` + +**If regular migrations fail, try:** + +```bash +# Or restart services and try again +yarn docker:restart +yarn docker:migrate:dev +``` + +## Development Workflow Tips + +### Production Testing + +```bash +# Start production backend alongside development +docker-compose -f docker-compose.dev.yml up -d backend + +# Test production at http://localhost:3000 +# Development still available at http://localhost:3001 +``` + +## Notes + +- Volumes keep Postgres data between restarts. Do not remove unless you want a fresh DB. +- Adminer runs in a container so it can reach the Postgres service using the internal Docker hostname `postgres`. + +### Security + +- This compose file is for development only. Replace default passwords and use secrets in production. diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile new file mode 100644 index 00000000..f73de0d9 --- /dev/null +++ b/apps/backend/Dockerfile @@ -0,0 +1,39 @@ +# Simplified Dockerfile for NestJS backend +FROM node:20 AS build +WORKDIR /app + +# Copy package files +COPY package.json yarn.lock ./ +COPY apps/backend/package.json* ./apps/backend/ +COPY shared/package.json* ./shared/ + +# Install all dependencies +RUN yarn install --frozen-lockfile + +# Copy source code and build +COPY . . +RUN npx nx build backend + +# Production stage +FROM node:20-slim AS runtime +WORKDIR /app + +# Install only production dependencies +COPY package.json yarn.lock ./ +RUN yarn install --production --frozen-lockfile && yarn cache clean + +# Copy built application +COPY --from=build /app/dist/apps/backend ./dist + +# Create non-root user +RUN groupadd -g 1001 nodejs && useradd -r -u 1001 -g nodejs nestjs +RUN chown -R nestjs:nodejs /app +USER nestjs + +# Set environment +ENV NODE_ENV=production +ENV PORT=3000 + +EXPOSE 3000 + +CMD ["node", "dist/main.js"] diff --git a/apps/backend/src/app.controller.ts b/apps/backend/src/app.controller.ts index dff210a8..d0c1e7d1 100644 --- a/apps/backend/src/app.controller.ts +++ b/apps/backend/src/app.controller.ts @@ -10,4 +10,16 @@ export class AppController { getData() { return this.appService.getData(); } + + @Get('health') + getHealth() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + service: 'fcc-backend', + message: 'Real-time hot reload test!', + version: '3.0', + test: 'live-update', + }; + } } diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 54fb044e..b5fd0d30 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -3,11 +3,16 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { TaskModule } from './task/task.module'; +import { UsersModule } from './users/users.module'; +import { AuthModule } from './auth/auth.module'; import AppDataSource from './data-source'; @Module({ - imports: [TypeOrmModule.forRoot(AppDataSource.options), TaskModule], + imports: [ + TypeOrmModule.forRoot(AppDataSource.options), + UsersModule, + AuthModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/apps/backend/src/auth/auth.controller.spec.ts b/apps/backend/src/auth/auth.controller.spec.ts index 27a31e61..0b6b00ba 100644 --- a/apps/backend/src/auth/auth.controller.spec.ts +++ b/apps/backend/src/auth/auth.controller.spec.ts @@ -1,12 +1,33 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { UsersService } from '../users/users.service'; describe('AuthController', () => { let controller: AuthController; + // Create mock implementations + const mockAuthService = { + signup: jest.fn(), + }; + + const mockUsersService = { + create: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], + providers: [ + { + provide: AuthService, + useValue: mockAuthService, + }, + { + provide: UsersService, + useValue: mockUsersService, + }, + ], }).compile(); controller = module.get(AuthController); diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index 4cd06624..59eec746 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -1,6 +1,7 @@ import { DataSource } from 'typeorm'; import { PluralNamingStrategy } from './strategies/plural-naming.strategy'; -import { Task } from './task/types/task.entity'; +import { Donation } from './donations/donation.entity'; +import { User } from './users/user.entity'; import * as dotenv from 'dotenv'; dotenv.config(); @@ -12,8 +13,8 @@ const AppDataSource = new DataSource({ username: process.env.NX_DB_USERNAME, password: process.env.NX_DB_PASSWORD, database: process.env.NX_DB_DATABASE, - entities: [Task], - migrations: ['apps/backend/src/migrations/*.js'], + entities: [User, Donation], + migrations: ['apps/backend/src/migrations/*.ts'], // Setting synchronize: true shouldn't be used in production - otherwise you can lose production data synchronize: false, namingStrategy: new PluralNamingStrategy(), diff --git a/apps/backend/src/donations/donation.entity.ts b/apps/backend/src/donations/donation.entity.ts new file mode 100644 index 00000000..dbb2e3a6 --- /dev/null +++ b/apps/backend/src/donations/donation.entity.ts @@ -0,0 +1,55 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +export enum donationType { + 'one_time', + 'recurring', +} + +export enum recurringInterval { + 'weekly', + 'monthly', + 'bimonthly', + 'quarterly', + 'annually', +} + +@Entity() +export class Donation { + @PrimaryGeneratedColumn('identity', { + generatedIdentity: 'ALWAYS', + }) + id: number; + + @Column() + firstName: string; + + @Column() + lastName: string; + + @Column() + email: string; + + @Column({ type: 'numeric', precision: 10, scale: 2 }) + amount: number; + + @Column({ default: false }) + isAnonymous: boolean; + + @Column() + donationType: donationType; + + @Column({ nullable: true }) + recurringInterval: recurringInterval; + + @Column({ nullable: true }) + dedicationMessage: string; + + @Column({ default: false }) + showDedicationPublicly: boolean; + + @Column() + createdAt: Date; + + @Column() + updatedAt: Date; +} diff --git a/apps/backend/src/donations/donations.repository.spec.ts b/apps/backend/src/donations/donations.repository.spec.ts new file mode 100644 index 00000000..e8f73d2b --- /dev/null +++ b/apps/backend/src/donations/donations.repository.spec.ts @@ -0,0 +1,348 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; +import { DonationsRepository, PaginationFilters } from './donations.repository'; +import { Donation, donationType, recurringInterval } from './donation.entity'; + +describe('DonationsRepository', () => { + let repository: DonationsRepository; + let mockTypeOrmRepo: jest.Mocked>; + let mockQueryBuilder: jest.Mocked>; + + const mockDonation: Donation = { + id: 1, + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + amount: 100.0, + isAnonymous: false, + donationType: donationType.one_time, + recurringInterval: null, + dedicationMessage: 'In memory of Jane', + showDedicationPublicly: true, + createdAt: new Date('2024-01-15T10:00:00Z'), + updatedAt: new Date('2024-01-15T10:00:00Z'), + }; + + beforeEach(async () => { + // Create mock query builder with all necessary methods + mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn(), + getMany: jest.fn(), + getRawOne: jest.fn(), + } as any; + + // Create mock TypeORM repository + mockTypeOrmRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DonationsRepository, + { + provide: getRepositoryToken(Donation), + useValue: mockTypeOrmRepo, + }, + ], + }).compile(); + + repository = module.get(DonationsRepository); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findPaginated', () => { + it('should return paginated results without filters', async () => { + const mockDonations = [mockDonation, { ...mockDonation, id: 2 }]; + mockQueryBuilder.getManyAndCount.mockResolvedValue([mockDonations, 2]); + + const result = await repository.findPaginated(1, 10); + + expect(mockTypeOrmRepo.createQueryBuilder).toHaveBeenCalledWith('donation'); + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('donation.createdAt', 'DESC'); + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(10); + expect(result).toEqual({ + rows: mockDonations, + total: 2, + page: 1, + perPage: 10, + totalPages: 1, + }); + }); + + it('should apply pagination correctly for page 2', async () => { + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 25]); + + await repository.findPaginated(2, 10); + + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(10); + expect(mockQueryBuilder.take).toHaveBeenCalledWith(10); + }); + + it('should calculate total pages correctly', async () => { + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 25]); + + const result = await repository.findPaginated(1, 10); + + expect(result.totalPages).toBe(3); + }); + + it('should filter by donationType', async () => { + const filters: PaginationFilters = { donationType: 'recurring' }; + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + + await repository.findPaginated(1, 10, filters); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'donation.donationType = :donationType', + { donationType: 'recurring' }, + ); + }); + + it('should filter by isAnonymous', async () => { + const filters: PaginationFilters = { isAnonymous: true }; + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + + await repository.findPaginated(1, 10, filters); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'donation.isAnonymous = :isAnonymous', + { isAnonymous: true }, + ); + }); + + it('should filter by recurringInterval', async () => { + const filters: PaginationFilters = { recurringInterval: 'monthly' }; + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + + await repository.findPaginated(1, 10, filters); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'donation.recurringInterval = :recurringInterval', + { recurringInterval: 'monthly' }, + ); + }); + + it('should filter by amount range', async () => { + const filters: PaginationFilters = { minAmount: 50, maxAmount: 200 }; + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + + await repository.findPaginated(1, 10, filters); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'donation.amount >= :minAmount', + { minAmount: 50 }, + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'donation.amount <= :maxAmount', + { maxAmount: 200 }, + ); + }); + + it('should filter by date range', async () => { + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-12-31'); + const filters: PaginationFilters = { startDate, endDate }; + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + + await repository.findPaginated(1, 10, filters); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'donation.createdAt >= :startDate', + { startDate }, + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'donation.createdAt <= :endDate', + { endDate }, + ); + }); + + it('should apply multiple filters together', async () => { + const filters: PaginationFilters = { + donationType: 'recurring', + isAnonymous: false, + minAmount: 100, + }; + mockQueryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + + await repository.findPaginated(1, 10, filters); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledTimes(3); + }); + }); + + describe('searchByDonorNameOrEmail', () => { + it('should search by donor name or email with default limit', async () => { + const mockResults = [mockDonation]; + mockQueryBuilder.getMany.mockResolvedValue(mockResults); + + const result = await repository.searchByDonorNameOrEmail('john'); + + expect(mockTypeOrmRepo.createQueryBuilder).toHaveBeenCalledWith('donation'); + expect(mockQueryBuilder.where).toHaveBeenCalled(); + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('donation.createdAt', 'DESC'); + expect(mockQueryBuilder.limit).toHaveBeenCalledWith(50); + expect(result).toEqual(mockResults); + }); + + it('should search with custom limit', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + + await repository.searchByDonorNameOrEmail('jane', 10); + + expect(mockQueryBuilder.limit).toHaveBeenCalledWith(10); + }); + + it('should handle empty search results', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + + const result = await repository.searchByDonorNameOrEmail('nonexistent'); + + expect(result).toEqual([]); + }); + + it('should convert search term to lowercase', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + + await repository.searchByDonorNameOrEmail('JOHN'); + + // The where clause should include the lowercase version + expect(mockQueryBuilder.where).toHaveBeenCalled(); + }); + }); + + describe('getTotalsByDateRange', () => { + it('should calculate totals for date range', async () => { + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-12-31'); + mockQueryBuilder.getRawOne.mockResolvedValue({ + total: '1500.50', + count: '15', + }); + + const result = await repository.getTotalsByDateRange(startDate, endDate); + + expect(mockTypeOrmRepo.createQueryBuilder).toHaveBeenCalledWith('donation'); + expect(mockQueryBuilder.select).toHaveBeenCalledWith('SUM(donation.amount)', 'total'); + expect(mockQueryBuilder.addSelect).toHaveBeenCalledWith('COUNT(donation.id)', 'count'); + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'donation.createdAt >= :startDate', + { startDate }, + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'donation.createdAt <= :endDate', + { endDate }, + ); + expect(result).toEqual({ + total: 1500.5, + count: 15, + }); + }); + + it('should return zeros when no donations in range', async () => { + mockQueryBuilder.getRawOne.mockResolvedValue({ + total: null, + count: '0', + }); + + const result = await repository.getTotalsByDateRange( + new Date('2024-01-01'), + new Date('2024-01-02'), + ); + + expect(result).toEqual({ + total: 0, + count: 0, + }); + }); + + it('should handle string numbers from database', async () => { + mockQueryBuilder.getRawOne.mockResolvedValue({ + total: '2500.75', + count: '42', + }); + + const result = await repository.getTotalsByDateRange( + new Date('2024-01-01'), + new Date('2024-12-31'), + ); + + expect(result.total).toBe(2500.75); + expect(result.count).toBe(42); + }); + }); + + describe('findRecentPublic', () => { + it('should return recent public donations with privacy applied', async () => { + const mockDonations = [ + mockDonation, + { ...mockDonation, id: 2, isAnonymous: true }, + ]; + mockQueryBuilder.getMany.mockResolvedValue(mockDonations); + + const result = await repository.findRecentPublic(10); + + expect(mockTypeOrmRepo.createQueryBuilder).toHaveBeenCalledWith('donation'); + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('donation.createdAt', 'DESC'); + expect(mockQueryBuilder.limit).toHaveBeenCalledWith(10); + expect(result).toHaveLength(2); + expect(result[0]).toHaveProperty('id'); + expect(result[0]).toHaveProperty('amount'); + expect(result[0]).not.toHaveProperty('email'); + }); + + it('should respect limit parameter', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + + await repository.findRecentPublic(5); + + expect(mockQueryBuilder.limit).toHaveBeenCalledWith(5); + }); + + it('should return empty array when no donations exist', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + + const result = await repository.findRecentPublic(10); + + expect(result).toEqual([]); + }); + }); + + describe('deleteById', () => { + it('should delete donation by id', async () => { + mockTypeOrmRepo.delete.mockResolvedValue({ affected: 1, raw: {} } as any); + + await repository.deleteById(1); + + expect(mockTypeOrmRepo.delete).toHaveBeenCalledWith(1); + }); + + it('should throw error when donation not found', async () => { + mockTypeOrmRepo.delete.mockResolvedValue({ affected: 0, raw: {} } as any); + + await expect(repository.deleteById(999)).rejects.toThrow( + 'Donation with ID 999 not found', + ); + }); + }); + + +}); \ No newline at end of file diff --git a/apps/backend/src/donations/donations.repository.ts b/apps/backend/src/donations/donations.repository.ts new file mode 100644 index 00000000..1e29f6b2 --- /dev/null +++ b/apps/backend/src/donations/donations.repository.ts @@ -0,0 +1,207 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Brackets } from 'typeorm'; +import { Donation } from './donation.entity'; +import { DonationMappers, Donation as DomainDonation } from './mappers'; +import { PublicDonationDto } from './dtos/public-donation-dto'; + +export interface PaginationFilters { + donationType?: 'one_time' | 'recurring'; + isAnonymous?: boolean; + recurringInterval?: 'weekly' | 'monthly' | 'bimonthly' | 'quarterly' | 'annually'; + minAmount?: number; + maxAmount?: number; + startDate?: Date; + endDate?: Date; +} + +export interface PaginatedResult { + rows: T[]; + total: number; + page: number; + perPage: number; + totalPages: number; +} + +@Injectable() +export class DonationsRepository { + constructor( + @InjectRepository(Donation) + private readonly repository: Repository, + ) {} + + /** + * Find donations with pagination and optional filters + */ + async findPaginated( + page: number, + perPage: number, + filters?: PaginationFilters, + ): Promise> { + const queryBuilder = this.repository.createQueryBuilder('donation'); + + // Apply filters + if (filters) { + if (filters.donationType) { + queryBuilder.andWhere('donation.donationType = :donationType', { + donationType: filters.donationType, + }); + } + + if (filters.isAnonymous !== undefined) { + queryBuilder.andWhere('donation.isAnonymous = :isAnonymous', { + isAnonymous: filters.isAnonymous, + }); + } + + if (filters.recurringInterval) { + queryBuilder.andWhere('donation.recurringInterval = :recurringInterval', { + recurringInterval: filters.recurringInterval, + }); + } + + if (filters.minAmount !== undefined) { + queryBuilder.andWhere('donation.amount >= :minAmount', { + minAmount: filters.minAmount, + }); + } + + if (filters.maxAmount !== undefined) { + queryBuilder.andWhere('donation.amount <= :maxAmount', { + maxAmount: filters.maxAmount, + }); + } + + if (filters.startDate) { + queryBuilder.andWhere('donation.createdAt >= :startDate', { + startDate: filters.startDate, + }); + } + + if (filters.endDate) { + queryBuilder.andWhere('donation.createdAt <= :endDate', { + endDate: filters.endDate, + }); + } + } + + // Order by most recent first + queryBuilder.orderBy('donation.createdAt', 'DESC'); + + // Apply pagination + const offset = (page - 1) * perPage; + queryBuilder.skip(offset).take(perPage); + + // Execute query + const [rows, total] = await queryBuilder.getManyAndCount(); + + return { + rows, + total, + page, + perPage, + totalPages: Math.ceil(total / perPage), + }; + } + + /** + * Search donations by donor name or email + * Useful for admin search functionality + */ + async searchByDonorNameOrEmail( + query: string, + limit: number = 50, + ): Promise { + const searchTerm = `%${query.toLowerCase()}%`; + + return this.repository + .createQueryBuilder('donation') + .where( + new Brackets((qb) => { + qb.where('LOWER(donation.firstName) LIKE :searchTerm', { searchTerm }) + .orWhere('LOWER(donation.lastName) LIKE :searchTerm', { searchTerm }) + .orWhere('LOWER(donation.email) LIKE :searchTerm', { searchTerm }) + .orWhere( + "LOWER(CONCAT(donation.firstName, ' ', donation.lastName)) LIKE :searchTerm", + { searchTerm }, + ); + }), + ) + .orderBy('donation.createdAt', 'DESC') + .limit(limit) + .getMany(); + } + + /** + * Get aggregated totals for a date range + * Useful for admin dashboards and reporting + */ + async getTotalsByDateRange( + startDate: Date, + endDate: Date, + ): Promise<{ total: number; count: number }> { + const result = await this.repository + .createQueryBuilder('donation') + .select('SUM(donation.amount)', 'total') + .addSelect('COUNT(donation.id)', 'count') + .where('donation.createdAt >= :startDate', { startDate }) + .andWhere('donation.createdAt <= :endDate', { endDate }) + .getRawOne(); + + return { + total: parseFloat(result.total) || 0, + count: parseInt(result.count, 10) || 0, + }; + } + + /** + * Find recent public donations for display on public pages + * Respects privacy settings (anonymous, dedication visibility) + */ + async findRecentPublic(limit: number): Promise { + const donations = await this.repository + .createQueryBuilder('donation') + .orderBy('donation.createdAt', 'DESC') + .limit(limit) + .getMany(); + + // Map to public DTOs using the mapper's privacy logic + return DonationMappers.toPublicDonationDtos( + donations.map((d) => this.mapEntityToDomain(d)), + ); + } + + /** + * Delete a donation by ID (admin-only destructive operation) + */ + async deleteById(id: number): Promise { + const result = await this.repository.delete(id); + + if (result.affected === 0) { + throw new Error(`Donation with ID ${id} not found`); + } + } + + /** + * Map entity to domain model (adds status and transactionId if needed) + * This bridges the gap between the entity and the domain model used in mappers + */ + private mapEntityToDomain(entity: Donation): DomainDonation { + return { + id: entity.id, + firstName: entity.firstName, + lastName: entity.lastName, + email: entity.email, + amount: entity.amount, + isAnonymous: entity.isAnonymous, + donationType: entity.donationType as unknown as 'one_time' | 'recurring', + recurringInterval: entity.recurringInterval as unknown as 'weekly' | 'monthly' | 'bimonthly' | 'quarterly' | 'annually' | undefined, + dedicationMessage: entity.dedicationMessage, + showDedicationPublicly: entity.showDedicationPublicly, + status: 'completed', // Default status - will be enhanced in future + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + transactionId: undefined, // Will be added when payment integration is implemented + }; + } +} \ No newline at end of file diff --git a/apps/backend/src/donations/dtos/create-donation-dto.ts b/apps/backend/src/donations/dtos/create-donation-dto.ts new file mode 100644 index 00000000..4a1e935e --- /dev/null +++ b/apps/backend/src/donations/dtos/create-donation-dto.ts @@ -0,0 +1,106 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsEmail, + IsNumber, + IsBoolean, + IsEnum, + IsOptional, + Min, + IsNotEmpty, +} from 'class-validator'; + +export enum DonationType { + ONE_TIME = 'one_time', + RECURRING = 'recurring', +} + +export enum RecurringInterval { + WEEKLY = 'weekly', + MONTHLY = 'monthly', + BIMONTHLY = 'bimonthly', + QUARTERLY = 'quarterly', + ANNUALLY = 'annually', +} + +export class CreateDonationDto { + @ApiProperty({ + description: 'donor first name', + example: 'John', + }) + @IsString() + @IsNotEmpty() + firstName: string; + + @ApiProperty({ + description: 'donor last name', + example: 'Smith', + }) + @IsString() + @IsNotEmpty() + lastName: string; + + @ApiProperty({ + description: 'donor email address', + example: 'john.smith@example.com', + }) + @IsEmail() + @IsNotEmpty() + email: string; + + @ApiProperty({ + description: 'the donation amount in dollars', + example: 100.0, + minimum: 0.01, + }) + @IsNumber() + @Min(0.01) + amount: number; + + @ApiProperty({ + description: 'whether the donation should be anonymous', + example: false, + required: false, + default: false, + }) + @IsBoolean() + @IsOptional() + isAnonymous?: boolean = false; + + @ApiProperty({ + description: 'the type of donation', + enum: DonationType, + example: DonationType.ONE_TIME, + }) + @IsEnum(DonationType) + donationType: DonationType; + + @ApiProperty({ + description: 'recurring interval for recurring donations', + enum: RecurringInterval, + required: false, + example: RecurringInterval.MONTHLY, + }) + @IsEnum(RecurringInterval) + @IsOptional() + recurringInterval?: RecurringInterval; + + @ApiProperty({ + description: 'optional dedication message', + example: 'for the Fenway community', + required: false, + }) + @IsString() + @IsOptional() + dedicationMessage?: string; + + @ApiProperty({ + description: 'whether to show dedication message publicly', + example: false, + required: false, + default: false, + }) + @IsBoolean() + @IsOptional() + showDedicationPublicly?: boolean = false; +} diff --git a/apps/backend/src/donations/dtos/create-donation.domain.ts b/apps/backend/src/donations/dtos/create-donation.domain.ts new file mode 100644 index 00000000..41e8d215 --- /dev/null +++ b/apps/backend/src/donations/dtos/create-donation.domain.ts @@ -0,0 +1,24 @@ +import { + DonationType, + NormalizedInterval, +} from '../../util/donations/donations.util'; + +export class CreateDonationDTO { + firstName: string; + + lastName: string; + + email: string; + + amount: number; + + isAnonymous: boolean = false; + + donationType: DonationType; + + recurringInterval?: NormalizedInterval | null; + + dedicationMessage?: string | null; + + showDedicationPublicly?: boolean = false; +} diff --git a/apps/backend/src/donations/dtos/donation-response-dto.ts b/apps/backend/src/donations/dtos/donation-response-dto.ts new file mode 100644 index 00000000..16e43c85 --- /dev/null +++ b/apps/backend/src/donations/dtos/donation-response-dto.ts @@ -0,0 +1,101 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { DonationType, RecurringInterval } from './create-donation-dto'; + +export enum DonationStatus { + PENDING = 'pending', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +export class DonationResponseDto { + @ApiProperty({ + description: 'Unique donation identifier', + example: 123, + }) + id: number; + + @ApiProperty({ + description: 'donor first name', + example: 'John', + }) + firstName: string; + + @ApiProperty({ + description: 'donor last name', + example: 'Smith', + }) + lastName: string; + + @ApiProperty({ + description: 'donor email address', + example: 'john.smith@example.com', + }) + email: string; + + @ApiProperty({ + description: 'donation amount in dollars', + example: 100.0, + }) + amount: number; + + @ApiProperty({ + description: 'whether the donation is anonymous', + example: false, + }) + isAnonymous: boolean; + + @ApiProperty({ + description: 'the type of donation', + enum: DonationType, + example: DonationType.ONE_TIME, + }) + donationType: DonationType; + + @ApiProperty({ + description: 'the recurring interval for recurring donations', + enum: RecurringInterval, + required: false, + example: RecurringInterval.MONTHLY, + }) + recurringInterval?: RecurringInterval; + + @ApiProperty({ + description: 'optional dedication message', + example: 'for the Fenway community', + required: false, + }) + dedicationMessage?: string; + + @ApiProperty({ + description: 'whether to show dedication message publicly', + example: false, + }) + showDedicationPublicly: boolean; + + @ApiProperty({ + description: 'the current donation status', + enum: DonationStatus, + example: DonationStatus.COMPLETED, + }) + status: DonationStatus; + + @ApiProperty({ + description: 'timestamp when donation was created', + example: '2024-01-15T10:30:00Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'timestamp when donation was last updated', + example: '2024-01-15T10:35:00Z', + }) + updatedAt: Date; + + @ApiProperty({ + description: 'payment processor transaction ID', + example: 'txn_1234567890', + required: false, + }) + transactionId?: string; +} diff --git a/apps/backend/src/donations/dtos/donation-response.domain.ts b/apps/backend/src/donations/dtos/donation-response.domain.ts new file mode 100644 index 00000000..7ea04c3a --- /dev/null +++ b/apps/backend/src/donations/dtos/donation-response.domain.ts @@ -0,0 +1,11 @@ +import { CreateDonationDTO } from './create-donation.domain'; + +export class DonationResponseDTO { + id: number; + + stored: CreateDonationDTO; + + createdAt: string; + + updatedAt: string; +} diff --git a/apps/backend/src/donations/dtos/index.ts b/apps/backend/src/donations/dtos/index.ts new file mode 100644 index 00000000..c520f5b0 --- /dev/null +++ b/apps/backend/src/donations/dtos/index.ts @@ -0,0 +1,3 @@ +export * from './create-donation.domain'; +export * from './donation-response.domain'; +export * from './public-donation.domain'; diff --git a/apps/backend/src/donations/dtos/public-donation-dto.ts b/apps/backend/src/donations/dtos/public-donation-dto.ts new file mode 100644 index 00000000..308c817a --- /dev/null +++ b/apps/backend/src/donations/dtos/public-donation-dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { DonationType, RecurringInterval } from './create-donation-dto'; +import { DonationStatus } from './donation-response-dto'; + +export class PublicDonationDto { + @ApiProperty({ + description: 'unique donation identifier', + example: 123, + }) + id: number; + + @ApiProperty({ + description: 'donor name, hidden if anonymous', + example: 'John Smith', + required: false, + }) + donorName?: string; + + @ApiProperty({ + description: 'donation amount, in dollars', + example: 100.0, + }) + amount: number; + + @ApiProperty({ + description: 'whether or not the donation is anonymous', + example: false, + }) + isAnonymous: boolean; + + @ApiProperty({ + description: 'the type of donation', + enum: DonationType, + example: DonationType.ONE_TIME, + }) + donationType: DonationType; + + @ApiProperty({ + description: 'the recurring interval for recurring donations', + enum: RecurringInterval, + required: false, + example: RecurringInterval.MONTHLY, + }) + recurringInterval?: RecurringInterval; + + @ApiProperty({ + description: + 'the dedication message, shown if showDedicationPublicly is true', + example: 'for the Fenway community', + required: false, + }) + dedicationMessage?: string; + + @ApiProperty({ + description: 'the current donation status', + enum: DonationStatus, + example: DonationStatus.COMPLETED, + }) + status: DonationStatus; + + @ApiProperty({ + description: 'timestamp when donation was created', + example: '2024-01-15T10:30:00Z', + }) + createdAt: Date; +} diff --git a/apps/backend/src/donations/dtos/public-donation.domain.ts b/apps/backend/src/donations/dtos/public-donation.domain.ts new file mode 100644 index 00000000..995d8855 --- /dev/null +++ b/apps/backend/src/donations/dtos/public-donation.domain.ts @@ -0,0 +1,17 @@ +import { DonationType } from '../../util/donations/donations.util'; + +export class PublicDonationDTO { + id: number; + + amount: number; + + donationType: DonationType; + + dedicationMessage?: string | null; + + isAnonymous: boolean; + + donorName?: string | null; + + createdAt: string; +} diff --git a/apps/backend/src/donations/mappers.spec.ts b/apps/backend/src/donations/mappers.spec.ts new file mode 100644 index 00000000..7e34743d --- /dev/null +++ b/apps/backend/src/donations/mappers.spec.ts @@ -0,0 +1,283 @@ +import { validate } from 'class-validator'; +import { + CreateDonationDto, + DonationType, + RecurringInterval, +} from './dtos/create-donation-dto'; +import { DonationStatus } from './dtos/donation-response-dto'; +import { PublicDonationDto } from './dtos/public-donation-dto'; +import { DonationMappers, Donation } from './mappers'; + +describe('DonationMappers', () => { + const mockDonation: Donation = { + id: 123, + firstName: 'John', + lastName: 'Smith', + email: 'john.smith@example.com', + amount: 100.0, + isAnonymous: false, + donationType: 'one_time', + dedicationMessage: 'for the Fenway community', + showDedicationPublicly: true, + status: 'completed', + createdAt: new Date('2024-01-15T10:30:00Z'), + updatedAt: new Date('2024-01-15T10:35:00Z'), + transactionId: 'txn_1234567890', + }; + + const mockCreateDto: CreateDonationDto = { + firstName: 'John', + lastName: 'Smith', + email: 'john.smith@example.com', + amount: 100.0, + isAnonymous: false, + donationType: DonationType.ONE_TIME, + dedicationMessage: 'for the Fenway community', + showDedicationPublicly: true, + }; + + const mockPublicDonation: PublicDonationDto = { + id: 123, + donorName: 'John Smith', + amount: 100.0, + isAnonymous: false, + donationType: DonationType.ONE_TIME, + recurringInterval: undefined, + dedicationMessage: 'for the Fenway community', + status: DonationStatus.COMPLETED, + createdAt: new Date('2024-01-15T10:30:00Z'), + }; + + describe('toCreateDonationRequest', () => { + it('should map CreateDonationDto to CreateDonationRequest correctly', () => { + const result = DonationMappers.toCreateDonationRequest(mockCreateDto); + + expect(result).toEqual({ + firstName: 'John', + lastName: 'Smith', + email: 'john.smith@example.com', + amount: 100.0, + isAnonymous: false, + donationType: 'one_time', + dedicationMessage: 'for the Fenway community', + showDedicationPublicly: true, + }); + }); + + it('should apply default values for optional fields', () => { + const minimalDto: CreateDonationDto = { + firstName: 'Jane', + lastName: 'Doe', + email: 'jane.doe@example.com', + amount: 50.0, + donationType: DonationType.ONE_TIME, + }; + + const result = DonationMappers.toCreateDonationRequest(minimalDto); + + expect(result.isAnonymous).toBe(false); + expect(result.showDedicationPublicly).toBe(false); + expect(result.recurringInterval).toBeUndefined(); + expect(result.dedicationMessage).toBeUndefined(); + }); + + it('should handle recurring donations with interval', () => { + const recurringDto: CreateDonationDto = { + ...mockCreateDto, + donationType: DonationType.RECURRING, + recurringInterval: RecurringInterval.MONTHLY, + }; + + const result = DonationMappers.toCreateDonationRequest(recurringDto); + + expect(result.donationType).toBe('recurring'); + expect(result.recurringInterval).toBe('monthly'); + }); + }); + + describe('toDonationResponseDto', () => { + it('should map Donation to DonationResponseDto correctly', () => { + const result = DonationMappers.toDonationResponseDto(mockDonation); + + expect(result.id).toBe(mockDonation.id); + expect(result.firstName).toBe(mockDonation.firstName); + expect(result.lastName).toBe(mockDonation.lastName); + expect(result.email).toBe(mockDonation.email); + expect(result.amount).toBe(mockDonation.amount); + expect(result.isAnonymous).toBe(mockDonation.isAnonymous); + expect(result.donationType).toBe(mockDonation.donationType); + expect(result.dedicationMessage).toBe(mockDonation.dedicationMessage); + expect(result.showDedicationPublicly).toBe( + mockDonation.showDedicationPublicly, + ); + expect(result.status).toBe(mockDonation.status); + expect(result.createdAt).toBe(mockDonation.createdAt); + expect(result.updatedAt).toBe(mockDonation.updatedAt); + expect(result.transactionId).toBe(mockDonation.transactionId); + }); + }); + + describe('toPublicDonationDto', () => { + it('should include donor name when not anonymous', () => { + const result = DonationMappers.toPublicDonationDto(mockDonation); + + expect(result.donorName).toBe('John Smith'); + expect(result.dedicationMessage).toBe('for the Fenway community'); + }); + + it('should exclude donor name when anonymous', () => { + const anonymousDonation: Donation = { + ...mockDonation, + isAnonymous: true, + }; + + const result = DonationMappers.toPublicDonationDto(anonymousDonation); + + expect(result.donorName).toBeUndefined(); + expect(result.isAnonymous).toBe(true); + }); + + it('should exclude dedication message when showDedicationPublicly is false', () => { + const privateDedication: Donation = { + ...mockDonation, + showDedicationPublicly: false, + }; + + const result = DonationMappers.toPublicDonationDto(privateDedication); + + expect(result.dedicationMessage).toBeUndefined(); + }); + + it('should exclude dedication message when it is empty even if showDedicationPublicly is true', () => { + const noDedication: Donation = { + ...mockDonation, + dedicationMessage: undefined, + showDedicationPublicly: true, + }; + + const result = DonationMappers.toPublicDonationDto(noDedication); + + expect(result.dedicationMessage).toBeUndefined(); + }); + + it('should not include email or sensitive fields', () => { + const result = DonationMappers.toPublicDonationDto(mockDonation); + + expect(result.id).toBe(mockPublicDonation.id); + expect(result.donorName).toBe(mockPublicDonation.donorName); + expect(result.amount).toBe(mockPublicDonation.amount); + expect(result.isAnonymous).toBe(mockPublicDonation.isAnonymous); + expect(result.donationType).toBe(mockPublicDonation.donationType); + expect(result.recurringInterval).toBe( + mockPublicDonation.recurringInterval, + ); + expect(result.dedicationMessage).toBe( + mockPublicDonation.dedicationMessage, + ); + expect(result.status).toBe(mockPublicDonation.status); + expect(result.createdAt).toEqual(mockPublicDonation.createdAt); + + expect(result).not.toHaveProperty('email'); + expect(result).not.toHaveProperty('firstName'); + expect(result).not.toHaveProperty('lastName'); + expect(result).not.toHaveProperty('transactionId'); + expect(result).not.toHaveProperty('updatedAt'); + expect(result).not.toHaveProperty('showDedicationPublicly'); + }); + }); + + describe('array mapping methods', () => { + it('should map array of donations to response DTOs', () => { + const donations = [mockDonation, { ...mockDonation, id: 456 }]; + const result = DonationMappers.toDonationResponseDtos(donations); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe(mockDonation.id); + expect(result[1].id).toBe(456); + }); + + it('should map array of donations to public DTOs', () => { + const donations = [ + mockDonation, + { ...mockDonation, id: 456, isAnonymous: true }, + ]; + const result = DonationMappers.toPublicDonationDtos(donations); + + expect(result).toHaveLength(2); + expect(result[0].donorName).toBe('John Smith'); + expect(result[1].donorName).toBeUndefined(); + }); + }); +}); + +describe('CreateDonationDto Validation', () => { + it('should validate a valid DTO', async () => { + const dto = new CreateDonationDto(); + dto.firstName = 'John'; + dto.lastName = 'Smith'; + dto.email = 'john.smith@example.com'; + dto.amount = 100.0; + dto.donationType = DonationType.ONE_TIME; + + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('should fail validation for invalid email', async () => { + const dto = new CreateDonationDto(); + dto.firstName = 'John'; + dto.lastName = 'Smith'; + dto.email = 'invalid-email'; + dto.amount = 100.0; + dto.donationType = DonationType.ONE_TIME; + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((error) => error.property === 'email')).toBe(true); + }); + + it('should fail validation for empty required fields', async () => { + const dto = new CreateDonationDto(); + dto.firstName = ''; + dto.lastName = ''; + dto.email = ''; + dto.amount = 0; + dto.donationType = DonationType.ONE_TIME; + + const errors = await validate(dto); + expect(errors.length).toBeGreaterThan(0); + + const errorProperties = errors.map((error) => error.property); + expect(errorProperties).toContain('firstName'); + expect(errorProperties).toContain('lastName'); + expect(errorProperties).toContain('email'); + expect(errorProperties).toContain('amount'); + }); + + it('should fail validation for amount too low', async () => { + const dto = new CreateDonationDto(); + dto.firstName = 'John'; + dto.lastName = 'Smith'; + dto.email = 'john.smith@example.com'; + dto.amount = 0.005; // Below minimum + dto.donationType = DonationType.ONE_TIME; + + const errors = await validate(dto); + expect(errors.some((error) => error.property === 'amount')).toBe(true); + }); + + it('should fail validation for invalid recurring interval', async () => { + const dto = new CreateDonationDto(); + dto.firstName = 'John'; + dto.lastName = 'Smith'; + dto.email = 'john.smith@example.com'; + dto.amount = 100.0; + dto.donationType = DonationType.RECURRING; + dto.recurringInterval = 'invalid' as RecurringInterval; + + const errors = await validate(dto); + expect(errors.some((error) => error.property === 'recurringInterval')).toBe( + true, + ); + }); +}); diff --git a/apps/backend/src/donations/mappers.ts b/apps/backend/src/donations/mappers.ts new file mode 100644 index 00000000..2583d1e2 --- /dev/null +++ b/apps/backend/src/donations/mappers.ts @@ -0,0 +1,122 @@ +import { + CreateDonationDto, + DonationType, + RecurringInterval, +} from './dtos/create-donation-dto'; +import { + DonationResponseDto, + DonationStatus, +} from './dtos/donation-response-dto'; +import { PublicDonationDto } from './dtos/public-donation-dto'; + +export interface CreateDonationRequest { + firstName: string; + lastName: string; + email: string; + amount: number; + isAnonymous: boolean; + donationType: 'one_time' | 'recurring'; + recurringInterval?: + | 'weekly' + | 'monthly' + | 'bimonthly' + | 'quarterly' + | 'annually'; + dedicationMessage?: string; + showDedicationPublicly: boolean; +} + +export interface Donation { + id: number; + firstName: string; + lastName: string; + email: string; + amount: number; + isAnonymous: boolean; + donationType: 'one_time' | 'recurring'; + recurringInterval?: + | 'weekly' + | 'monthly' + | 'bimonthly' + | 'quarterly' + | 'annually'; + dedicationMessage?: string; + showDedicationPublicly: boolean; + status: 'pending' | 'completed' | 'failed' | 'cancelled'; + createdAt: Date; + updatedAt: Date; + transactionId?: string; +} + +export class DonationMappers { + static toCreateDonationRequest( + dto: CreateDonationDto, + ): CreateDonationRequest { + return { + firstName: dto.firstName, + lastName: dto.lastName, + email: dto.email, + amount: dto.amount, + isAnonymous: dto.isAnonymous ?? false, + donationType: dto.donationType as 'one_time' | 'recurring', + recurringInterval: dto.recurringInterval as + | 'weekly' + | 'monthly' + | 'bimonthly' + | 'quarterly' + | 'annually' + | undefined, + dedicationMessage: dto.dedicationMessage, + showDedicationPublicly: dto.showDedicationPublicly ?? false, + }; + } + + static toDonationResponseDto(donation: Donation): DonationResponseDto { + return { + id: donation.id, + firstName: donation.firstName, + lastName: donation.lastName, + email: donation.email, + amount: donation.amount, + isAnonymous: donation.isAnonymous, + donationType: donation.donationType as DonationType, + recurringInterval: donation.recurringInterval as RecurringInterval, + dedicationMessage: donation.dedicationMessage, + showDedicationPublicly: donation.showDedicationPublicly, + status: donation.status as DonationStatus, + createdAt: donation.createdAt, + updatedAt: donation.updatedAt, + transactionId: donation.transactionId, + }; + } + + static toPublicDonationDto(donation: Donation): PublicDonationDto { + const publicDto: PublicDonationDto = { + id: donation.id, + amount: donation.amount, + isAnonymous: donation.isAnonymous, + donationType: donation.donationType as DonationType, + recurringInterval: donation.recurringInterval as RecurringInterval, + status: donation.status as DonationStatus, + createdAt: donation.createdAt, + }; + + if (!donation.isAnonymous) { + publicDto.donorName = `${donation.firstName} ${donation.lastName}`; + } + + if (donation.showDedicationPublicly && donation.dedicationMessage) { + publicDto.dedicationMessage = donation.dedicationMessage; + } + + return publicDto; + } + + static toDonationResponseDtos(donations: Donation[]): DonationResponseDto[] { + return donations.map((donation) => this.toDonationResponseDto(donation)); + } + + static toPublicDonationDtos(donations: Donation[]): PublicDonationDto[] { + return donations.map((donation) => this.toPublicDonationDto(donation)); + } +} diff --git a/apps/backend/src/migrations/1759151412730-add_users.ts b/apps/backend/src/migrations/1759151412730-add_users.ts new file mode 100644 index 00000000..842bb2b8 --- /dev/null +++ b/apps/backend/src/migrations/1759151412730-add_users.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUsers1759151412730 implements MigrationInterface { + name = 'AddUsers1759151412730'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "users" ("id" integer NOT NULL, "status" character varying NOT NULL, "firstName" character varying NOT NULL, "lastName" character varying NOT NULL, "email" character varying NOT NULL, CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "users"`); + } +} diff --git a/apps/backend/src/migrations/1759151447065-add_donations.ts b/apps/backend/src/migrations/1759151447065-add_donations.ts new file mode 100644 index 00000000..448a11f8 --- /dev/null +++ b/apps/backend/src/migrations/1759151447065-add_donations.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDonations1759151447065 implements MigrationInterface { + name = 'AddDonations1759151447065'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "donations" ("id" integer GENERATED ALWAYS AS IDENTITY NOT NULL, "firstName" character varying NOT NULL, "lastName" character varying NOT NULL, "email" character varying NOT NULL, "amount" numeric(10,2) NOT NULL, "isAnonymous" boolean NOT NULL DEFAULT false, "donationType" integer NOT NULL, "recurringInterval" integer, "dedicationMessage" character varying, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT "PK_c01355d6f6f50fc6d1b4a946abf" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "donations"`); + } +} diff --git a/apps/backend/src/util/donations/donations.util.ts b/apps/backend/src/util/donations/donations.util.ts new file mode 100644 index 00000000..f286e860 --- /dev/null +++ b/apps/backend/src/util/donations/donations.util.ts @@ -0,0 +1,37 @@ +export const donationTypes = ['one_time', 'recurring'] as const; +export type DonationType = (typeof donationTypes)[number]; +export const recurringIntervals = [ + 'monthly', + 'bimonthly', + 'quarterly', + 'annually', + 'weekly', +] as const; +export type NormalizedInterval = (typeof recurringIntervals)[number]; + +export function normalizeInterval( + input: string | null, +): NormalizedInterval | null { + if (!input) { + return null; + } + + const normalized = input.toLowerCase().trim(); + + return (recurringIntervals as readonly string[]).includes(normalized) + ? (normalized as NormalizedInterval) + : null; +} + +export function normalizeDonorName( + input: string | null, + anonymous: boolean, +): string | null { + return anonymous ? null : input; +} + +export function normalizeDonationAmount(amount: string | number): number { + const num = Number(amount); + + return isFinite(num) ? num : 0; +} diff --git a/apps/frontend/proxy.conf.json b/apps/frontend/proxy.conf.json index 63dd6275..84c3f18e 100644 --- a/apps/frontend/proxy.conf.json +++ b/apps/frontend/proxy.conf.json @@ -1,6 +1,6 @@ { "/api": { - "target": "http://localhost:3000", + "target": "http://localhost:3001", "secure": false } } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..cfa4cf27 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,135 @@ +services: + # PostgreSQL Database + postgres: + image: postgres:15-alpine + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: fcc_dev + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro + networks: + - fcc-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d fcc_dev"] + interval: 10s + timeout: 5s + retries: 5 + + # NestJS Backend + backend: + build: + context: . + dockerfile: apps/backend/Dockerfile + target: runtime + restart: unless-stopped + environment: + NX_DB_HOST: postgres + NX_DB_PORT: 5432 + NX_DB_USERNAME: postgres + NX_DB_PASSWORD: postgres + NX_DB_DATABASE: fcc_dev + + NODE_ENV: development + PORT: 3000 + + # JWT and Auth (add secrets in prod) + JWT_SECRET: dev-secret-change-in-prod + + ports: + - "3000:3000" + depends_on: + postgres: + condition: service_healthy + networks: + - fcc-network + volumes: + # For development: mount source code for hot reload (optional) + # - ../../apps/backend/src:/app/src:ro + # - ../../apps/backend/dist:/app/dist + - backend_logs:/app/logs + # health check without wget dependency + # healthcheck: + # test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3000/api || exit 1"] + # interval: 30s + # timeout: 10s + # retries: 3 + # start_period: 60s + + # NestJS Backend - Development + backend-dev: + build: + context: . + dockerfile: apps/backend/Dockerfile + target: build + restart: unless-stopped + environment: + NX_DB_HOST: postgres + NX_DB_PORT: 5432 + NX_DB_USERNAME: postgres + NX_DB_PASSWORD: postgres + NX_DB_DATABASE: fcc_dev + + NODE_ENV: development + PORT: 3001 + + # JWT and Auth (add secrets in prod) + JWT_SECRET: dev-secret-change-in-prod + + ports: + - "3001:3001" + depends_on: + postgres: + condition: service_healthy + networks: + - fcc-network + volumes: + - ./apps:/app/apps + - ./nx.json:/app/nx.json + - ./tsconfig.base.json:/app/tsconfig.base.json + - ./package.json:/app/package.json + - backend_logs:/app/logs + command: sh -c "cd /app && npm run start:dev:docker" + + # Adminer - Database Admin Tool + adminer: + image: adminer:4-standalone + restart: unless-stopped + ports: + - "8080:8080" + depends_on: + - postgres + networks: + - fcc-network + environment: + ADMINER_DEFAULT_SERVER: postgres + ADMINER_DESIGN: lucas + +volumes: + postgres_data: + driver: local + backend_logs: + driver: local + +networks: + fcc-network: + driver: bridge + + # E2E Testing (soon) + # e2e: + # build: + # context: . + # dockerfile: apps/frontend-e2e/Dockerfile + # depends_on: + # - backend + # - frontend + # networks: + # - fcc-network + # volumes: + # - ./apps/frontend-e2e/cypress/videos:/app/cypress/videos + # - ./apps/frontend-e2e/cypress/screenshots:/app/cypress/screenshots \ No newline at end of file diff --git a/package.json b/package.json index ed9b3fe5..1e90c442 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,21 @@ "migration:run": "npm run typeorm -- migration:run -d apps/backend/src/data-source.ts", "migration:revert": "npm run typeorm -- migration:revert -d apps/backend/src/data-source.ts", "migration:create": "npm run typeorm -- migration:create apps/backend/src/migrations/$npm_config_name", - "test": "jest" + "test": "jest", + "start:dev": "npx nx serve backend", + "start:dev:docker": "npx nodemon --watch apps/backend/src --ext ts --legacy-watch --poll-interval 1000 --exec \"ts-node --project apps/backend/tsconfig.app.json -r tsconfig-paths/register apps/backend/src/main.ts\"", + "docker:build": "docker compose -f docker-compose.dev.yml build", + "docker:up": "docker compose -f docker-compose.dev.yml up -d", + "docker:up:dev": "docker compose -f docker-compose.dev.yml up -d postgres adminer backend-dev", + "docker:up:all": "docker compose -f docker-compose.dev.yml up -d", + "docker:down": "docker compose -f docker-compose.dev.yml down", + "docker:logs": "docker compose -f docker-compose.dev.yml logs -f", + "docker:restart": "npm run docker:down && npm run docker:up", + "docker:clean": "docker compose -f docker-compose.dev.yml down -v --remove-orphans", + "docker:migrate": "docker compose -f docker-compose.dev.yml exec backend npm run migration:run", + "docker:migrate:dev": "docker compose -f docker-compose.dev.yml exec backend-dev npm run migration:run", + "docker:migrate:status": "docker compose -f docker-compose.dev.yml exec backend-dev npm run typeorm -- migration:show -d apps/backend/src/data-source.ts", + "docker:migrate:revert": "docker compose -f docker-compose.dev.yml exec backend-dev npm run migration:revert" }, "private": true, "dependencies": { @@ -78,9 +92,10 @@ "jest": "^30.1.3", "jsdom": "^22.1.0", "lint-staged": "^14.0.1", + "nodemon": "^3.0.1", "nx": "^16.8.1", "nx-cloud": "^16.4.0", - "prettier": "^2.6.2", + "prettier": "^3.6.2", "ts-jest": "^29.1.0", "typescript": "^5.1.3", "vite": "^4.3.9", diff --git a/scripts/docker-dev.bat b/scripts/docker-dev.bat new file mode 100644 index 00000000..719401f2 --- /dev/null +++ b/scripts/docker-dev.bat @@ -0,0 +1,140 @@ +@echo off +REM Docker Development Helper for Windows +REM This script provides easy commands for Docker development + +setlocal enabledelayedexpansion + +REM Define colors (Windows) +set "GREEN=[92m" +set "YELLOW=[93m" +set "RED=[91m" +set "NC=[0m" + +REM Helper functions +:print_status +echo %GREEN%[INFO]%NC% %~1 +goto :eof + +:print_warning +echo %YELLOW%[WARNING]%NC% %~1 +goto :eof + +:print_error +echo %RED%[ERROR]%NC% %~1 +goto :eof + +REM Check if Docker is running +:check_docker +docker info >nul 2>&1 +if %errorlevel% neq 0 ( + call :print_error "Docker is not running. Please start Docker Desktop." + echo. + echo To install Docker Desktop: + echo 1. Download from https://www.docker.com/products/docker-desktop + echo 2. Install and restart your computer + echo 3. Start Docker Desktop + echo 4. Wait for Docker to be ready (check system tray icon) + echo. + pause + exit /b 1 +) +goto :eof + +REM Setup environment file +:setup_env +if not exist .env.docker ( + call :print_status "Creating .env.docker from template..." + copy .env.docker.example .env.docker >nul + call :print_warning "Please review and update .env.docker with your configuration" +) +goto :eof + +REM Build images +:build +call :print_status "Building Docker images..." +docker-compose -f docker-compose.dev.yml build --no-cache +goto :eof + +REM Start services +:up +call :print_status "Starting development services..." +call :setup_env +docker-compose -f docker-compose.dev.yml up -d +call :print_status "Services started! Backend: http://localhost:3000/api" +call :print_status "Database admin: http://localhost:8080 (user: postgres, pass: postgres, server: postgres)" +goto :eof + +REM Stop services +:down +call :print_status "Stopping development services..." +docker-compose -f docker-compose.dev.yml down +goto :eof + +REM Restart services +:restart +call :print_status "Restarting development services..." +call :down +call :up +goto :eof + +REM View logs +:logs +set service=%~1 +if "%service%"=="" set service=backend +call :print_status "Showing logs for %service%..." +docker-compose -f docker-compose.dev.yml logs -f %service% +goto :eof + +REM Run migrations +:migrate +call :print_status "Running database migrations..." +docker-compose -f docker-compose.dev.yml exec backend sh -c "cd /app && npm run migration:run" +goto :eof + +REM Show status +:status +call :print_status "Docker services status:" +docker-compose -f docker-compose.dev.yml ps +goto :eof + +REM Main script logic +if "%1"=="build" ( + call :check_docker + call :build +) else if "%1"=="up" ( + call :check_docker + call :up +) else if "%1"=="down" ( + call :down +) else if "%1"=="restart" ( + call :check_docker + call :restart +) else if "%1"=="logs" ( + call :logs %2 +) else if "%1"=="migrate" ( + call :migrate +) else if "%1"=="status" ( + call :status +) else ( + echo Docker Development Helper for Windows + echo. + echo Usage: %0 {command} + echo. + echo Commands: + echo build Build Docker images + echo up Start development services + echo down Stop development services + echo restart Restart development services + echo logs [service] Show logs (default: backend) + echo migrate Run database migrations + echo status Show services status + echo. + echo Examples: + echo %0 up Start all services + echo %0 logs backend Show backend logs + echo %0 migrate Run migrations + echo. + echo Prerequisites: + echo - Docker Desktop installed and running + echo - Download from: https://www.docker.com/products/docker-desktop +) diff --git a/scripts/docker-dev.sh b/scripts/docker-dev.sh new file mode 100644 index 00000000..69e97831 --- /dev/null +++ b/scripts/docker-dev.sh @@ -0,0 +1,187 @@ +#!/bin/bash + +# Docker Development Helper Script +# This script provides easy commands for Docker development + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Helper function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to check if Docker is running +check_docker() { + if ! docker info > /dev/null 2>&1; then + print_error "Docker is not running. Please start Docker Desktop." + exit 1 + fi +} + +# Function to setup environment file +setup_env() { + if [ ! -f .env.docker ]; then + print_status "Creating .env.docker from template..." + cp .env.docker.example .env.docker + print_warning "Please review and update .env.docker with your configuration" + fi +} + +# Function to build images +build() { + print_status "Building Docker images..." + docker-compose -f docker-compose.dev.yml build --no-cache +} + +# Function to start services +up() { + print_status "Starting development services..." + setup_env + docker-compose -f docker-compose.dev.yml up -d + print_status "Services started! Backend: http://localhost:3000/api" + print_status "Database admin: http://localhost:8080 (user: postgres, pass: postgres, server: postgres)" +} + +# Function to stop services +down() { + print_status "Stopping development services..." + docker-compose -f docker-compose.dev.yml down +} + +# Function to restart services +restart() { + print_status "Restarting development services..." + down + up +} + +# Function to view logs +logs() { + service=${1:-backend} + print_status "Showing logs for $service..." + docker-compose -f docker-compose.dev.yml logs -f "$service" +} + +# Function to run migrations +migrate() { + print_status "Running database migrations..." + docker-compose -f docker-compose.dev.yml exec backend sh -c "cd /app && npm run migration:run" +} + +# Function to generate migration +migrate_generate() { + if [ -z "$1" ]; then + print_error "Please provide a migration name: ./docker-dev.sh migrate:generate add_users" + exit 1 + fi + print_status "Generating migration: $1" + docker-compose -f docker-compose.dev.yml exec backend sh -c "cd /app && npm run migration:generate --name=$1" +} + +# Function to run backend shell +shell() { + print_status "Opening shell in backend container..." + docker-compose -f docker-compose.dev.yml exec backend sh +} + +# Function to run tests +test() { + service=${1:-backend} + print_status "Running tests for $service..." + docker-compose -f docker-compose.dev.yml exec "$service" sh -c "cd /app && npm run test:$service" +} + +# Function to clean up everything +clean() { + print_warning "This will remove all containers, volumes, and images. Are you sure? (y/N)" + read -r response + if [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then + print_status "Cleaning up Docker resources..." + docker-compose -f docker-compose.dev.yml down -v --remove-orphans + docker system prune -f + print_status "Cleanup complete!" + fi +} + +# Function to show status +status() { + print_status "Docker services status:" + docker-compose -f docker-compose.dev.yml ps +} + +# Main script logic +case "$1" in + "build") + check_docker + build + ;; + "up") + check_docker + up + ;; + "down") + down + ;; + "restart") + check_docker + restart + ;; + "logs") + logs "$2" + ;; + "migrate") + migrate + ;; + "migrate:generate") + migrate_generate "$2" + ;; + "shell") + shell + ;; + "test") + test "$2" + ;; + "clean") + clean + ;; + "status") + status + ;; + *) + echo "Docker Development Helper" + echo "" + echo "Usage: $0 {command}" + echo "" + echo "Commands:" + echo " build Build Docker images" + echo " up Start development services" + echo " down Stop development services" + echo " restart Restart development services" + echo " logs [service] Show logs (default: backend)" + echo " migrate Run database migrations" + echo " migrate:generate Generate new migration" + echo " shell Open shell in backend container" + echo " test [service] Run tests (default: backend)" + echo " status Show services status" + echo " clean Clean up all Docker resources" + echo "" + echo "Examples:" + echo " $0 up # Start all services" + echo " $0 logs backend # Show backend logs" + echo " $0 migrate:generate add_users # Generate migration" + ;; +esac diff --git a/scripts/init-db.sql b/scripts/init-db.sql new file mode 100644 index 00000000..dfdbe21f --- /dev/null +++ b/scripts/init-db.sql @@ -0,0 +1,43 @@ +-- Database initialization script +-- This script runs when the PostgreSQL container starts for the first time + +-- Create user first if not exists +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'fcc_user') THEN + CREATE USER fcc_user WITH PASSWORD 'fcc_password'; + END IF; +END +$$; + +-- Create the main database if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'fcc_dev') THEN + CREATE DATABASE fcc_dev OWNER fcc_user; + END IF; +END +$$; + +-- Create a test database for running tests +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'fcc_test') THEN + CREATE DATABASE fcc_test OWNER fcc_user; + END IF; +END +$$; + +-- Grant permissions +GRANT ALL PRIVILEGES ON DATABASE fcc_dev TO fcc_user; +GRANT ALL PRIVILEGES ON DATABASE fcc_test TO fcc_user; + +-- Enable necessary PostgreSQL extensions +\c fcc_dev; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +\c fcc_test; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Log completion +\echo 'Database initialization completed!' diff --git a/yarn.lock b/yarn.lock index 607608fc..87fbb767 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5458,6 +5458,21 @@ chokidar@3.5.3, "chokidar@>=3.0.0 <4.0.0", chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" +chokidar@^3.5.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + chownr@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" @@ -6111,7 +6126,7 @@ debug@^3.1.0, debug@^3.2.6, debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.3.1: +debug@^4, debug@^4.3.1: version "4.4.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== @@ -7892,6 +7907,11 @@ ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== +ignore-by-default@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" + integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== + ignore@^5.0.4, ignore@^5.1.9, ignore@^5.2.0, ignore@^5.2.4: version "5.2.4" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" @@ -10075,6 +10095,22 @@ node-releases@^2.0.19: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.20.tgz#e26bb79dbdd1e64a146df389c699014c611cbc27" integrity sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA== +nodemon@^3.0.1: + version "3.1.10" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.10.tgz#5015c5eb4fffcb24d98cf9454df14f4fecec9bc1" + integrity sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw== + dependencies: + chokidar "^3.5.2" + debug "^4" + ignore-by-default "^1.0.1" + minimatch "^3.1.2" + pstree.remy "^1.1.8" + semver "^7.5.3" + simple-update-notifier "^2.0.0" + supports-color "^5.5.0" + touch "^3.1.0" + undefsafe "^2.0.5" + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -11039,10 +11075,10 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier@^2.6.2: - version "2.8.8" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" - integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== +prettier@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393" + integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ== pretty-bytes@^5.6.0: version "5.6.0" @@ -11133,6 +11169,11 @@ psl@^1.1.33: resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== +pstree.remy@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" + integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== + pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" @@ -11818,6 +11859,13 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +simple-update-notifier@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" + integrity sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== + dependencies: + semver "^7.5.3" + sirv@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/sirv/-/sirv-2.0.3.tgz#ca5868b87205a74bef62a469ed0296abceccd446" @@ -12194,7 +12242,7 @@ stylus@^0.59.0: sax "~1.2.4" source-map "^0.7.3" -supports-color@^5.3.0: +supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== @@ -12407,6 +12455,11 @@ totalist@^3.0.0: resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8" integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ== +touch@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" + integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== + tough-cookie@^4.1.2, tough-cookie@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" @@ -12705,6 +12758,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +undefsafe@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" + integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== + undici-types@~5.26.4: version "5.26.5" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"