diff --git a/package-lock.json b/package-lock.json index d21b323..b4106ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,12 +20,12 @@ "@nestjs/swagger": "^11.2.6", "@nestjs/typeorm": "^11.0.0", "@types/ejs": "^3.1.5", - "@types/multer": "^2.1.0", "@types/nodemailer": "^7.0.11", "@types/uuid": "^10.0.0", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", + "dotenv": "^17.3.1", "ejs": "^4.0.1", "joi": "^18.0.2", "multer": "^2.1.1", @@ -50,6 +50,7 @@ "@types/bcrypt": "^5.0.2", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", @@ -4579,6 +4580,7 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -4589,6 +4591,7 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4640,6 +4643,7 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -4651,6 +4655,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -4663,6 +4668,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -4737,6 +4743,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, "license": "MIT", "dependencies": { "@types/express": "*" @@ -4796,18 +4803,21 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4817,6 +4827,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -6947,9 +6958,9 @@ } }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -6973,6 +6984,18 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -12707,6 +12730,18 @@ "ieee754": "^1.2.1" } }, + "node_modules/typeorm/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/typeorm/node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", diff --git a/package.json b/package.json index 239e142..a52749a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "stellarAid-api", + "name": "stellaraid-api", "version": "0.0.1", "description": "", "author": "", @@ -17,7 +17,11 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "typeorm-ts-node-commonjs", + "migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts", + "migration:run": "npm run typeorm -- migration:run -d src/database/data-source.ts", + "migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.1015.0", @@ -31,12 +35,12 @@ "@nestjs/swagger": "^11.2.6", "@nestjs/typeorm": "^11.0.0", "@types/ejs": "^3.1.5", - "@types/multer": "^2.1.0", "@types/nodemailer": "^7.0.11", "@types/uuid": "^10.0.0", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", + "dotenv": "^17.3.1", "ejs": "^4.0.1", "joi": "^18.0.2", "multer": "^2.1.1", @@ -61,6 +65,7 @@ "@types/bcrypt": "^5.0.2", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", @@ -97,6 +102,11 @@ "testEnvironment": "node", "moduleNameMapper": { "^src/(.*)$": "/src/$1" + }, + "globals": { + "ts-jest": { + "tsconfig": "tsconfig.spec.json" + } } } } diff --git a/src/app.module.ts b/src/app.module.ts index a7f2cce..fa5429b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import configuration from './database/config'; import { LoggerModule } from './logger/logger.module'; import { ProjectsModule } from './projects/projects.module'; import { MailModule } from './mail/mail.module'; +import { DonationsModule } from './donations/donations.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { MailModule } from './mail/mail.module'; // Users module provides user-facing endpoints such as change-password UsersModule, ProjectsModule, + DonationsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/common/services/file-upload.service.ts b/src/common/services/file-upload.service.ts index 5ed2f58..610ba60 100644 --- a/src/common/services/file-upload.service.ts +++ b/src/common/services/file-upload.service.ts @@ -3,6 +3,7 @@ import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { ConfigService } from '@nestjs/config'; import { randomUUID } from 'crypto'; +import { File } from 'buffer'; @Injectable() export class FileUploadService { diff --git a/src/database/data-source.ts b/src/database/data-source.ts new file mode 100644 index 0000000..c568030 --- /dev/null +++ b/src/database/data-source.ts @@ -0,0 +1,17 @@ +import { DataSource } from 'typeorm'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export default new DataSource({ + type: 'postgres', + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '5432', 10), + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + entities: ['dist/**/*.entity{.ts,.js}'], + migrations: ['src/database/migrations/*.ts'], + synchronize: false, + logging: true, +}); diff --git a/src/database/migrations/1711382400000-CreateDonationsTable.ts b/src/database/migrations/1711382400000-CreateDonationsTable.ts new file mode 100644 index 0000000..3413bc5 --- /dev/null +++ b/src/database/migrations/1711382400000-CreateDonationsTable.ts @@ -0,0 +1,127 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +export class CreateDonationsTable1711382400000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'donations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { + name: 'projectId', + type: 'uuid', + }, + { + name: 'donorId', + type: 'uuid', + isNullable: true, + }, + { + name: 'amount', + type: 'decimal', + precision: 18, + scale: 7, + }, + { + name: 'assetType', + type: 'varchar', + default: "'XLM'", + }, + { + name: 'transactionHash', + type: 'varchar', + isNullable: true, + isUnique: true, + }, + { + name: 'isAnonymous', + type: 'boolean', + default: false, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + ], + }), + true, + ); + + // Create indexes + await queryRunner.createIndex( + 'donations', + new TableIndex({ + name: 'IDX_donations_project_id', + columnNames: ['projectId'], + }), + ); + + await queryRunner.createIndex( + 'donations', + new TableIndex({ + name: 'IDX_donations_transaction_hash', + columnNames: ['transactionHash'], + }), + ); + + // Create foreign keys + await queryRunner.createForeignKey( + 'donations', + new TableForeignKey({ + name: 'FK_donations_project', + columnNames: ['projectId'], + referencedTableName: 'projects', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'donations', + new TableForeignKey({ + name: 'FK_donations_donor', + columnNames: ['donorId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('donations'); + + if (table) { + // Drop foreign keys + const projectForeignKey = table.foreignKeys.find(fk => fk.name === 'FK_donations_project'); + if (projectForeignKey) { + await queryRunner.dropForeignKey('donations', projectForeignKey); + } + + const donorForeignKey = table.foreignKeys.find(fk => fk.name === 'FK_donations_donor'); + if (donorForeignKey) { + await queryRunner.dropForeignKey('donations', donorForeignKey); + } + + // Drop indexes + const projectIdIndex = table.indices.find(idx => idx.name === 'IDX_donations_project_id'); + if (projectIdIndex) { + await queryRunner.dropIndex('donations', projectIdIndex); + } + + const transactionHashIndex = table.indices.find(idx => idx.name === 'IDX_donations_transaction_hash'); + if (transactionHashIndex) { + await queryRunner.dropIndex('donations', transactionHashIndex); + } + } + + await queryRunner.dropTable('donations'); + } +} diff --git a/src/donations/donations.controller.ts b/src/donations/donations.controller.ts new file mode 100644 index 0000000..df2f0f2 --- /dev/null +++ b/src/donations/donations.controller.ts @@ -0,0 +1,121 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + ParseUUIDPipe, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { DonationsService } from './providers/donations.service'; +import { CreateDonationDto } from './dto/create-donation.dto'; +import { UpdateDonationDto } from './dto/update-donation.dto'; +import { DonationResponseDto } from './dto/donation-response.dto'; + +@ApiTags('Donations') +@Controller('donations') +export class DonationsController { + constructor(private readonly donationsService: DonationsService) {} + + @Post() + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a new donation' }) + @ApiResponse({ status: 201, type: DonationResponseDto }) + @ApiResponse({ status: 400, description: 'Bad request - Invalid data' }) + @ApiResponse({ status: 409, description: 'Conflict - Transaction hash already exists' }) + create(@Body() createDonationDto: CreateDonationDto) { + return this.donationsService.create(createDonationDto); + } + + @Get() + @ApiOperation({ summary: 'Get all donations with pagination' }) + @ApiResponse({ status: 200, description: 'List of donations' }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + findAll( + @Query('page', new ParseUUIDPipe({ optional: true })) page: number = 1, + @Query('limit', new ParseUUIDPipe({ optional: true })) limit: number = 10, + ) { + return this.donationsService.findAll(page, limit); + } + + @Get(':id') + @ApiOperation({ summary: 'Get donation by ID' }) + @ApiResponse({ status: 200, type: DonationResponseDto }) + @ApiResponse({ status: 404, description: 'Donation not found' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.donationsService.findOne(id); + } + + @Get('project/:projectId') + @ApiOperation({ summary: 'Get donations for a specific project' }) + @ApiResponse({ status: 200, description: 'List of project donations' }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + findByProject( + @Param('projectId', ParseUUIDPipe) projectId: string, + @Query('page') page: number = 1, + @Query('limit') limit: number = 10, + ) { + return this.donationsService.findByProject(projectId, page, limit); + } + + @Get('donor/:donorId') + @ApiOperation({ summary: 'Get donations by a specific donor' }) + @ApiResponse({ status: 200, description: 'List of donor donations' }) + @ApiQuery({ name: 'page', required: false, type: Number, example: 1 }) + @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 }) + findByDonor( + @Param('donorId', ParseUUIDPipe) donorId: string, + @Query('page') page: number = 1, + @Query('limit') limit: number = 10, + ) { + return this.donationsService.findByDonor(donorId, page, limit); + } + + @Get('transaction/:hash') + @ApiOperation({ summary: 'Get donation by transaction hash' }) + @ApiResponse({ status: 200, type: DonationResponseDto }) + @ApiResponse({ status: 404, description: 'Donation not found' }) + findByTransactionHash(@Param('hash') hash: string) { + return this.donationsService.findByTransactionHash(hash); + } + + @Patch(':id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Update a donation' }) + @ApiResponse({ status: 200, type: DonationResponseDto }) + @ApiResponse({ status: 404, description: 'Donation not found' }) + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateDonationDto: UpdateDonationDto, + ) { + return this.donationsService.update(id, updateDonationDto); + } + + @Delete(':id') + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete a donation' }) + @ApiResponse({ status: 200, description: 'Donation deleted successfully' }) + @ApiResponse({ status: 404, description: 'Donation not found' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.donationsService.remove(id); + } + + @Get('analytics/project/:projectId/total') + @ApiOperation({ summary: 'Get total donations amount for a project' }) + @ApiResponse({ status: 200, description: 'Total donations amount' }) + getTotalForProject(@Param('projectId', ParseUUIDPipe) projectId: string) { + return this.donationsService.getTotalDonationsForProject(projectId); + } + + @Get('analytics/project/:projectId/count') + @ApiOperation({ summary: 'Get donation count for a project' }) + @ApiResponse({ status: 200, description: 'Donation count' }) + getCountForProject(@Param('projectId', ParseUUIDPipe) projectId: string) { + return this.donationsService.getDonationCountForProject(projectId); + } +} diff --git a/src/donations/donations.module.ts b/src/donations/donations.module.ts new file mode 100644 index 0000000..6de1af9 --- /dev/null +++ b/src/donations/donations.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DonationsController } from './donations.controller'; +import { DonationsService } from './providers/donations.service'; +import { Donation } from './entities/donation.entity'; +import { Project } from '../projects/entities/project.entity'; +import { User } from '../users/entities/user.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Donation, Project, User]), + ], + controllers: [DonationsController], + providers: [DonationsService], + exports: [DonationsService, TypeOrmModule], +}) +export class DonationsModule {} diff --git a/src/donations/dto/create-donation.dto.ts b/src/donations/dto/create-donation.dto.ts new file mode 100644 index 0000000..aed5f02 --- /dev/null +++ b/src/donations/dto/create-donation.dto.ts @@ -0,0 +1,29 @@ +import { IsNotEmpty, IsNumber, IsString, IsOptional, Min } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateDonationDto { + @ApiProperty({ example: 'project-uuid', description: 'Project ID to donate to' }) + @IsNotEmpty() + @IsString() + projectId: string; + + @ApiProperty({ example: 100, description: 'Donation amount' }) + @IsNotEmpty() + @IsNumber() + @Min(0.0000001) + amount: number; + + @ApiProperty({ example: 'XLM', description: 'Asset type for donation', default: 'XLM' }) + @IsOptional() + @IsString() + assetType?: string; + + @ApiProperty({ example: 'transaction-hash-xyz', description: 'Blockchain transaction hash' }) + @IsNotEmpty() + @IsString() + transactionHash: string; + + @ApiProperty({ example: false, description: 'Whether donation is anonymous', default: false }) + @IsOptional() + isAnonymous?: boolean = false; +} diff --git a/src/donations/dto/donation-response.dto.ts b/src/donations/dto/donation-response.dto.ts new file mode 100644 index 0000000..a191fd6 --- /dev/null +++ b/src/donations/dto/donation-response.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Donation } from '../entities/donation.entity'; + +export class DonationResponseDto { + @ApiProperty({ example: 'donation-uuid', description: 'Donation ID' }) + id: string; + + @ApiProperty({ example: 'project-uuid', description: 'Project ID' }) + projectId: string; + + @ApiProperty({ example: 'donor-uuid', nullable: true, description: 'Donor ID' }) + donorId: string | null; + + @ApiProperty({ example: 100, description: 'Donation amount' }) + amount: number; + + @ApiProperty({ example: 'XLM', description: 'Asset type' }) + assetType: string; + + @ApiProperty({ example: 'transaction-hash-xyz', nullable: true, description: 'Transaction hash' }) + transactionHash: string | null; + + @ApiProperty({ example: false, description: 'Whether donation is anonymous' }) + isAnonymous: boolean; + + @ApiProperty({ example: '2024-01-01T00:00:00Z', description: 'Creation timestamp' }) + createdAt: Date; + + static fromEntity(donation: Donation): DonationResponseDto { + return { + id: donation.id, + projectId: donation.projectId, + donorId: donation.donorId, + amount: donation.amount, + assetType: donation.assetType, + transactionHash: donation.transactionHash, + isAnonymous: donation.isAnonymous, + createdAt: donation.createdAt, + }; + } +} diff --git a/src/donations/dto/update-donation.dto.ts b/src/donations/dto/update-donation.dto.ts new file mode 100644 index 0000000..99e9e2a --- /dev/null +++ b/src/donations/dto/update-donation.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateDonationDto } from './create-donation.dto'; + +export class UpdateDonationDto extends PartialType(CreateDonationDto) {} diff --git a/src/projects/entities/donation.entity.ts b/src/donations/entities/donation.entity.ts similarity index 75% rename from src/projects/entities/donation.entity.ts rename to src/donations/entities/donation.entity.ts index a4c3022..c4a7bcf 100644 --- a/src/projects/entities/donation.entity.ts +++ b/src/donations/entities/donation.entity.ts @@ -6,13 +6,15 @@ import { ManyToOne, JoinColumn, Index, + Unique, } from 'typeorm'; import { User } from '../../users/entities/user.entity'; -import { Project } from './project.entity'; +import { Project } from '../../projects/entities/project.entity'; @Entity('donations') @Index('IDX_donations_project_id', ['projectId']) -@Index('IDX_donations_donor_id', ['donorId']) +@Index('IDX_donations_transaction_hash', ['transactionHash']) +@Unique('UQ_donations_transaction_hash', ['transactionHash']) export class Donation { @PrimaryGeneratedColumn('uuid') id: string; @@ -36,7 +38,10 @@ export class Donation { @Column({ type: 'decimal', precision: 18, scale: 7 }) amount: number; - @Column({ nullable: true }) + @Column({ default: 'XLM' }) + assetType: string; + + @Column({ nullable: true, unique: true }) transactionHash: string | null; @Column({ default: false }) diff --git a/src/donations/providers/donations.service.ts b/src/donations/providers/donations.service.ts new file mode 100644 index 0000000..d785c01 --- /dev/null +++ b/src/donations/providers/donations.service.ts @@ -0,0 +1,138 @@ +import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Donation } from '../entities/donation.entity'; +import { CreateDonationDto } from '../dto/create-donation.dto'; +import { UpdateDonationDto } from '../dto/update-donation.dto'; +import { DonationResponseDto } from '../dto/donation-response.dto'; + +@Injectable() +export class DonationsService { + constructor( + @InjectRepository(Donation) + private donationsRepository: Repository, + ) {} + + async create(createDonationDto: CreateDonationDto): Promise { + try { + const donation = this.donationsRepository.create({ + ...createDonationDto, + }); + + const savedDonation = await this.donationsRepository.save(donation); + return DonationResponseDto.fromEntity(savedDonation); + } catch (error) { + if (error.code === '23505') { // PostgreSQL unique violation + throw new ConflictException('A donation with this transaction hash already exists'); + } + throw new BadRequestException('Failed to create donation'); + } + } + + async findAll(page: number = 1, limit: number = 10): Promise<{ data: DonationResponseDto[]; total: number }> { + const [data, total] = await this.donationsRepository.findAndCount({ + relations: ['project', 'donor'], + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { + data: data.map((donation) => DonationResponseDto.fromEntity(donation)), + total, + }; + } + + async findOne(id: string): Promise { + const donation = await this.donationsRepository.findOne({ + where: { id }, + relations: ['project', 'donor'], + }); + + if (!donation) { + throw new NotFoundException(`Donation with ID ${id} not found`); + } + + return DonationResponseDto.fromEntity(donation); + } + + async findByProject(projectId: string, page: number = 1, limit: number = 10): Promise<{ data: DonationResponseDto[]; total: number }> { + const [data, total] = await this.donationsRepository.findAndCount({ + where: { projectId }, + relations: ['donor'], + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { + data: data.map((donation) => DonationResponseDto.fromEntity(donation)), + total, + }; + } + + async findByDonor(donorId: string, page: number = 1, limit: number = 10): Promise<{ data: DonationResponseDto[]; total: number }> { + const [data, total] = await this.donationsRepository.findAndCount({ + where: { donorId }, + relations: ['project'], + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { + data: data.map((donation) => DonationResponseDto.fromEntity(donation)), + total, + }; + } + + async findByTransactionHash(transactionHash: string): Promise { + const donation = await this.donationsRepository.findOne({ + where: { transactionHash }, + relations: ['project', 'donor'], + }); + + if (!donation) { + throw new NotFoundException(`Donation with transaction hash ${transactionHash} not found`); + } + + return DonationResponseDto.fromEntity(donation); + } + + async update(id: string, updateDonationDto: UpdateDonationDto): Promise { + const donation = await this.findOne(id); + + Object.assign(donation, updateDonationDto); + + try { + const updatedDonation = await this.donationsRepository.save(donation); + return DonationResponseDto.fromEntity(updatedDonation); + } catch (error) { + if (error.code === '23505') { + throw new ConflictException('A donation with this transaction hash already exists'); + } + throw new BadRequestException('Failed to update donation'); + } + } + + async remove(id: string): Promise { + const donation = await this.findOne(id); + await this.donationsRepository.delete(donation.id); + } + + async getTotalDonationsForProject(projectId: string): Promise { + const result = await this.donationsRepository + .createQueryBuilder('donation') + .select('SUM(donation.amount)', 'total') + .where('donation.projectId = :projectId', { projectId }) + .getRawOne(); + + return parseFloat(result.total) || 0; + } + + async getDonationCountForProject(projectId: string): Promise { + return await this.donationsRepository.count({ + where: { projectId }, + }); + } +} diff --git a/src/projects/entities/project.entity.ts b/src/projects/entities/project.entity.ts index 68177ea..58cffb9 100644 --- a/src/projects/entities/project.entity.ts +++ b/src/projects/entities/project.entity.ts @@ -10,7 +10,7 @@ import { Index, } from 'typeorm'; import { User } from '../../users/entities/user.entity'; -import { Donation } from './donation.entity'; +import { Donation } from '../../donations/entities/donation.entity'; import { ProjectImage } from './project-image.entity'; import { ProjectHistory } from './project-history.entity'; import { ProjectCategory } from 'src/common/enums/project-category.enum'; diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index 34ccf75..d34d5e0 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MulterModule } from '@nestjs/platform-express'; import { Project } from './entities/project.entity'; -import { Donation } from './entities/donation.entity'; +import { Donation } from '../donations/entities/donation.entity'; import { ProjectHistory } from './entities/project-history.entity'; import { ProjectImage } from './entities/project-image.entity'; import { User } from '../users/entities/user.entity'; diff --git a/src/projects/providers/projects.service.ts b/src/projects/providers/projects.service.ts index d1f0135..cf321e3 100644 --- a/src/projects/providers/projects.service.ts +++ b/src/projects/providers/projects.service.ts @@ -10,7 +10,7 @@ import { ProjectStatus } from 'src/common/enums/project-status.enum'; import { ProjectSortBy } from 'src/common/enums/projects-sortBy.enum'; import { Project } from '../entities/project.entity'; import { ProjectHistory } from '../entities/project-history.entity'; -import { Donation } from '../entities/donation.entity'; +import { Donation } from '../../donations/entities/donation.entity'; import { CreateProjectDto } from '../dto/create-project.dto'; import { GetProjectsQueryDto } from '../dto/get-projects-query.dto'; import { UpdateProjectStatusDto } from '../dto/update-project-status.dto'; diff --git a/src/projects/services/analytics.service.ts b/src/projects/services/analytics.service.ts index 62f928d..fa89ca8 100644 --- a/src/projects/services/analytics.service.ts +++ b/src/projects/services/analytics.service.ts @@ -2,7 +2,7 @@ import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nest import { InjectRepository } from '@nestjs/typeorm'; import { Repository, SelectQueryBuilder } from 'typeorm'; import { Project } from '../entities/project.entity'; -import { Donation } from '../entities/donation.entity'; +import { Donation } from '../../donations/entities/donation.entity'; import { User } from '../../users/entities/user.entity'; import { AnalyticsQueryDto } from '../dto/analytics-query.dto'; diff --git a/src/types/multer.d.ts b/src/types/multer.d.ts new file mode 100644 index 0000000..c83be5b --- /dev/null +++ b/src/types/multer.d.ts @@ -0,0 +1,19 @@ +declare global { + namespace Express { + namespace Multer { + interface File { + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; + size: number; + destination: string; + filename: string; + path: string; + buffer: Buffer; + } + } + } +} + +export {}; diff --git a/test/projects/projects.service.spec.ts b/test/projects/projects.service.spec.ts index 4aef0a8..3f3dc34 100644 --- a/test/projects/projects.service.spec.ts +++ b/test/projects/projects.service.spec.ts @@ -4,7 +4,7 @@ import { ProjectCategory } from 'src/common/enums/project-category.enum'; import { ProjectStatus } from 'src/common/enums/project-status.enum'; import { ProjectSortBy } from 'src/common/enums/projects-sortBy.enum'; import { GetProjectsQueryDto } from 'src/projects/dto/get-projects-query.dto'; -import { Donation } from 'src/projects/entities/donation.entity'; +import { Donation } from 'src/donations/entities/donation.entity'; import { Project } from 'src/projects/entities/project.entity'; import { ProjectHistory } from 'src/projects/entities/project-history.entity'; import { ProjectsService } from 'src/projects/providers/projects.service'; diff --git a/tsconfig.json b/tsconfig.json index 7d909e5..b9d0923 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "forceConsistentCasingInFileNames": true, "noImplicitAny": false, "strictBindCallApply": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "types": ["node", "jest"] } } diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..1082064 --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"] + }, + "include": [ + "src/**/*", + "test/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +}