diff --git a/package-lock.json b/package-lock.json index 163d5fc..811483b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1110,7 +1110,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3093,7 +3092,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3264,7 +3262,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.13.tgz", "integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -3324,7 +3321,6 @@ "integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3408,7 +3404,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.17.tgz", "integrity": "sha512-mAf4eOsSBsTOn/VbrUO1gsjW6dVh91qqXPMXun4dN8SnNjf7PTQagM9o8d6ab8ZBpNe6UdZftdrZoDetU+n4Qg==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -4591,7 +4586,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4730,7 +4724,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4916,7 +4909,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -5604,7 +5596,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5666,7 +5657,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6149,7 +6139,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6372,7 +6361,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -6429,15 +6417,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7176,7 +7162,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7237,7 +7222,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7470,7 +7454,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8761,7 +8744,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -10532,7 +10514,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -10664,7 +10645,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -10773,7 +10753,6 @@ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", "license": "MIT", - "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -10972,7 +10951,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11183,8 +11161,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -11317,7 +11294,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -12075,7 +12051,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12435,7 +12410,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12596,7 +12570,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -12816,7 +12789,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13190,6 +13162,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -13208,6 +13181,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -13221,6 +13195,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -13235,6 +13210,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -13244,7 +13220,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/mime-db": { "version": "1.52.0", @@ -13252,6 +13229,7 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -13262,6 +13240,7 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -13275,6 +13254,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/src/donations/dto/get-project-donations-query.dto.ts b/src/donations/dto/get-project-donations-query.dto.ts new file mode 100644 index 0000000..a9c6403 --- /dev/null +++ b/src/donations/dto/get-project-donations-query.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class GetProjectDonationsQueryDto { + @ApiPropertyOptional({ + example: 1, + description: 'Page number (default: 1)', + minimum: 1, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + example: 10, + description: 'Number of items per page (default: 10, max: 100)', + minimum: 1, + maximum: 100, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 10; +} diff --git a/src/donations/dto/project-donations-response.dto.ts b/src/donations/dto/project-donations-response.dto.ts new file mode 100644 index 0000000..d8e90e5 --- /dev/null +++ b/src/donations/dto/project-donations-response.dto.ts @@ -0,0 +1,136 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Donation } from '../entities/donation.entity'; + +export class DonorInfoDto { + @ApiProperty({ example: 'donor-uuid', description: 'Donor ID' }) + id: string; + + @ApiProperty({ example: 'John', description: 'Donor first name' }) + firstName: string; + + @ApiProperty({ example: 'Doe', description: 'Donor last name' }) + lastName: string; + + @ApiProperty({ + example: 'GAA2M7F4E3C4D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6', + nullable: true, + description: 'Donor wallet address', + }) + walletAddress: string | null; + + @ApiProperty({ + example: 'https://example.com/avatar.jpg', + nullable: true, + description: 'Donor avatar URL', + }) + avatarUrl: string | null; +} + +export class ProjectDonationItemDto { + @ApiProperty({ example: 'donation-uuid', description: 'Donation ID' }) + id: string; + + @ApiProperty({ example: 100, description: 'Donation amount' }) + amount: number; + + @ApiProperty({ example: 'XLM', description: 'Asset type' }) + assetType: string; + + @ApiProperty({ + example: 'transaction-hash-xyz', + nullable: true, + description: 'Blockchain transaction hash', + }) + transactionHash: string | null; + + @ApiProperty({ + example: 'https://stellar.expert/explorer/public/tx/transaction-hash-xyz', + nullable: true, + description: 'Blockchain explorer link', + }) + blockchainExplorerUrl: string | null; + + @ApiProperty({ + example: false, + description: 'Whether donation is anonymous', + }) + isAnonymous: boolean; + + @ApiProperty({ + example: '2024-01-01T00:00:00Z', + description: 'Donation timestamp', + }) + createdAt: Date; + + @ApiProperty({ + type: DonorInfoDto, + nullable: true, + description: 'Donor information (null if anonymous)', + }) + donor: DonorInfoDto | null; +} + +export class ProjectDonationsStatsDto { + @ApiProperty({ example: 150, description: 'Total number of donations' }) + totalDonations: number; + + @ApiProperty({ example: 50000.5, description: 'Total amount donated' }) + totalAmount: number; + + @ApiProperty({ example: 333.33, description: 'Average donation amount' }) + averageDonation: number; + + @ApiProperty({ example: 100, description: 'Number of unique donors' }) + uniqueDonors: number; +} + +export class ProjectDonationsResponseDto { + @ApiProperty({ + type: [ProjectDonationItemDto], + description: 'List of donations', + }) + data: ProjectDonationItemDto[]; + + @ApiProperty({ example: 150, description: 'Total number of donations' }) + total: number; + + @ApiProperty({ example: 1, description: 'Current page' }) + page: number; + + @ApiProperty({ example: 10, description: 'Items per page' }) + limit: number; + + @ApiProperty({ + type: ProjectDonationsStatsDto, + description: 'Donation statistics', + }) + stats: ProjectDonationsStatsDto; + + static fromEntity( + donation: Donation, + blockchainExplorerBaseUrl: string = 'https://stellar.expert/explorer/public/tx', + ): ProjectDonationItemDto { + const donor = donation.donor + ? { + id: donation.donor.id, + firstName: donation.donor.firstName, + lastName: donation.donor.lastName, + walletAddress: donation.donor.walletAddress, + avatarUrl: donation.donor.avatarUrl, + } + : null; + + return { + id: donation.id, + amount: Number(donation.amount), + assetType: donation.assetType, + transactionHash: donation.transactionHash, + blockchainExplorerUrl: donation.transactionHash + ? `${blockchainExplorerBaseUrl}/${donation.transactionHash}` + : null, + isAnonymous: donation.isAnonymous, + createdAt: donation.createdAt, + donor: donation.isAnonymous ? null : donor, + }; + } +} diff --git a/src/donations/providers/donations.service.ts b/src/donations/providers/donations.service.ts index 06dd8f2..8992691 100644 --- a/src/donations/providers/donations.service.ts +++ b/src/donations/providers/donations.service.ts @@ -12,6 +12,11 @@ import { User } from '../../users/entities/user.entity'; import { CreateDonationDto } from '../dto/create-donation.dto'; import { UpdateDonationDto } from '../dto/update-donation.dto'; import { DonationResponseDto } from '../dto/donation-response.dto'; +import { + ProjectDonationsResponseDto, + ProjectDonationItemDto, + ProjectDonationsStatsDto, +} from '../dto/project-donations-response.dto'; import { StellarBlockchainService } from '../../common/services/stellar-blockchain.service'; import { MailService } from '../../mail/mail.service'; @@ -291,4 +296,66 @@ export class DonationsService { where: { projectId }, }); } + + async findDonationsByProject( + projectId: string, + page: number = 1, + limit: number = 10, + ): Promise { + // Fetch paginated donations with donor info + const [donations, total] = await this.donationsRepository.findAndCount({ + where: { projectId }, + relations: ['donor'], + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + // Calculate statistics + const stats = await this.calculateProjectDonationStats(projectId); + + // Transform donations to DTOs with anonymization + const donationItems: ProjectDonationItemDto[] = donations.map((donation) => + ProjectDonationsResponseDto.fromEntity(donation), + ); + + return { + data: donationItems, + total, + page, + limit, + stats, + }; + } + + private async calculateProjectDonationStats( + projectId: string, + ): Promise { + // Get total donations count and amount + const totalStats = await this.donationsRepository + .createQueryBuilder('donation') + .select('COUNT(donation.id)', 'totalDonations') + .addSelect('SUM(donation.amount)', 'totalAmount') + .where('donation.projectId = :projectId', { projectId }) + .getRawOne(); + + // Get unique donors count (excluding anonymous donations) + const uniqueDonorsResult = await this.donationsRepository + .createQueryBuilder('donation') + .select('COUNT(DISTINCT donation.donorId)', 'uniqueDonors') + .where('donation.projectId = :projectId', { projectId }) + .andWhere('donation.isAnonymous = :isAnonymous', { isAnonymous: false }) + .getRawOne(); + + const totalDonations = parseInt(totalStats.totalDonations, 10) || 0; + const totalAmount = parseFloat(totalStats.totalAmount) || 0; + const uniqueDonors = parseInt(uniqueDonorsResult.uniqueDonors, 10) || 0; + + return { + totalDonations, + totalAmount, + averageDonation: totalDonations > 0 ? totalAmount / totalDonations : 0, + uniqueDonors, + }; + } } diff --git a/src/projects/projects.controller.ts b/src/projects/projects.controller.ts index 96255c8..6b3aa63 100644 --- a/src/projects/projects.controller.ts +++ b/src/projects/projects.controller.ts @@ -34,6 +34,8 @@ import { UpdateProjectStatusDto } from './dto/update-project-status.dto'; import { SearchProjectsDto } from './dto/search-projects.dto'; import { AnalyticsQueryDto } from './dto/analytics-query.dto'; import { AdminUpdateProjectStatusDto } from './dto/admin-update-project.dto'; +import { GetProjectDonationsQueryDto } from '../donations/dto/get-project-donations-query.dto'; +import { ProjectDonationsResponseDto } from '../donations/dto/project-donations-response.dto'; import { Public } from '../common/decorators/public.decorator'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { RolesGuard } from '../common/guards/roles.guard'; @@ -43,6 +45,7 @@ import { ProjectsService } from './providers/projects.service'; import { ImageUploadService } from './services/image-upload.service'; import { SearchService } from './services/search.service'; import { AnalyticsService } from './services/analytics.service'; +import { DonationsService } from '../donations/providers/donations.service'; @ApiTags('projects') @ApiBearerAuth() @@ -53,6 +56,7 @@ export class ProjectsController { private readonly imageUploadService: ImageUploadService, private readonly searchService: SearchService, private readonly analyticsService: AnalyticsService, + private readonly donationsService: DonationsService, ) {} //______________________ Endpoint to create a new project (CREATOR role required) @@ -385,4 +389,32 @@ export class ProjectsController { }, }; } + + //_____________________ Endpoint to get project donations (public) + @Get(':id/donations') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get all donations for a specific project', + description: + 'Returns paginated donations with donor information (respects anonymity settings)', + }) + @ApiOkResponse({ + description: 'Donations retrieved successfully', + type: ProjectDonationsResponseDto, + }) + @ApiNotFoundResponse({ description: 'Project not found' }) + async getProjectDonations( + @Param('id', ParseUUIDPipe) id: string, + @Query() query: GetProjectDonationsQueryDto, + ): Promise { + // Verify project exists first + await this.projectsService.findOnePublic(id); + + return this.donationsService.findDonationsByProject( + id, + query.page, + query.limit, + ); + } } diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index b1de69f..ed6ac8f 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -13,6 +13,7 @@ import { FileUploadService } from '../common/services/file-upload.service'; import { SearchService } from './services/search.service'; import { AnalyticsService } from './services/analytics.service'; import { MailModule } from '../mail/mail.module'; +import { DonationsModule } from '../donations/donations.module'; @Module({ imports: [ @@ -30,6 +31,7 @@ import { MailModule } from '../mail/mail.module'; }, }), MailModule, + DonationsModule, ], controllers: [ProjectsController], providers: [ diff --git a/test/projects/projects.controller.spec.ts b/test/projects/projects.controller.spec.ts index 1bfa46c..352fe9f 100644 --- a/test/projects/projects.controller.spec.ts +++ b/test/projects/projects.controller.spec.ts @@ -9,6 +9,7 @@ import { ProjectsService } from 'src/projects/providers/projects.service'; import { ImageUploadService } from 'src/projects/services/image-upload.service'; import { SearchService } from 'src/projects/services/search.service'; import { AnalyticsService } from 'src/projects/services/analytics.service'; +import { DonationsService } from 'src/donations/providers/donations.service'; describe('ProjectsController', () => { let controller: ProjectsController; @@ -40,6 +41,11 @@ describe('ProjectsController', () => { getCreatorAnalytics: jest.fn(), }; + // Mock DonationsService + const mockDonationsService = { + findDonationsByProject: jest.fn(), + }; + // Mock project data const mockProjects: Partial[] = [ { @@ -66,7 +72,7 @@ describe('ProjectsController', () => { id: '550e8400-e29b-41d4-a716-446655440002', title: 'Healthcare Initiative', description: 'Providing medical supplies to underserved communities', - category: ProjectCategory.HEALTHCARE, + category: ProjectCategory.HEALTH, status: ProjectStatus.APPROVED, goalAmount: 15000, fundsRaised: 8000, @@ -104,6 +110,10 @@ describe('ProjectsController', () => { provide: AnalyticsService, useValue: mockAnalyticsService, }, + { + provide: DonationsService, + useValue: mockDonationsService, + }, ], }).compile(); @@ -271,7 +281,7 @@ describe('ProjectsController', () => { it('should return search results without suggestions', async () => { const searchDto = { - category: ProjectCategory.HEALTHCARE, + category: ProjectCategory.HEALTH, limit: 5, offset: 0, }; @@ -315,7 +325,7 @@ describe('ProjectsController', () => { totalProjects: 100, categoryDistribution: { [ProjectCategory.EDUCATION]: 40, - [ProjectCategory.HEALTHCARE]: 30, + [ProjectCategory.HEALTH]: 30, [ProjectCategory.ENVIRONMENT]: 30, }, statusDistribution: {