diff --git a/package-lock.json b/package-lock.json index 2bb1f15..9b23a87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@types/nodemailer": "^7.0.11", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", - "class-validator": "^0.14.3", + "class-validator": "^0.14.4", "ejs": "^4.0.1", "joi": "^18.0.2", "nestjs-pino": "^4.6.0", @@ -238,7 +238,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", @@ -2221,7 +2220,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", @@ -2392,7 +2390,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", @@ -2452,7 +2449,6 @@ "integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2536,7 +2532,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.13.tgz", "integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -2996,7 +2991,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3125,7 +3119,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" } @@ -3305,7 +3298,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", @@ -3993,7 +3985,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4055,7 +4046,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", @@ -4532,7 +4522,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4755,7 +4744,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4812,19 +4800,17 @@ "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.3", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", - "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", + "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", - "validator": "^13.15.20" + "validator": "^13.15.22" } }, "node_modules/cli-cursor": { @@ -5547,7 +5533,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", @@ -5608,7 +5593,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5841,7 +5825,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", @@ -7097,7 +7080,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -8878,7 +8860,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", @@ -8995,7 +8976,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", @@ -9104,7 +9084,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", @@ -9303,7 +9282,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9514,8 +9492,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", @@ -9648,7 +9625,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -10394,7 +10370,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", @@ -10754,7 +10729,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", @@ -10915,7 +10889,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", @@ -11110,7 +11083,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11484,6 +11456,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -11502,6 +11475,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -11515,6 +11489,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -11529,6 +11504,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -11538,7 +11514,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", @@ -11546,6 +11523,7 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -11556,6 +11534,7 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -11569,6 +11548,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/package.json b/package.json index 03172f5..08c8cea 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@types/nodemailer": "^7.0.11", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", - "class-validator": "^0.14.3", + "class-validator": "^0.14.4", "ejs": "^4.0.1", "joi": "^18.0.2", "nestjs-pino": "^4.6.0", diff --git a/src/common/enums/project-status.enum.ts b/src/common/enums/project-status.enum.ts index 4fccaa3..27b2f9b 100644 --- a/src/common/enums/project-status.enum.ts +++ b/src/common/enums/project-status.enum.ts @@ -3,6 +3,7 @@ export enum ProjectStatus { PENDING = 'pending', APPROVED = 'approved', ACTIVE = 'active', + PAUSED = 'paused', COMPLETED = 'completed', REJECTED = 'rejected', } diff --git a/src/projects/dto/update-project-status.dto.ts b/src/projects/dto/update-project-status.dto.ts new file mode 100644 index 0000000..6aa9978 --- /dev/null +++ b/src/projects/dto/update-project-status.dto.ts @@ -0,0 +1,13 @@ +import { IsString, IsOptional, IsIn } from 'class-validator'; +import { ProjectStatus } from 'src/common/enums/project-status.enum'; + +export class UpdateProjectStatusDto { + @IsString() + @IsOptional() + @IsIn([ProjectStatus.PAUSED, ProjectStatus.ACTIVE, ProjectStatus.COMPLETED]) + status: ProjectStatus; + + @IsString() + @IsOptional() + reason?: string; +} diff --git a/src/projects/entities/project-history.entity.ts b/src/projects/entities/project-history.entity.ts new file mode 100644 index 0000000..1ee2ae3 --- /dev/null +++ b/src/projects/entities/project-history.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Project } from './project.entity'; +import { ProjectStatus } from 'src/common/enums/project-status.enum'; + +@Entity('project_history') +export class ProjectHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: ProjectStatus }) + previousStatus: ProjectStatus; + + @Column({ type: 'enum', enum: ProjectStatus }) + newStatus: ProjectStatus; + + @Column({ nullable: true, type: 'text' }) + reason: string | null; + + @Column() + projectId: string; + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'projectId' }) + project: Project; + + @Column() + changedBy: string; + + @ManyToOne(() => User, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'changedBy' }) + changedByUser: User; + + @CreateDateColumn() + changedAt: Date; +} diff --git a/src/projects/entities/project.entity.ts b/src/projects/entities/project.entity.ts index 11837f4..82ffcc7 100644 --- a/src/projects/entities/project.entity.ts +++ b/src/projects/entities/project.entity.ts @@ -11,6 +11,7 @@ import { } from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { Donation } from './donation.entity'; +import { ProjectHistory } from './project-history.entity'; import { ProjectCategory } from 'src/common/enums/project-category.enum'; import { ProjectStatus } from 'src/common/enums/project-status.enum'; @@ -73,6 +74,9 @@ export class Project { @OneToMany(() => Donation, (donation) => donation.project, { cascade: true }) donations: Donation[]; + @OneToMany(() => ProjectHistory, (history) => history.project, { cascade: true }) + history: ProjectHistory[]; + @CreateDateColumn() createdAt: Date; diff --git a/src/projects/projects.controller.ts b/src/projects/projects.controller.ts index fbfd4d1..1f1d106 100644 --- a/src/projects/projects.controller.ts +++ b/src/projects/projects.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, + Patch, Body, HttpCode, HttpStatus, @@ -24,6 +25,7 @@ import { } from '@nestjs/swagger'; import { GetProjectsQueryDto } from './dto/get-projects-query.dto'; import { CreateProjectDto } from './dto/create-project.dto'; +import { UpdateProjectStatusDto } from './dto/update-project-status.dto'; import { Public } from '../common/decorators/public.decorator'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { RolesGuard } from '../common/guards/roles.guard'; @@ -83,4 +85,79 @@ export class ProjectsController { const project = await this.projectsService.create(createProjectDto, userId); return project; } + + //_____________________ Endpoint to pause a project + @Patch(':id/pause') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.CREATOR, UserRole.ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Pause a project (CREATOR or ADMIN required)' }) + @ApiOkResponse({ description: 'Project paused successfully' }) + @ApiNotFoundResponse({ description: 'Project not found' }) + @ApiForbiddenResponse({ description: 'Only creator or admin can pause project' }) + async pauseProject( + @Param('id') id: string, + @Body() updateStatusDto: UpdateProjectStatusDto, + @Request() req, + ) { + const userId = req.user.sub; + const userRole = req.user.role; + const project = await this.projectsService.updateStatus( + id, + { status: 'paused' as any, reason: updateStatusDto.reason }, + userId, + userRole, + ); + return project; + } + + //_____________________ Endpoint to resume a project + @Patch(':id/resume') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.CREATOR, UserRole.ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Resume a project (CREATOR or ADMIN required)' }) + @ApiOkResponse({ description: 'Project resumed successfully' }) + @ApiNotFoundResponse({ description: 'Project not found' }) + @ApiForbiddenResponse({ description: 'Only creator or admin can resume project' }) + async resumeProject( + @Param('id') id: string, + @Body() updateStatusDto: UpdateProjectStatusDto, + @Request() req, + ) { + const userId = req.user.sub; + const userRole = req.user.role; + const project = await this.projectsService.updateStatus( + id, + { status: 'active' as any, reason: updateStatusDto.reason }, + userId, + userRole, + ); + return project; + } + + //_____________________ Endpoint to complete a project + @Post(':id/complete') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.CREATOR, UserRole.ADMIN) + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Complete a project (CREATOR or ADMIN required)' }) + @ApiOkResponse({ description: 'Project completed successfully' }) + @ApiNotFoundResponse({ description: 'Project not found' }) + @ApiForbiddenResponse({ description: 'Only creator or admin can complete project' }) + async completeProject( + @Param('id') id: string, + @Body() updateStatusDto: UpdateProjectStatusDto, + @Request() req, + ) { + const userId = req.user.sub; + const userRole = req.user.role; + const project = await this.projectsService.updateStatus( + id, + { status: 'completed' as any, reason: updateStatusDto.reason }, + userId, + userRole, + ); + return project; + } } diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index cb558ec..cca37c1 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Project } from './entities/project.entity'; import { Donation } from './entities/donation.entity'; +import { ProjectHistory } from './entities/project-history.entity'; import { ProjectsController } from './projects.controller'; import { ProjectsService } from './providers/projects.service'; @Module({ - imports: [TypeOrmModule.forFeature([Project, Donation])], + imports: [TypeOrmModule.forFeature([Project, Donation, ProjectHistory])], controllers: [ProjectsController], providers: [ProjectsService], exports: [ProjectsService], diff --git a/src/projects/providers/projects.service.ts b/src/projects/providers/projects.service.ts index e7c82ef..d1f0135 100644 --- a/src/projects/providers/projects.service.ts +++ b/src/projects/providers/projects.service.ts @@ -2,15 +2,18 @@ import { Injectable, NotFoundException, BadRequestException, + ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, SelectQueryBuilder } from 'typeorm'; 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 { CreateProjectDto } from '../dto/create-project.dto'; import { GetProjectsQueryDto } from '../dto/get-projects-query.dto'; +import { UpdateProjectStatusDto } from '../dto/update-project-status.dto'; @Injectable() export class ProjectsService { @@ -19,6 +22,8 @@ export class ProjectsService { private readonly projectRepository: Repository, @InjectRepository(Donation) private readonly donationRepository: Repository, + @InjectRepository(ProjectHistory) + private readonly projectHistoryRepository: Repository, ) {} // create a new project @@ -221,4 +226,103 @@ export class ProjectsService { })), }; } + + // validate status transitions + private validateStatusTransition( + currentStatus: ProjectStatus, + newStatus: ProjectStatus, + ): boolean { + const validTransitions: Record = { + [ProjectStatus.DRAFT]: [ProjectStatus.PENDING], + [ProjectStatus.PENDING]: [ProjectStatus.APPROVED, ProjectStatus.REJECTED], + [ProjectStatus.APPROVED]: [ProjectStatus.ACTIVE, ProjectStatus.REJECTED], + [ProjectStatus.ACTIVE]: [ProjectStatus.PAUSED, ProjectStatus.COMPLETED], + [ProjectStatus.PAUSED]: [ProjectStatus.ACTIVE, ProjectStatus.COMPLETED], + [ProjectStatus.COMPLETED]: [], // Completed projects cannot change status + [ProjectStatus.REJECTED]: [], // Rejected projects cannot change status + }; + + return validTransitions[currentStatus]?.includes(newStatus) || false; + } + + // update project status + public async updateStatus( + id: string, + updateStatusDto: UpdateProjectStatusDto, + userId: string, + userRole: string, + ): Promise { + const project = await this.projectRepository.findOne({ + where: { id }, + relations: ['creator'], + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + // Check if user is creator or admin + const isCreator = project.creatorId === userId; + const isAdmin = userRole === 'admin'; + + if (!isCreator && !isAdmin) { + throw new ForbiddenException('Only creator or admin can change project status'); + } + + const { status: newStatus, reason } = updateStatusDto; + + // Validate status transition + if (!this.validateStatusTransition(project.status, newStatus)) { + throw new BadRequestException( + `Invalid status transition from ${project.status} to ${newStatus}`, + ); + } + + const previousStatus = project.status; + + // Update project status + project.status = newStatus; + await this.projectRepository.save(project); + + // Record status change in history + await this.projectHistoryRepository.save({ + previousStatus, + newStatus, + reason: reason || null, + projectId: id, + changedBy: userId, + }); + + return project; + } + + // auto-complete projects past deadline + public async autocompleteExpiredProjects(): Promise { + const expiredProjects = await this.projectRepository + .createQueryBuilder('project') + .where('project.deadline < :now', { now: new Date() }) + .andWhere('project.status IN (:...statuses)', { + statuses: [ProjectStatus.ACTIVE, ProjectStatus.PAUSED], + }) + .getMany(); + + for (const project of expiredProjects) { + project.status = ProjectStatus.COMPLETED; + await this.projectRepository.save(project); + + // Record auto-completion in history + await this.projectHistoryRepository.save({ + previousStatus: project.status, + newStatus: ProjectStatus.COMPLETED, + reason: 'Auto-completed due to deadline', + projectId: project.id, + changedBy: 'system', + }); + } + } + + // check if project accepts donations + public canAcceptDonations(project: Project): boolean { + return project.status === ProjectStatus.ACTIVE; + } } diff --git a/test/projects/projects.service.spec.ts b/test/projects/projects.service.spec.ts index 9e52606..4aef0a8 100644 --- a/test/projects/projects.service.spec.ts +++ b/test/projects/projects.service.spec.ts @@ -6,6 +6,7 @@ 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 { Project } from 'src/projects/entities/project.entity'; +import { ProjectHistory } from 'src/projects/entities/project-history.entity'; import { ProjectsService } from 'src/projects/providers/projects.service'; import { Repository } from 'typeorm'; @@ -36,6 +37,10 @@ describe('ProjectsService', () => { createQueryBuilder: jest.fn(), }; + const mockProjectHistoryRepository = { + save: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -48,6 +53,10 @@ describe('ProjectsService', () => { provide: getRepositoryToken(Donation), useValue: mockDonationRepository, }, + { + provide: getRepositoryToken(ProjectHistory), + useValue: mockProjectHistoryRepository, + }, ], }).compile();