├── .gitignore ├── backend ├── lombok.config ├── src │ ├── test │ │ ├── resources │ │ │ └── application.properties │ │ └── java │ │ │ └── de │ │ │ └── neuefische │ │ │ └── capstone │ │ │ └── backend │ │ │ ├── imageupload │ │ │ ├── CloudinaryServiceTest.java │ │ │ ├── ImageUploadServiceTest.java │ │ │ └── ImageUploadIntegrationTest.java │ │ │ ├── security │ │ │ ├── MongoUserDetailsServiceTest.java │ │ │ ├── MongoUserServiceTest.java │ │ │ └── MongoUserIntegrationTest.java │ │ │ ├── ProjectCalculationsTest.java │ │ │ └── ProjectServiceTest.java │ └── main │ │ ├── resources │ │ └── application.properties │ │ └── java │ │ └── de │ │ └── neuefische │ │ └── capstone │ │ └── backend │ │ ├── models │ │ ├── Category.java │ │ ├── ImageCreation.java │ │ ├── Demand.java │ │ ├── ParticipationCreation.java │ │ ├── DonationCreation.java │ │ ├── Participation.java │ │ ├── Image.java │ │ ├── Donation.java │ │ ├── ProjectCreation.java │ │ ├── ProjectNoId.java │ │ └── Project.java │ │ ├── services │ │ └── IdService.java │ │ ├── ProjectRepo.java │ │ ├── imageupload │ │ ├── ImageUploadRepo.java │ │ ├── BeanConfigCloudinary.java │ │ ├── ImageUploadController.java │ │ ├── CloudinaryService.java │ │ └── ImageUploadService.java │ │ ├── BackendApplication.java │ │ ├── security │ │ ├── MongoUserRepository.java │ │ ├── MongoUserWithoutPassword.java │ │ ├── MongoUserCreation.java │ │ ├── MongoUser.java │ │ ├── MongoUserDetailsService.java │ │ ├── MongoUserController.java │ │ ├── SecurityConfig.java │ │ └── MongoUserService.java │ │ ├── ProjectController.java │ │ ├── ProjectCalculations.java │ │ └── ProjectService.java ├── .mvn │ └── wrapper │ │ ├── maven-wrapper.jar │ │ └── maven-wrapper.properties ├── .gitignore ├── pom.xml ├── mvnw.cmd └── mvnw ├── frontend ├── src │ ├── vite-env.d.ts │ ├── pages │ │ ├── Home.tsx │ │ ├── Gallery.tsx │ │ ├── ShowProject.tsx │ │ ├── FilteredGallery.tsx │ │ ├── LoginPage.tsx │ │ ├── UserProfile.tsx │ │ ├── RegisterPage.tsx │ │ ├── SearchGallary.tsx │ │ ├── AddDonationOrParticipation.tsx │ │ └── AddEditProject.tsx │ ├── main.tsx │ ├── components │ │ ├── ProtectedRoutes.tsx │ │ ├── LogoutButton.tsx │ │ ├── HomeButton.tsx │ │ ├── SearchButton.tsx │ │ ├── AddButton.tsx │ │ ├── DonationButton.tsx │ │ ├── ParticipationButton.tsx │ │ ├── DeleteButton.tsx │ │ ├── ProgressBarGalleryView.tsx │ │ ├── UserButton.tsx │ │ ├── ProgressBar.tsx │ │ ├── EditButton.tsx │ │ ├── FilterButton.tsx │ │ ├── TopDonators.tsx │ │ ├── NavigationBar.tsx │ │ ├── FilterUserData.tsx │ │ └── ProjectCard.tsx │ ├── models │ │ └── models.tsx │ ├── GlobalStyles.tsx │ ├── App.tsx │ └── hooks │ │ └── useFetch.tsx ├── tsconfig.node.json ├── .gitignore ├── vite.config.ts ├── index.html ├── .eslintrc.cjs ├── tsconfig.json ├── package.json └── public │ ├── vite.svg │ └── favicon.svg ├── Dockerfile ├── sonar-project.properties ├── .github ├── workflows │ ├── sonar-frontend.yml │ ├── maven.yml │ ├── show-logs.yml │ ├── sonar-backend.yml │ └── deploy.yml └── ISSUE_TEMPLATE │ └── user-story-template.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | *.iml 3 | -------------------------------------------------------------------------------- /backend/lombok.config: -------------------------------------------------------------------------------- 1 | lombok.addLombokGeneratedAnnotation = true -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /backend/src/test/resources/application.properties: -------------------------------------------------------------------------------- 1 | de.flapdoodle.mongodb.embedded.version=6.0.1 2 | -------------------------------------------------------------------------------- /backend/.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ben-21/capstone-neuefische/HEAD/backend/.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /backend/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.data.mongodb.uri=${MONGO_DB_URI} 2 | server.error.include-message=always 3 | server.error.include-binding-errors=always 4 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/models/Category.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.models; 2 | 3 | public enum Category { 4 | DONATION, 5 | PARTICIPATION 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/models/ImageCreation.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.models; 2 | 3 | public record ImageCreation( 4 | String name 5 | ) { 6 | } 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:20 2 | 3 | ENV ENVIRONMENT=prod 4 | 5 | LABEL maintainer="bs" 6 | 7 | EXPOSE 8080 8 | 9 | ADD backend/target/capstone.jar app.jar 10 | 11 | CMD [ "sh", "-c", "java -jar /app.jar" ] -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/models/Demand.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.models; 2 | 3 | public enum Demand { 4 | MONEYDONATION, 5 | DONATIONINKIND, 6 | FOODDONATION, 7 | DRUGDONATION, 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/models/ParticipationCreation.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.models; 2 | 3 | public record ParticipationCreation( 4 | String projectId, 5 | String projectName 6 | ) { 7 | } 8 | -------------------------------------------------------------------------------- /backend/.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.3/apache-maven-3.9.3-bin.zip 2 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar 3 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/models/DonationCreation.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.models; 2 | 3 | import java.math.BigDecimal; 4 | 5 | public record DonationCreation( 6 | String projectId, 7 | String projectName, 8 | BigDecimal amount 9 | ) { 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/models/Participation.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.models; 2 | 3 | public record Participation( 4 | String id, 5 | String projectId, 6 | String projectName, 7 | String participationName, 8 | String userId 9 | ) { 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/models/Image.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.models; 2 | 3 | import org.springframework.data.mongodb.core.mapping.Document; 4 | 5 | @Document("images") 6 | public record Image( 7 | String id, 8 | String name, 9 | String url 10 | ) { 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import Gallery from "./Gallery.tsx"; 2 | import {StyledBody, StyledMain} from "../GlobalStyles.tsx"; 3 | 4 | export default function Home() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/services/IdService.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.services; 2 | 3 | 4 | import org.springframework.stereotype.Component; 5 | 6 | @Component 7 | public class IdService { 8 | public String createRandomId() { 9 | return java.util.UUID.randomUUID().toString(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/models/Donation.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.models; 2 | 3 | import java.math.BigDecimal; 4 | 5 | public record Donation( 6 | String id, 7 | String projectId, 8 | String projectName, 9 | String donorName, 10 | BigDecimal amount, 11 | String userId 12 | ) { 13 | } 14 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | host: 'localhost', 9 | port: 3000, 10 | proxy: { 11 | '/api/': { 12 | target: 'http://localhost:8080', 13 | } 14 | } 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/ProjectRepo.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend; 2 | 3 | import de.neuefische.capstone.backend.models.Project; 4 | import org.springframework.data.mongodb.repository.MongoRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | @Repository 8 | public interface ProjectRepo extends MongoRepository { 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import {BrowserRouter} from "react-router-dom"; 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | 10 | 11 | , 12 | ) 13 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/models/ProjectCreation.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.models; 2 | 3 | import java.util.List; 4 | 5 | public record ProjectCreation( 6 | String name, 7 | String description, 8 | Category category, 9 | List demands, 10 | String location, 11 | int goal, 12 | Image image 13 | ) { 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/components/ProtectedRoutes.tsx: -------------------------------------------------------------------------------- 1 | import {Outlet, Navigate} from "react-router-dom"; 2 | 3 | type Props = { 4 | user?: string 5 | } 6 | 7 | export default function ProtectedRoutes({user}: Props) { 8 | 9 | if (user === undefined) return "loading ..."; 10 | 11 | const isLoggedIn = user !== "anonymousUser"; 12 | 13 | return <>{isLoggedIn ? : }; 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/imageupload/ImageUploadRepo.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.imageupload; 2 | 3 | import de.neuefische.capstone.backend.models.Image; 4 | import org.springframework.data.mongodb.repository.MongoRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | @Repository 8 | public interface ImageUploadRepo extends MongoRepository { 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/BackendApplication.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class BackendApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(BackendApplication.class, args); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Be Human - A Humanitarian Project Platform 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/security/MongoUserRepository.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.security; 2 | 3 | import org.springframework.data.mongodb.repository.MongoRepository; 4 | import org.springframework.stereotype.Repository; 5 | 6 | import java.util.Optional; 7 | @Repository 8 | public interface MongoUserRepository extends MongoRepository { 9 | Optional findByUsername(String username); 10 | } 11 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | plugins: ['react-refresh'], 11 | rules: { 12 | 'react-refresh/only-export-components': 'warn', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/security/MongoUserWithoutPassword.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.security; 2 | 3 | import de.neuefische.capstone.backend.models.Donation; 4 | import de.neuefische.capstone.backend.models.Participation; 5 | 6 | import java.util.List; 7 | 8 | public record MongoUserWithoutPassword( 9 | String id, 10 | String username, 11 | List donations, 12 | List participations 13 | ) { 14 | } 15 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=ben-21_capstone-neuefische-frontend 2 | sonar.organization=ben-21 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=ben-21_capstone-neuefische-frontend 6 | #sonar.projectVersion=1.0 7 | 8 | 9 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 10 | sonar.sources=./frontend/src 11 | 12 | # Encoding of the source code. Default is default system encoding 13 | #sonar.sourceEncoding=UTF-8 -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/models/ProjectNoId.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.models; 2 | 3 | import java.util.List; 4 | 5 | public record ProjectNoId( 6 | String name, 7 | String description, 8 | Category category, 9 | List demands, 10 | int progress, 11 | int goal, 12 | String location, 13 | List donations, 14 | List participations, 15 | String userId, 16 | Image image 17 | ) { 18 | } 19 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | -------------------------------------------------------------------------------- /frontend/src/pages/Gallery.tsx: -------------------------------------------------------------------------------- 1 | import ProjectCard from "../components/ProjectCard.tsx"; 2 | import {useFetch} from "../hooks/useFetch.tsx"; 3 | import {StyledGallery} from "../GlobalStyles.tsx"; 4 | 5 | export default function Gallery() { 6 | 7 | const projects = useFetch((state) => state.projects); 8 | 9 | return ( 10 | <> 11 | 12 | {projects.map((project) => ( 13 | 14 | ))} 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/security/MongoUserCreation.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.security; 2 | import jakarta.validation.constraints.NotEmpty; 3 | import jakarta.validation.constraints.Pattern; 4 | import jakarta.validation.constraints.Size; 5 | 6 | public record MongoUserCreation( 7 | @NotEmpty 8 | @Size(min=4, max=30, message = "Username must be between 4 and 30 characters") 9 | String username, 10 | @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[0-9]).{6,20}$", message = "Invalid password") 11 | String password 12 | ) { 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/security/MongoUser.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.security; 2 | 3 | import de.neuefische.capstone.backend.models.Donation; 4 | import de.neuefische.capstone.backend.models.Participation; 5 | import org.springframework.data.annotation.Id; 6 | import org.springframework.data.mongodb.core.mapping.Document; 7 | 8 | import java.util.List; 9 | 10 | @Document("users") 11 | public record MongoUser( 12 | @Id 13 | String id, 14 | String username, 15 | String password, 16 | List donations, 17 | List participations 18 | ) { 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/sonar-frontend.yml: -------------------------------------------------------------------------------- 1 | name: SonarCloud-Frontend 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | jobs: 9 | sonarcloud: 10 | name: SonarCloud 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 16 | - name: SonarCloud Scan 17 | uses: SonarSource/sonarcloud-github-action@master 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 20 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/models/Project.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.models; 2 | 3 | import org.springframework.data.annotation.Id; 4 | import org.springframework.data.mongodb.core.mapping.Document; 5 | 6 | import java.util.List; 7 | 8 | @Document("projects") 9 | public record Project( 10 | @Id 11 | String id, 12 | String name, 13 | String description, 14 | Category category, 15 | List demands, 16 | int progress, 17 | int goal, 18 | String location, 19 | List donations, 20 | List participations, 21 | String userId, 22 | Image image 23 | ) { 24 | } 25 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/LogoutButton.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import {useNavigate} from "react-router-dom"; 3 | import LoginIcon from "@mui/icons-material/Login"; 4 | import {useFetch} from "../hooks/useFetch.tsx"; 5 | import {StyledButton} from "../GlobalStyles.tsx"; 6 | 7 | export default function LogoutButton() { 8 | 9 | const navigate = useNavigate(); 10 | const me = useFetch((state) => state.me); 11 | 12 | function handleLogout() { 13 | axios.post("/api/users/logout") 14 | .catch(console.error) 15 | .then(() => me()) 16 | .then(() => navigate("/")); 17 | } 18 | 19 | return ( 20 | }>LOGOUT 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/user-story-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: User Story Template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | --- 11 | name: User story template 12 | about: This template provides a basic structure for user story issues. 13 | 14 | --- 15 | 16 | # User story 17 | As a ..., I want to ..., so I can ... 18 | 19 | *Ideally, this is in the issue title, but if not, you can put it here. If so, delete this section.* 20 | 21 | # Acceptance criteria 22 | 23 | - [ ] This is something that can be verified to show that this user story is satisfied. 24 | 25 | # Tasks 26 | ## Frontend 27 | - [ ] 28 | ## Backend 29 | - [ ] 30 | 31 | # Sprint Ready Checklist 32 | - [ ] Acceptance criteria defined 33 | - [ ] Team understands acceptance criteria 34 | - [ ] Team has defined solution / steps to satisfy acceptance criteria 35 | - [ ] Acceptance criteria is verifiable / testable 36 | -------------------------------------------------------------------------------- /frontend/src/components/HomeButton.tsx: -------------------------------------------------------------------------------- 1 | import {useNavigate} from "react-router-dom"; 2 | import HomeIcon from '@mui/icons-material/Home'; 3 | import {IconButton} from "@mui/material"; 4 | import styled from "@emotion/styled"; 5 | 6 | export default function HomeButton() { 7 | 8 | const navigate = useNavigate(); 9 | 10 | function handleClick() { 11 | navigate("/") 12 | window.scrollTo(0, 0); 13 | } 14 | 15 | return ( 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | const StyledIconButton = styled(IconButton)` 23 | width: 46px; 24 | height: 46px; 25 | 26 | svg { 27 | font-size: 32px; 28 | } 29 | 30 | border-radius: 4px; 31 | border: none; 32 | background-color: var(--colorBlack); 33 | margin: 0; 34 | padding: 0; 35 | display: grid; 36 | place-items: center; 37 | cursor: pointer; 38 | `; 39 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Java CI with Maven 10 | 11 | on: 12 | push 13 | 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up JDK 20 23 | uses: actions/setup-java@v3 24 | with: 25 | java-version: '20' 26 | distribution: 'temurin' 27 | cache: maven 28 | - name: Build with Maven 29 | run: mvn -B package --file backend/pom.xml 30 | 31 | 32 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/imageupload/BeanConfigCloudinary.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.imageupload; 2 | 3 | import com.cloudinary.Cloudinary; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | import java.util.HashMap; 8 | import java.util.Map; 9 | 10 | @Configuration 11 | public class BeanConfigCloudinary { 12 | private static final String CLOUD_NAME = "dfzzbhu3x"; 13 | private static final String API_KEY = "399958539413299"; 14 | private static final String API_SECRET = System.getenv("CLOUDINARY_SECRET"); 15 | @Bean 16 | public Cloudinary createCloudinary() { 17 | Map config = new HashMap<>(); 18 | config.put("cloud_name",CLOUD_NAME); 19 | config.put("api_key",API_KEY); 20 | config.put("api_secret",API_SECRET); 21 | return new Cloudinary(config); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/show-logs.yml: -------------------------------------------------------------------------------- 1 | name: "Get Logs" 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | get-logs: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Get logs from docker 11 | uses: appleboy/ssh-action@master 12 | with: 13 | host: capstone-project.de 14 | #Set App Name (replace "example" with "alpha"-"tango") 15 | username: cgn-java-23-2-benjamin 16 | password: ${{ secrets.SSH_PASSWORD }} 17 | #Set App Name (replace "example" with "alpha"-"tango") 18 | script: | 19 | sudo docker logs cgn-java-23-2-benjamin 20 | - name: Check the deployed service URL 21 | uses: jtalk/url-health-check-action@v3 22 | with: 23 | #Set App Name (replace "example" with "alpha"-"tango") 24 | url: http://cgn-java-23-2-benjamin.capstone-project.de 25 | max-attempts: 3 26 | retry-delay: 5s 27 | retry-all: true -------------------------------------------------------------------------------- /frontend/src/components/SearchButton.tsx: -------------------------------------------------------------------------------- 1 | import {useNavigate} from "react-router-dom"; 2 | import styled from "@emotion/styled"; 3 | import SearchIcon from '@mui/icons-material/Search'; 4 | import {IconButton} from "@mui/material"; 5 | 6 | export default function SearchButton() { 7 | const navigate = useNavigate(); 8 | 9 | function handleClick() { 10 | navigate(`/search`) 11 | window.scrollTo(0, 0) 12 | } 13 | 14 | return ( 15 | 16 | 17 | 18 | ) 19 | } 20 | 21 | const StyledIconButton = styled(IconButton)` 22 | width: 46px; 23 | height: 46px; 24 | 25 | svg { 26 | font-size: 32px; 27 | } 28 | 29 | border-radius: 4px; 30 | border: none; 31 | background-color: var(--colorBlack); 32 | margin: 0; 33 | padding: 0; 34 | display: grid; 35 | place-items: center; 36 | cursor: pointer; 37 | `; 38 | -------------------------------------------------------------------------------- /frontend/src/components/AddButton.tsx: -------------------------------------------------------------------------------- 1 | import {useNavigate} from "react-router-dom"; 2 | import AddCircleIcon from '@mui/icons-material/AddCircle'; 3 | import {IconButton} from "@mui/material"; 4 | import styled from "@emotion/styled"; 5 | 6 | export default function AddButton() { 7 | 8 | const navigate = useNavigate(); 9 | 10 | function handleClick() { 11 | navigate("/add") 12 | window.scrollTo(0, 0); 13 | } 14 | 15 | return ( 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | const StyledIconButton = styled(IconButton)` 23 | width: 46px; 24 | height: 46px; 25 | 26 | svg { 27 | font-size: 32px; 28 | } 29 | 30 | border-radius: 4px; 31 | border: none; 32 | background-color: var(--colorBlack); 33 | margin: 0; 34 | padding: 0; 35 | display: grid; 36 | place-items: center; 37 | cursor: pointer; 38 | `; 39 | -------------------------------------------------------------------------------- /frontend/src/components/DonationButton.tsx: -------------------------------------------------------------------------------- 1 | import {useNavigate} from "react-router-dom"; 2 | import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; 3 | import {IconButton} from "@mui/material"; 4 | import styled from "@emotion/styled"; 5 | 6 | 7 | type Props = { 8 | projectId: string; 9 | } 10 | export default function DonationButton(props: Props) { 11 | 12 | const navigate = useNavigate(); 13 | 14 | function handleClick() { 15 | navigate(`/donate/${props.projectId}`) 16 | } 17 | 18 | return ( 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | const StyledIconButton = styled(IconButton)` 26 | width: 46px; 27 | height: 46px; 28 | 29 | svg { 30 | font-size: 32px; 31 | } 32 | 33 | border-radius: 4px; 34 | border: none; 35 | background-color: var(--colorBlack); 36 | margin: 0; 37 | padding: 0; 38 | display: grid; 39 | place-items: center; 40 | cursor: pointer; 41 | `; 42 | -------------------------------------------------------------------------------- /frontend/src/components/ParticipationButton.tsx: -------------------------------------------------------------------------------- 1 | import {useNavigate} from "react-router-dom"; 2 | import VolunteerActivismIcon from '@mui/icons-material/VolunteerActivism'; 3 | import {IconButton} from "@mui/material"; 4 | import styled from "@emotion/styled"; 5 | 6 | type Props = { 7 | projectId: string; 8 | } 9 | 10 | export default function ParticipationButton(props: Props) { 11 | 12 | const navigate = useNavigate(); 13 | 14 | function handleClick() { 15 | navigate(`/participate/${props.projectId}`) 16 | } 17 | 18 | return ( 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | const StyledIconButton = styled(IconButton)` 26 | width: 46px; 27 | height: 46px; 28 | 29 | svg { 30 | font-size: 32px; 31 | } 32 | 33 | border-radius: 4px; 34 | border: none; 35 | background-color: var(--colorBlack); 36 | margin: 0; 37 | padding: 0; 38 | display: grid; 39 | place-items: center; 40 | cursor: pointer; 41 | `; 42 | -------------------------------------------------------------------------------- /frontend/src/components/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import {useNavigate} from "react-router-dom"; 2 | import DeleteIcon from '@mui/icons-material/Delete'; 3 | import {IconButton} from "@mui/material"; 4 | import styled from "@emotion/styled"; 5 | import {useFetch} from "../hooks/useFetch.tsx"; 6 | 7 | 8 | type Props = { 9 | projectId: string; 10 | } 11 | export default function DeleteButton(props: Props) { 12 | 13 | const navigate = useNavigate(); 14 | const deleteProject = useFetch(state => state.deleteProject); 15 | 16 | function handleClick() { 17 | deleteProject(props.projectId); 18 | navigate("/") 19 | } 20 | 21 | return ( 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | const StyledIconButton = styled(IconButton)` 29 | width: 46px; 30 | height: 46px; 31 | 32 | svg { 33 | font-size: 32px; 34 | } 35 | 36 | border-radius: 4px; 37 | border: none; 38 | background-color: var(--colorBlack); 39 | margin: 0; 40 | padding: 0; 41 | display: grid; 42 | place-items: center; 43 | cursor: pointer; 44 | `; 45 | -------------------------------------------------------------------------------- /frontend/src/components/ProgressBarGalleryView.tsx: -------------------------------------------------------------------------------- 1 | import LinearProgress, {LinearProgressProps} from '@mui/material/LinearProgress'; 2 | import Box from '@mui/material/Box'; 3 | import {Project} from "../models/models.tsx"; 4 | import styled from "@emotion/styled"; 5 | 6 | type Props = { 7 | project: Project; 8 | } 9 | 10 | export default function ProgressBarGalleryView(props: Props) { 11 | function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | return ( 22 | 23 | {props.project.progress > 0 && 24 | } 26 | 27 | ); 28 | } 29 | 30 | const StyledBox = styled.div` 31 | margin: 10px 0 0 0; 32 | padding-left: 0; 33 | width: 100%; 34 | `; 35 | -------------------------------------------------------------------------------- /frontend/src/pages/ShowProject.tsx: -------------------------------------------------------------------------------- 1 | import ProjectCard from "../components/ProjectCard.tsx"; 2 | import {useFetch} from "../hooks/useFetch.tsx"; 3 | import {useParams} from "react-router-dom"; 4 | import {useEffect, useState} from "react"; 5 | import {Project} from "../models/models.tsx"; 6 | import {StyledBody} from "../GlobalStyles.tsx"; 7 | 8 | export default function ShowProject() { 9 | 10 | const {id} = useParams(); 11 | const getProjectById = useFetch(state => state.getProjectById); 12 | const [project, setProject] = useState(undefined); 13 | 14 | useEffect(() => { 15 | if (!id) { 16 | throw new Error("Id is undefined") 17 | } 18 | getProjectById(id) 19 | .then((project) => { 20 | setProject(project); 21 | }) 22 | .catch(error => { 23 | console.error(error); 24 | }); 25 | }, [id, getProjectById]); 26 | 27 | if (!project) { 28 | return

Loading...

29 | } 30 | 31 | return ( 32 | 33 | 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/imageupload/ImageUploadController.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.imageupload; 2 | 3 | import de.neuefische.capstone.backend.models.Image; 4 | import de.neuefische.capstone.backend.models.ImageCreation; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.http.HttpStatus; 7 | import org.springframework.web.bind.annotation.*; 8 | import org.springframework.web.multipart.MultipartFile; 9 | 10 | import java.io.IOException; 11 | import java.util.List; 12 | 13 | @RestController 14 | @RequiredArgsConstructor 15 | @RequestMapping("/api/upload") 16 | public class ImageUploadController { 17 | private final ImageUploadService imageUploadService; 18 | 19 | @GetMapping 20 | public List getAllImages() { 21 | return imageUploadService.getAllImages(); 22 | } 23 | 24 | @PostMapping 25 | @ResponseStatus(HttpStatus.CREATED) 26 | public Image addImageProfile(@RequestPart("data") ImageCreation imageProfileWithoutId, @RequestPart(name="file",required = false) MultipartFile image) throws IOException { 27 | return imageUploadService.addImage(imageProfileWithoutId, image); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/imageupload/CloudinaryService.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.imageupload; 2 | 3 | import com.cloudinary.Cloudinary; 4 | import com.cloudinary.utils.ObjectUtils; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Service; 7 | import org.springframework.web.multipart.MultipartFile; 8 | 9 | import java.io.File; 10 | import java.io.IOException; 11 | import java.util.Collections; 12 | import java.util.Map; 13 | 14 | @Service 15 | @RequiredArgsConstructor 16 | public class CloudinaryService { 17 | private final Cloudinary cloudinary; 18 | private static final String TEMP_DIRECTORY = System.getProperty("java.io.tmpdir"); 19 | 20 | public String uploadImage(MultipartFile image) throws IOException { 21 | File fileToUpload = File.createTempFile("image", null, new File(TEMP_DIRECTORY)); 22 | image.transferTo(fileToUpload); 23 | 24 | Map response = cloudinary.uploader().upload(fileToUpload, Collections.emptyMap()); 25 | 26 | String httpUrl = response.get("url").toString(); 27 | String httpsUrl = httpUrl.replace("http://", "https://"); 28 | 29 | return httpsUrl; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "^11.11.1", 14 | "@emotion/styled": "^11.11.0", 15 | "@mui/icons-material": "^5.14.1", 16 | "@mui/material": "^5.14.2", 17 | "axios": "^1.4.0", 18 | "mui-file-input": "^3.0.0", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-router-dom": "^6.14.2", 22 | "react-toastify": "^9.1.3", 23 | "styled-components": "^6.0.5", 24 | "zustand": "^4.3.9" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "^18.0.37", 28 | "@types/react-dom": "^18.0.11", 29 | "@typescript-eslint/eslint-plugin": "^5.59.0", 30 | "@typescript-eslint/parser": "^5.59.0", 31 | "@vitejs/plugin-react": "^4.0.0", 32 | "eslint": "^8.38.0", 33 | "eslint-plugin-react-hooks": "^4.6.0", 34 | "eslint-plugin-react-refresh": "^0.3.4", 35 | "typescript": "^5.0.2", 36 | "vite": "^4.3.9" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/security/MongoUserDetailsService.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.security; 2 | 3 | import org.springframework.security.core.userdetails.User; 4 | import org.springframework.security.core.userdetails.UserDetails; 5 | import org.springframework.security.core.userdetails.UserDetailsService; 6 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 7 | import org.springframework.stereotype.Service; 8 | 9 | import java.util.Collections; 10 | 11 | @Service 12 | public class MongoUserDetailsService implements UserDetailsService { 13 | 14 | private final MongoUserRepository mongoUserRepository; 15 | 16 | public MongoUserDetailsService(MongoUserRepository mongoUserRepository) { 17 | this.mongoUserRepository = mongoUserRepository; 18 | } 19 | 20 | @Override 21 | public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { 22 | MongoUser mongoUser = mongoUserRepository.findByUsername(username) 23 | .orElseThrow(() -> new UsernameNotFoundException("Username " + username + " not found")); 24 | 25 | return new User(mongoUser.username(), mongoUser.password(), Collections.emptyList()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/test/java/de/neuefische/capstone/backend/imageupload/CloudinaryServiceTest.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.imageupload; 2 | 3 | import com.cloudinary.Cloudinary; 4 | import com.cloudinary.Uploader; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.mock.web.MockMultipartFile; 7 | 8 | import java.io.IOException; 9 | import java.util.Map; 10 | 11 | import static org.junit.jupiter.api.Assertions.assertEquals; 12 | import static org.mockito.Mockito.*; 13 | 14 | class CloudinaryServiceTest { 15 | Cloudinary cloudinary = mock(Cloudinary.class); 16 | Uploader uploader = mock(Uploader.class); 17 | CloudinaryService cloudinaryService = new CloudinaryService(cloudinary); 18 | 19 | @Test 20 | void uploadImage() throws IOException { 21 | //GIVEN 22 | MockMultipartFile mockMultipartFile = new MockMultipartFile("file", "test".getBytes()); 23 | 24 | when(cloudinary.uploader()).thenReturn(uploader); 25 | when(uploader.upload(any(), any())).thenReturn(Map.of("url", "test-url")); 26 | 27 | //WHEN 28 | String actual = cloudinaryService.uploadImage(mockMultipartFile); 29 | 30 | //THEN 31 | verify(uploader).upload(any(), any()); 32 | assertEquals("test-url", actual); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/imageupload/ImageUploadService.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.imageupload; 2 | 3 | import de.neuefische.capstone.backend.models.Image; 4 | import de.neuefische.capstone.backend.models.ImageCreation; 5 | import de.neuefische.capstone.backend.services.IdService; 6 | import lombok.RequiredArgsConstructor; 7 | import org.springframework.stereotype.Service; 8 | import org.springframework.web.multipart.MultipartFile; 9 | 10 | import java.io.IOException; 11 | import java.util.List; 12 | 13 | @Service 14 | @RequiredArgsConstructor 15 | public class ImageUploadService { 16 | 17 | private final ImageUploadRepo imageUploadRepo; 18 | private final IdService idService; 19 | private final CloudinaryService cloudinaryService; 20 | 21 | public List getAllImages() { 22 | return imageUploadRepo.findAll(); 23 | } 24 | 25 | public Image addImage(ImageCreation imageWithoutId, MultipartFile image) throws IOException { 26 | String id = idService.createRandomId(); 27 | String url = null; 28 | 29 | if (image!= null) { 30 | url = cloudinaryService.uploadImage(image); 31 | } 32 | Image imageToSave = new Image(id, imageWithoutId.name(),url); 33 | return imageUploadRepo.save(imageToSave); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/components/UserButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import {useNavigate} from "react-router-dom"; 3 | import {IconButton} from "@mui/material"; 4 | import AccountCircleIcon from '@mui/icons-material/AccountCircle'; 5 | import {useFetch} from "../hooks/useFetch.tsx"; 6 | import {useEffect} from "react"; 7 | 8 | export default function UserButton() { 9 | 10 | const navigate = useNavigate(); 11 | const me = useFetch(state => state.me); 12 | const userName = useFetch(state => state.userName); 13 | 14 | useEffect(() => { 15 | me(); 16 | }, [me, userName]); 17 | 18 | function handleClick() { 19 | if (userName === "anonymousUser") { 20 | navigate("/login") 21 | } else { 22 | navigate("/profile") 23 | } 24 | } 25 | 26 | return ( 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | const StyledIconButton = styled(IconButton)` 34 | width: 46px; 35 | height: 46px; 36 | 37 | svg { 38 | font-size: 32px; 39 | } 40 | 41 | border-radius: 4px; 42 | border: none; 43 | background-color: var(--colorBlack); 44 | margin: 0; 45 | padding: 0; 46 | display: grid; 47 | place-items: center; 48 | cursor: pointer; 49 | `; 50 | -------------------------------------------------------------------------------- /.github/workflows/sonar-backend.yml: -------------------------------------------------------------------------------- 1 | name: SonarCloud-Backend 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | jobs: 9 | build: 10 | name: Build and analyze 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 16 | - name: Set up JDK 20 17 | uses: actions/setup-java@v3 18 | with: 19 | java-version: 20 20 | distribution: 'zulu' # Alternative distribution options are available. 21 | - name: Cache SonarCloud packages 22 | uses: actions/cache@v3 23 | with: 24 | path: ~/.sonar/cache 25 | key: ${{ runner.os }}-sonar 26 | restore-keys: ${{ runner.os }}-sonar 27 | - name: Cache Maven packages 28 | uses: actions/cache@v3 29 | with: 30 | path: ~/.m2 31 | key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} 32 | restore-keys: ${{ runner.os }}-m2 33 | - name: Build and analyze 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 36 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 37 | run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=ben-21_capstone-neuefische-backend --file backend/pom.xml -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/pages/FilteredGallery.tsx: -------------------------------------------------------------------------------- 1 | import ProjectCard from "../components/ProjectCard.tsx"; 2 | import {useFetch} from "../hooks/useFetch.tsx"; 3 | import {Project} from "../models/models.tsx"; 4 | import {useLocation} from "react-router-dom"; 5 | import {useEffect, useState} from "react"; 6 | import {StyledBody, StyledGallery, StyledMain} from "../GlobalStyles.tsx"; 7 | 8 | export default function FilteredGallery() { 9 | 10 | const location = useLocation(); 11 | const searchParams = new URLSearchParams(location.search); 12 | const filter = searchParams.get("filter"); 13 | const projects = useFetch((state) => state.projects); 14 | const fetchProjects = useFetch((state) => state.fetchProjects); 15 | const [filteredProjects, setFilteredProjects] = useState([]); 16 | 17 | useEffect(() => { 18 | fetchProjects(); 19 | }, [fetchProjects]); 20 | 21 | useEffect(() => { 22 | if (filter === null || filter === "all") { 23 | setFilteredProjects(projects); 24 | } else { 25 | setFilteredProjects(projects.filter((project) => project.category === filter)); 26 | } 27 | }, [filter, projects]); 28 | 29 | return ( 30 | 31 | 32 | 33 | {filteredProjects.map((project) => ( 34 | 35 | ))} 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/security/MongoUserController.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.security; 2 | 3 | import jakarta.servlet.http.HttpSession; 4 | import jakarta.validation.Valid; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.security.core.context.SecurityContextHolder; 7 | import org.springframework.web.bind.annotation.*; 8 | 9 | @RestController 10 | @RequiredArgsConstructor 11 | @RequestMapping("/api/users") 12 | public class MongoUserController { 13 | private final MongoUserService mongoUserService; 14 | 15 | @GetMapping("/me") 16 | public String getUserInfo() { 17 | return SecurityContextHolder.getContext().getAuthentication().getName(); 18 | } 19 | 20 | @GetMapping("/me-object") 21 | public MongoUserWithoutPassword getUserObject() { 22 | String username = SecurityContextHolder.getContext().getAuthentication().getName(); 23 | 24 | return mongoUserService.findByUsername(username); 25 | } 26 | 27 | @PostMapping("/login") 28 | public String login() { 29 | return SecurityContextHolder.getContext().getAuthentication().getName(); 30 | } 31 | 32 | @PostMapping("/logout") 33 | public void logout(HttpSession httpSession) { 34 | httpSession.invalidate(); 35 | SecurityContextHolder.clearContext(); 36 | } 37 | 38 | @PostMapping("/register") 39 | public String register(@Valid @RequestBody MongoUserCreation mongoUserWithoutId) { 40 | mongoUserService.registerUser(mongoUserWithoutId); 41 | return "registered"; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/models/models.tsx: -------------------------------------------------------------------------------- 1 | 2 | export type Project = { 3 | id: string; 4 | name: string; 5 | description: string; 6 | category: "DONATION" | "PARTICIPATION"; 7 | demands: Demand[]; 8 | progress: number; 9 | goal: string; 10 | location: string; 11 | donations: Donation[]; 12 | participations: Participation[]; 13 | userId: string; 14 | image: Image; 15 | } 16 | 17 | export type ProjectCreation = { 18 | name: string; 19 | description: string; 20 | category: "DONATION" | "PARTICIPATION"; 21 | demands: Demand[]; 22 | location: string; 23 | goal: string; 24 | image: Image; 25 | } 26 | 27 | export type ProjectNoId = { 28 | name: string; 29 | description: string; 30 | category: "DONATION" | "PARTICIPATION"; 31 | demands: Demand[]; 32 | progress: number; 33 | goal: string; 34 | location: string; 35 | donations: Donation[]; 36 | participations: Participation[]; 37 | userId: string; 38 | image: Image; 39 | } 40 | 41 | export type User = { 42 | id: string; 43 | username: string; 44 | donations: Donation[]; 45 | participations: Participation[]; 46 | } 47 | 48 | export type Image = { 49 | id: string; 50 | name: string; 51 | url: string; 52 | } 53 | 54 | export type Demand = "MONEYDONATION" | "DONATIONINKIND" | "FOODDONATION" | "DRUGDONATION"; 55 | export type Donation = {id: string, projectId: string, projectName: string, donorName: string, amount: string, userId: string}; 56 | export type DonationCreation = {projectId: string, projectName: string, amount: string}; 57 | export type Participation = {id: string, projectId: string, projectName: string, participationName: string, userId: string}; 58 | export type ParticipationCreation = {projectId: string, projectName: string}; 59 | export type ImageCreation = {name: string}; 60 | -------------------------------------------------------------------------------- /backend/src/test/java/de/neuefische/capstone/backend/security/MongoUserDetailsServiceTest.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.security; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.security.core.userdetails.User; 5 | import org.springframework.security.core.userdetails.UserDetails; 6 | 7 | import java.util.ArrayList; 8 | import java.util.Collections; 9 | import java.util.Optional; 10 | 11 | import static org.junit.jupiter.api.Assertions.assertEquals; 12 | import static org.mockito.Mockito.*; 13 | 14 | 15 | class MongoUserDetailsServiceTest { 16 | 17 | private final MongoUserRepository mongoUserRepository = mock(MongoUserRepository.class); 18 | private final MongoUserDetailsService mongoUserDetailsService = new MongoUserDetailsService(mongoUserRepository); 19 | 20 | @Test 21 | void returnUser_WhenUsernameExists() { 22 | // Given 23 | MongoUser mongoUser = new MongoUser("1", "test", "test", new ArrayList<>(), new ArrayList<>()); 24 | when(mongoUserRepository.findByUsername("test")) 25 | .thenReturn(Optional.of(mongoUser)); 26 | 27 | // When 28 | UserDetails actual = mongoUserDetailsService.loadUserByUsername("test"); 29 | 30 | // Then 31 | UserDetails expected = new User("test", "test", Collections.emptyList()); 32 | assertEquals(expected, actual); 33 | } 34 | 35 | @Test 36 | void throwException_WhenUsernameDoesNotExist() { 37 | // Given 38 | when(mongoUserRepository.findByUsername("test")) 39 | .thenReturn(Optional.empty()); 40 | 41 | // When / Then 42 | try { 43 | mongoUserDetailsService.loadUserByUsername("test"); 44 | } catch (Exception e) { 45 | assertEquals("Username test not found", e.getMessage()); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/components/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import LinearProgress, {LinearProgressProps} from '@mui/material/LinearProgress'; 2 | import Box from '@mui/material/Box'; 3 | import {Project} from "../models/models.tsx"; 4 | import styled from "@emotion/styled"; 5 | import {useEffect, useState} from "react"; 6 | 7 | type Props = { 8 | project: Project; 9 | } 10 | 11 | export default function ProgressBar(props: Props) { 12 | function LinearProgressWithLabel(props: LinearProgressProps & { value: number }) { 13 | return ( 14 | <> 15 | 16 | 17 | 18 | 19 | 20 | 21 | Target achieved by {props.value}% 22 | 23 | 24 | ); 25 | } 26 | 27 | const [progress, setProgress] = useState(0); 28 | 29 | useEffect(() => { 30 | const timer = setInterval(() => { 31 | setProgress((prevProgress) => { 32 | if (prevProgress >= props.project.progress) { 33 | clearInterval(timer); 34 | return props.project.progress; 35 | } 36 | return prevProgress + 1; 37 | }); 38 | }, 100); 39 | 40 | return () => { 41 | clearInterval(timer); 42 | }; 43 | }, [props.project.progress]); 44 | 45 | return ( 46 | 47 | {props.project.progress > 0 && 48 | } 49 | 50 | ); 51 | } 52 | 53 | const StyledBox = styled.div` 54 | margin: 10px 10% 0 12%; 55 | `; 56 | 57 | const StyledPercentageDiv = styled.div` 58 | font-family: "Roboto", sans-serif; 59 | font-weight: 400; 60 | margin-top: 10px; 61 | `; 62 | -------------------------------------------------------------------------------- /frontend/src/pages/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import {ChangeEvent, FormEvent, useState} from "react"; 2 | import {useNavigate} from "react-router-dom"; 3 | import {useFetch} from "../hooks/useFetch.tsx"; 4 | import LoginIcon from '@mui/icons-material/Login'; 5 | import {StyledBody, StyledButton, StyledForm, StyledTextField} from "../GlobalStyles.tsx"; 6 | 7 | export default function LoginPage() { 8 | 9 | const [username, setUsername] = useState(""); 10 | const [password, setPassword] = useState(""); 11 | const navigate = useNavigate(); 12 | const login = useFetch((state) => state.login); 13 | 14 | function handleUsernameInput(event: ChangeEvent) { 15 | setUsername(event.currentTarget.value); 16 | } 17 | 18 | function handlePasswordInput(event: ChangeEvent) { 19 | setPassword(event.currentTarget.value); 20 | } 21 | 22 | function handleSubmit(event: FormEvent) { 23 | event.preventDefault(); 24 | login(username, password, navigate); 25 | } 26 | 27 | return ( 28 | 29 | 30 | 34 | 39 | }>LOGIN 41 | {/*OR*/} 42 | {/* navigate("/register")} variant="outlined"*/} 43 | {/* endIcon={}>REGISTER*/} 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/components/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import {useNavigate} from "react-router-dom"; 2 | import EditIcon from '@mui/icons-material/Edit'; 3 | import {IconButton} from "@mui/material"; 4 | import styled from "@emotion/styled"; 5 | import {useFetch} from "../hooks/useFetch.tsx"; 6 | import {useEffect, useState} from "react"; 7 | import {Project} from "../models/models.tsx"; 8 | 9 | 10 | type Props = { 11 | projectId: string; 12 | } 13 | 14 | export default function EditButton(props: Props) { 15 | 16 | const navigate = useNavigate(); 17 | const user = useFetch(state => state.user); 18 | const meObject = useFetch(state => state.meObject); 19 | const getProjectById = useFetch(state => state.getProjectById); 20 | const [project, setProject] = useState(undefined); 21 | const [checkUser, setCheckUser] = useState(false); 22 | 23 | useEffect(() => { 24 | meObject(); 25 | }, [meObject]); 26 | 27 | useEffect(() => { 28 | if (props.projectId) { 29 | getProjectById(props.projectId) 30 | .then((project) => { 31 | setProject(project); 32 | }) 33 | .catch(error => { 34 | console.error(error); 35 | }); 36 | } 37 | }, [props.projectId, getProjectById]); 38 | 39 | useEffect(() => { 40 | if (project?.userId === user?.id) { 41 | setCheckUser(true); 42 | } else { 43 | setCheckUser(false); 44 | } 45 | }, [project, user]); 46 | 47 | function handleClick() { 48 | navigate(`/edit/${props.projectId}`) 49 | } 50 | 51 | return ( 52 | <> 53 | {checkUser && 54 | 55 | 56 | 57 | } 58 | 59 | ) 60 | } 61 | 62 | const StyledIconButton = styled(IconButton)` 63 | width: 46px; 64 | height: 46px; 65 | 66 | svg { 67 | font-size: 32px; 68 | } 69 | 70 | border-radius: 4px; 71 | border: none; 72 | background-color: var(--colorBlack); 73 | margin: 0; 74 | padding: 0; 75 | display: grid; 76 | place-items: center; 77 | cursor: pointer; 78 | `; 79 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/ProjectController.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend; 2 | 3 | import de.neuefische.capstone.backend.models.*; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.http.ResponseEntity; 6 | import org.springframework.web.bind.annotation.*; 7 | 8 | import java.util.List; 9 | import java.util.NoSuchElementException; 10 | 11 | @RestController 12 | @RequiredArgsConstructor 13 | @RequestMapping("/api/projects") 14 | public class ProjectController { 15 | 16 | private final ProjectService projectService; 17 | 18 | @PostMapping 19 | public Project addProject(@RequestBody ProjectCreation projectCreation) { 20 | return projectService.addProject(projectCreation); 21 | } 22 | 23 | @GetMapping("{id}") 24 | public Project getProjectById(@PathVariable String id) { 25 | return projectService.getProjectById(id); 26 | } 27 | 28 | @GetMapping 29 | public List getAllProjects() { 30 | return projectService.getAllProjects(); 31 | } 32 | 33 | @PutMapping("{id}") 34 | public ResponseEntity updateProject(@PathVariable String id, @RequestBody ProjectNoId projectNoId) { 35 | try { 36 | Project updatedProject = projectService.updateProject(id, projectNoId); 37 | return ResponseEntity.ok(updatedProject); 38 | } catch (NoSuchElementException e) { 39 | return ResponseEntity.notFound().build(); 40 | } 41 | } 42 | 43 | @DeleteMapping("{id}") 44 | public ResponseEntity deleteProject(@PathVariable String id) { 45 | 46 | try { 47 | projectService.deleteProject(id); 48 | return ResponseEntity.ok("Project deleted successfully"); 49 | 50 | } catch (NoSuchElementException e) { 51 | return ResponseEntity.notFound().build(); 52 | } 53 | } 54 | 55 | @PostMapping("/donate/{id}") 56 | public Project addDonation(@PathVariable String id, @RequestBody DonationCreation donationCreation) { 57 | return projectService.addDonation(id, donationCreation); 58 | } 59 | 60 | @PostMapping("/participate/{id}") 61 | public Project addParticipation(@PathVariable String id, @RequestBody ParticipationCreation participationCreation) { 62 | return projectService.addParticipation(id, participationCreation); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/ProjectCalculations.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend; 2 | 3 | 4 | import de.neuefische.capstone.backend.models.Category; 5 | import de.neuefische.capstone.backend.models.Project; 6 | import org.springframework.stereotype.Component; 7 | 8 | import java.math.BigDecimal; 9 | import java.math.RoundingMode; 10 | 11 | @Component 12 | public class ProjectCalculations { 13 | 14 | public Project calculateProgressForDonations(Project project) { 15 | if (project.category() == Category.DONATION) { 16 | BigDecimal sumOfDonations = project.donations().stream().reduce(BigDecimal.ZERO, (sum, donation) -> sum.add(donation.amount()), BigDecimal::add); 17 | int newProgress = sumOfDonations.divide(BigDecimal.valueOf(project.goal()), 2, RoundingMode.DOWN).multiply(BigDecimal.valueOf(100)).intValue(); 18 | return new Project( 19 | project.id(), 20 | project.name(), 21 | project.description(), 22 | project.category(), 23 | project.demands(), 24 | newProgress, 25 | project.goal(), 26 | project.location(), 27 | project.donations(), 28 | project.participations(), 29 | project.userId(), 30 | project.image() 31 | ); 32 | } 33 | return project; 34 | } 35 | 36 | public Project calculateProgressForParticipations(Project project) { 37 | if (project.category() == Category.PARTICIPATION) { 38 | int newParticipations = project.participations().size(); 39 | int newProgress = (int) ((double) newParticipations / project.goal() * 100); 40 | 41 | return new Project( 42 | project.id(), 43 | project.name(), 44 | project.description(), 45 | project.category(), 46 | project.demands(), 47 | newProgress, 48 | project.goal(), 49 | project.location(), 50 | project.donations(), 51 | project.participations(), 52 | project.userId(), 53 | project.image() 54 | ); 55 | } 56 | return project; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /backend/src/test/java/de/neuefische/capstone/backend/imageupload/ImageUploadServiceTest.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.imageupload; 2 | 3 | import de.neuefische.capstone.backend.models.Image; 4 | import de.neuefische.capstone.backend.models.ImageCreation; 5 | import de.neuefische.capstone.backend.services.IdService; 6 | import org.junit.jupiter.api.Test; 7 | import org.springframework.mock.web.MockMultipartFile; 8 | 9 | import java.io.IOException; 10 | import java.util.Collections; 11 | import java.util.List; 12 | 13 | 14 | 15 | import static org.assertj.core.api.Assertions.assertThat; 16 | import static org.junit.jupiter.api.Assertions.assertEquals; 17 | import static org.mockito.Mockito.*; 18 | 19 | class ImageUploadServiceTest { 20 | ImageUploadRepo imageUploadRepo = mock(ImageUploadRepo.class); 21 | CloudinaryService cloudinaryService = mock(CloudinaryService.class); 22 | IdService idService = mock(IdService.class); 23 | ImageUploadService imageUploadService = new ImageUploadService(imageUploadRepo, idService, cloudinaryService); 24 | 25 | @Test 26 | void getAllProfiles() { 27 | //GIVEN 28 | Image image = new Image("1", "docker-image", "test-url.de"); 29 | when(imageUploadRepo.findAll()) 30 | .thenReturn(Collections.singletonList(image)); 31 | //WHEN 32 | List actual = imageUploadService.getAllImages(); 33 | 34 | //THEN 35 | assertThat(actual) 36 | .containsOnly(image); 37 | } 38 | 39 | @Test 40 | void postImage() throws IOException { 41 | //GIVEN 42 | ImageCreation imageCreation = new ImageCreation("docker-image"); 43 | Image expected = new Image("1", "docker-image", "test-url.de"); 44 | MockMultipartFile file = new MockMultipartFile("docker-image", "irgendwas".getBytes()); 45 | 46 | when(cloudinaryService.uploadImage(file)).thenReturn("test-url.de"); 47 | when(imageUploadRepo.save(expected)).thenReturn(expected); 48 | when(idService.createRandomId()).thenReturn("1"); 49 | 50 | 51 | //WHEN 52 | Image actual = imageUploadService.addImage(imageCreation, file); 53 | 54 | //THEN 55 | verify(cloudinaryService).uploadImage(file); 56 | verify(imageUploadRepo).save(expected); 57 | verify(idService).createRandomId(); 58 | assertEquals(expected, actual); 59 | } 60 | } 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /frontend/src/components/FilterButton.tsx: -------------------------------------------------------------------------------- 1 | import {useNavigate} from "react-router-dom"; 2 | import FilterAltIcon from '@mui/icons-material/FilterAlt'; 3 | import {IconButton} from "@mui/material"; 4 | import styled from "@emotion/styled"; 5 | import * as React from 'react'; 6 | import Menu from '@mui/material/Menu'; 7 | import MenuItem from '@mui/material/MenuItem'; 8 | 9 | 10 | export default function FilterButton() { 11 | 12 | const navigate = useNavigate(); 13 | const [anchorEl, setAnchorEl] = React.useState(null); 14 | const open = Boolean(anchorEl); 15 | 16 | const handleClick = (event: React.MouseEvent) => { 17 | setAnchorEl(event.currentTarget); 18 | }; 19 | 20 | const handleClose = (filter: string) => { 21 | if (filter === "stay") { 22 | setAnchorEl(null) 23 | } else { 24 | navigate(`/filter?filter=${filter}`); 25 | window.scrollTo(0, 0); 26 | setAnchorEl(null); 27 | } 28 | }; 29 | 30 | return ( 31 | <> 32 | 39 | 40 | 41 | handleClose("stay")} 46 | MenuListProps={{ 47 | 'aria-labelledby': 'basic-button', 48 | }} 49 | > 50 | handleClose("all")}>All Projects 51 | handleClose("DONATION")}>Donation Projects 52 | handleClose("PARTICIPATION")}>Participation Projects 53 | 54 | 55 | ) 56 | } 57 | 58 | const StyledIconButton = styled(IconButton)` 59 | width: 46px; 60 | height: 46px; 61 | 62 | svg { 63 | font-size: 32px; 64 | } 65 | 66 | border-radius: 4px; 67 | border: none; 68 | background-color: var(--colorBlack); 69 | margin: 0; 70 | padding: 0; 71 | display: grid; 72 | place-items: center; 73 | cursor: pointer; 74 | `; 75 | -------------------------------------------------------------------------------- /frontend/src/GlobalStyles.tsx: -------------------------------------------------------------------------------- 1 | import {createGlobalStyle} from "styled-components"; 2 | import {Button, createTheme, FormControl, TextField, ToggleButton, ToggleButtonGroup} from "@mui/material"; 3 | import styled from "@emotion/styled"; 4 | 5 | const GlobalStyles = createGlobalStyle` 6 | body { 7 | background-color: #FF644A; 8 | } 9 | `; 10 | export default GlobalStyles; 11 | 12 | 13 | export const StyledTheme = createTheme({ 14 | components: { 15 | MuiPaper: { 16 | styleOverrides: { 17 | root: { 18 | backgroundColor: "#EBE7D8" 19 | } 20 | } 21 | } 22 | } 23 | }); 24 | 25 | export const StyledBody = styled.div` 26 | display: flex; 27 | flex-direction: column; 28 | justify-content: flex-start; 29 | gap: 1.1em; 30 | margin-bottom: 100px; 31 | margin-top: 101px; 32 | `; 33 | 34 | export const StyledForm = styled.form` 35 | display: flex; 36 | flex-direction: column; 37 | align-items: center; 38 | justify-content: center; 39 | gap: 1.1em; 40 | background-color: #EBE7D8; 41 | border-radius: 4px; 42 | padding: 10px; 43 | `; 44 | 45 | export const StyledTextField = styled(TextField)` 46 | width: 100%; 47 | font-family: "Roboto", sans-serif; 48 | border-radius: 4px; 49 | `; 50 | 51 | export const StyledButton = styled(Button)` 52 | width: 100%; 53 | height: 56px; 54 | color: #163E56; 55 | border-color: #163E56; 56 | `; 57 | 58 | export const StyledSpan = styled.span` 59 | font-family: "Roboto Light", sans-serif; 60 | font-size: 0.8em; 61 | color: #163E56; 62 | `; 63 | 64 | export const StyledGallery = styled.div` 65 | display: flex; 66 | flex-direction: column; 67 | align-items: center; 68 | justify-content: center; 69 | gap: 1.1em; 70 | `; 71 | 72 | export const StyledSearchBar = styled.div` 73 | position: fixed; 74 | transform: translateX(-50%); 75 | left: 50%; 76 | top: 100px; 77 | width: 90%; 78 | border-radius: 4px; 79 | `; 80 | 81 | export const StyledToggleGroup = styled(ToggleButtonGroup)` 82 | font-family: "Roboto Light", sans-serif; 83 | display: flex; 84 | justify-content: center; 85 | width: 100%; 86 | `; 87 | 88 | export const StyledToggleButton = styled(ToggleButton)` 89 | font-family: "Roboto", sans-serif; 90 | display: flex; 91 | justify-content: center; 92 | width: 100%; 93 | height: 56px; 94 | 95 | &.Mui-selected { 96 | color: #163E56; 97 | } 98 | `; 99 | 100 | export const StyledChipFormControl = styled(FormControl)` 101 | width: 100%; 102 | border-radius: 4px; 103 | `; 104 | 105 | export const StyledMain = styled.main` 106 | display: flex; 107 | flex-direction: column; 108 | justify-content: center; 109 | gap: 20px; 110 | `; 111 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/security/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.security; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.http.HttpMethod; 6 | import org.springframework.security.config.Customizer; 7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 8 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 9 | import org.springframework.security.config.http.SessionCreationPolicy; 10 | import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; 11 | import org.springframework.security.crypto.password.PasswordEncoder; 12 | import org.springframework.security.web.SecurityFilterChain; 13 | import org.springframework.security.web.csrf.CookieCsrfTokenRepository; 14 | import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; 15 | 16 | 17 | @EnableWebSecurity 18 | @Configuration 19 | public class SecurityConfig { 20 | 21 | @Bean 22 | public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { 23 | CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler(); 24 | requestHandler.setCsrfRequestAttributeName(null); 25 | 26 | return http.csrf(csrf -> csrf 27 | .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) 28 | .csrfTokenRequestHandler(requestHandler)) 29 | .httpBasic(Customizer.withDefaults()) 30 | .sessionManagement(httpSecuritySessionManagementConfigurer -> 31 | httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)) 32 | .authorizeHttpRequests(httpRequests -> 33 | httpRequests 34 | .requestMatchers(HttpMethod.GET, "/api/projects").permitAll() 35 | .requestMatchers("/api/projects").authenticated() 36 | .requestMatchers(HttpMethod.GET, "/api/projects/**").permitAll() 37 | .requestMatchers("/api/projects/**").authenticated() 38 | .requestMatchers("/api/users/me").permitAll() 39 | .requestMatchers("/api/users/me-object").permitAll() 40 | .requestMatchers("/api/users/register").permitAll() 41 | .requestMatchers("/api/users/profile").authenticated() 42 | .anyRequest().permitAll() 43 | ) 44 | .build(); 45 | } 46 | 47 | @Bean 48 | public PasswordEncoder passwordEncoder() { 49 | return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/security/MongoUserService.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.security; 2 | 3 | import de.neuefische.capstone.backend.services.IdService; 4 | import lombok.RequiredArgsConstructor; 5 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 6 | import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; 7 | import org.springframework.security.crypto.password.PasswordEncoder; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.util.ArrayList; 11 | 12 | @Service 13 | @RequiredArgsConstructor 14 | public class MongoUserService { 15 | 16 | private final MongoUserRepository mongoUserRepository; 17 | private final IdService idService; 18 | 19 | public MongoUserWithoutPassword findByUsername(String username) { 20 | if (mongoUserRepository.findByUsername(username).isEmpty()) { 21 | return new MongoUserWithoutPassword("unknown", "anonymousUser", new ArrayList<>(), new ArrayList<>()); 22 | } 23 | MongoUser mongoUser = mongoUserRepository.findByUsername(username) 24 | .orElseThrow(() -> new UsernameNotFoundException("Username " + username + " not found")); 25 | 26 | return new MongoUserWithoutPassword(mongoUser.id(), mongoUser.username(), mongoUser.donations(), mongoUser.participations()); 27 | } 28 | 29 | public void registerUser(MongoUserCreation mongoUserWithoutId) { 30 | if (mongoUserRepository.findByUsername(mongoUserWithoutId.username()).isPresent()) { 31 | throw new IllegalArgumentException("User already exists"); 32 | } 33 | 34 | PasswordEncoder encoder = new Argon2PasswordEncoder(16, 32, 8, 1 << 16, 4); 35 | String encodedPassword = encoder.encode(mongoUserWithoutId.password()); 36 | 37 | MongoUser newUser = new MongoUser(idService.createRandomId(), mongoUserWithoutId.username(), encodedPassword, new ArrayList<>(), new ArrayList<>()); 38 | mongoUserRepository.insert(newUser); 39 | } 40 | 41 | public MongoUserWithoutPassword updateUser(MongoUserWithoutPassword mongoUserWithoutPassword) { 42 | MongoUser mongoUser = mongoUserRepository.findById(mongoUserWithoutPassword.id()).orElseThrow(() -> new UsernameNotFoundException("Username " + mongoUserWithoutPassword.username() + " not found")); 43 | MongoUser updatedUser = new MongoUser( 44 | mongoUserWithoutPassword.id(), 45 | mongoUserWithoutPassword.username(), 46 | mongoUser.password(), 47 | mongoUserWithoutPassword.donations(), 48 | mongoUserWithoutPassword.participations()); 49 | 50 | MongoUser returnUser = mongoUserRepository.save(updatedUser); 51 | return new MongoUserWithoutPassword(returnUser.id(), returnUser.username(), returnUser.donations(), returnUser.participations()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/components/TopDonators.tsx: -------------------------------------------------------------------------------- 1 | import {Project} from "../models/models.tsx"; 2 | import styled from "@emotion/styled"; 3 | import Avatar from '@mui/material/Avatar'; 4 | 5 | type Props = { 6 | project: Project; 7 | } 8 | 9 | export default function TopDonators(props: Props) { 10 | 11 | const maxDonation = Math.max(...props.project.donations.map(donation => parseFloat(donation.amount))); 12 | const sortedDonations = props.project.donations.slice().sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount)); 13 | const topDonations = sortedDonations.slice(0, 5); 14 | 15 | function stringAvatar(name: string) { 16 | const nameParts = name.split(' '); 17 | return { 18 | sx: { 19 | bgcolor: stringToColor(name), 20 | }, 21 | children: nameParts.length > 1 22 | ? `${nameParts[0][0]}${nameParts[1][0]}` 23 | : `${nameParts[0][0]}`, 24 | }; 25 | } 26 | 27 | function stringToColor(string: string) { 28 | let hash = 0; 29 | for (let i = 0; i < string.length; i += 1) { 30 | hash = string.charCodeAt(i) + ((hash << 5) - hash); 31 | } 32 | let color = '#'; 33 | for (let i = 0; i < 3; i += 1) { 34 | const value = (hash >> (i * 8)) & 0xff; 35 | color += `00${value.toString(16)}`.slice(-2); 36 | } 37 | return color; 38 | } 39 | 40 | return ( 41 | 42 | Top Donations 43 | 44 | {topDonations.map(donation => 45 | 46 | 47 | 48 | {donation.amount} EUR 49 | 50 | 51 | )} 52 | 53 | 54 | ) 55 | } 56 | 57 | const StyledTopDonators = styled.div` 58 | margin: 30px 0 10px 0; 59 | `; 60 | 61 | const StyledDonationChart = styled.div` 62 | display: flex; 63 | flex-direction: column; 64 | gap: 10px; 65 | margin: 0 10px; 66 | `; 67 | 68 | const StyledDonationItem = styled.div` 69 | display: flex; 70 | align-items: center; 71 | `; 72 | 73 | const StyledDonationBar = styled.div` 74 | min-width: 50px; 75 | background-color: #e74c3c; 76 | color: white; 77 | padding: 5px; 78 | margin-left: 15px; 79 | border-radius: 5px; 80 | display: flex; 81 | justify-content: flex-end; 82 | `; 83 | 84 | const StyledHeadLine = styled.h2` 85 | padding: 0 0 10px 10px; 86 | margin: 0; 87 | font-family: "Robot", sans-serif; 88 | font-weight: 400; 89 | `; 90 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import {Route, Routes} from "react-router-dom"; 2 | import {ToastContainer} from "react-toastify"; 3 | import 'react-toastify/dist/ReactToastify.css'; 4 | import {useFetch} from "./hooks/useFetch.tsx"; 5 | import {useEffect, useState} from "react"; 6 | import ShowProject from "./pages/ShowProject.tsx"; 7 | import AddEditProject from "./pages/AddEditProject.tsx"; 8 | import NavigationBar from "./components/NavigationBar.tsx"; 9 | import Header from "./components/Header.tsx"; 10 | import AddDonationOrParticipation from "./pages/AddDonationOrParticipation.tsx"; 11 | import LoginPage from "./pages/LoginPage.tsx"; 12 | import RegisterPage from "./pages/RegisterPage.tsx"; 13 | import UserProfile from "./pages/UserProfile.tsx"; 14 | import ProtectedRoutes from "./components/ProtectedRoutes.tsx"; 15 | import Home from "./pages/Home.tsx"; 16 | import FilteredGallery from "./pages/FilteredGallery.tsx"; 17 | import SearchGallery from "./pages/SearchGallary.tsx"; 18 | import {ThemeProvider} from "@mui/material"; 19 | import GlobalStyles, {StyledTheme} from "./GlobalStyles.tsx"; 20 | 21 | export default function App() { 22 | 23 | const fetchProjects = useFetch((state) => state.fetchProjects); 24 | const [initialLoad, setInitialLoad] = useState(true); 25 | const me = useFetch(state => state.me); 26 | const meObject = useFetch(state => state.meObject); 27 | const userName = useFetch(state => state.userName); 28 | 29 | useEffect(() => { 30 | try { 31 | fetchProjects(); 32 | me(); 33 | meObject(); 34 | } catch (error) { 35 | console.error(error); 36 | } finally { 37 | setInitialLoad(false); 38 | } 39 | }, [fetchProjects, me, meObject]); 40 | 41 | if (initialLoad) return null; 42 | 43 | return ( 44 | <> 45 | 46 | 47 |
48 | 49 | 50 | }> 51 | }/> 52 | }/> 53 | }/> 54 | }/> 55 | }/> 56 | 57 | }/> 58 | }/> 59 | }/> 60 | }/> 61 | }/> 62 | }/> 63 | 64 | 65 | 66 | 67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /frontend/src/pages/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import {SelectChangeEvent} from "@mui/material"; 3 | import {useFetch} from "../hooks/useFetch.tsx"; 4 | import EditIcon from "@mui/icons-material/Edit"; 5 | import {useEffect, useState} from "react"; 6 | import LogoutButton from "../components/LogoutButton.tsx"; 7 | import FilterUserData from "../components/FilterUserData.tsx"; 8 | import Box from '@mui/material/Box'; 9 | import InputLabel from '@mui/material/InputLabel'; 10 | import MenuItem from '@mui/material/MenuItem'; 11 | import FormControl from '@mui/material/FormControl'; 12 | import Select from '@mui/material/Select'; 13 | import {StyledBody, StyledButton, StyledTextField} from "../GlobalStyles.tsx"; 14 | 15 | export default function UserProfile() { 16 | 17 | const user = useFetch(state => state.user); 18 | const meObject = useFetch(state => state.meObject); 19 | const [filter, setFilter] = useState("My Projects"); 20 | 21 | useEffect(() => { 22 | meObject(); 23 | }, [meObject]); 24 | 25 | const handleFilterChange = (event: SelectChangeEvent) => { 26 | setFilter(event.target.value); 27 | }; 28 | 29 | return ( 30 | 31 | 32 | Personal Data 33 | 37 | }>EDIT USERDATA 39 | 40 | Project Data 41 | 42 | 43 | Filter 44 | 54 | 55 | 56 | 57 | 58 | 59 | ) 60 | } 61 | 62 | const StyledProfile = styled.div` 63 | font-family: "Roboto", sans-serif; 64 | background-color: #EBE7D8; 65 | border-radius: 4px; 66 | padding: 0 10px 10px 10px; 67 | `; 68 | 69 | const StyledHeadLine = styled.h2` 70 | padding-left: 0; 71 | margin-top: 10px; 72 | margin-bottom: 6px; 73 | `; 74 | -------------------------------------------------------------------------------- /backend/src/test/java/de/neuefische/capstone/backend/security/MongoUserServiceTest.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.security; 2 | 3 | import de.neuefische.capstone.backend.services.IdService; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import java.util.ArrayList; 7 | import java.util.Optional; 8 | 9 | import static org.junit.jupiter.api.Assertions.assertEquals; 10 | import static org.mockito.Mockito.*; 11 | 12 | class MongoUserServiceTest { 13 | private final MongoUserRepository mongoUserRepository = mock(MongoUserRepository.class); 14 | private final IdService idService = mock(IdService.class); 15 | private final MongoUserService mongoUserService = new MongoUserService(mongoUserRepository, idService); 16 | 17 | @Test 18 | void verifyRepoCalls_WhenUserIsRegistered() { 19 | //Given 20 | MongoUserCreation mongoUserWithNoId = new MongoUserCreation("test", "test"); 21 | when(mongoUserRepository.findByUsername("test")) 22 | .thenReturn(Optional.empty()); 23 | when(idService.createRandomId()) 24 | .thenReturn("1"); 25 | 26 | //When 27 | mongoUserService.registerUser(mongoUserWithNoId); 28 | 29 | //Then 30 | verify(mongoUserRepository).findByUsername("test"); 31 | verify(idService).createRandomId(); 32 | } 33 | 34 | @Test 35 | void throwException_WhenNoUseralreadyExists() { 36 | //Given 37 | when(mongoUserRepository.findByUsername("test")) 38 | .thenReturn(Optional.of(new MongoUser("1", "test", "test", new ArrayList<>(), new ArrayList<>()))); 39 | 40 | //When / Then 41 | try { 42 | mongoUserService.registerUser(new MongoUserCreation("test", "test")); 43 | } catch (Exception e) { 44 | assertEquals("User already exists", e.getMessage()); 45 | } 46 | } 47 | 48 | @Test 49 | void returnObjectOfUser_WhenUsernameExists() { 50 | // Given 51 | MongoUser mongoUser = new MongoUser("1", "test", "test", new ArrayList<>(), new ArrayList<>()); 52 | when(mongoUserRepository.findByUsername("test")) 53 | .thenReturn(Optional.of(mongoUser)); 54 | MongoUserWithoutPassword expected = new MongoUserWithoutPassword("1", "test", new ArrayList<>(), new ArrayList<>()); 55 | 56 | // When 57 | MongoUserWithoutPassword actual = mongoUserService.findByUsername("test"); 58 | 59 | // Then 60 | verify(mongoUserRepository, times(2)).findByUsername("test"); 61 | assertEquals(expected, actual); 62 | } 63 | 64 | @Test 65 | void returnObjectOfAnonymousUser_WhenUsernameExists() { 66 | // Given 67 | when(mongoUserRepository.findByUsername("test")) 68 | .thenReturn(Optional.empty()); 69 | MongoUserWithoutPassword expected = new MongoUserWithoutPassword("unknown", "anonymousUser", new ArrayList<>(), new ArrayList<>()); 70 | 71 | // When 72 | MongoUserWithoutPassword actual = mongoUserService.findByUsername("test"); 73 | 74 | // Then 75 | verify(mongoUserRepository).findByUsername("test"); 76 | assertEquals(expected, actual); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /frontend/src/components/NavigationBar.tsx: -------------------------------------------------------------------------------- 1 | import AddButton from "./AddButton.tsx"; 2 | import EditButton from "./EditButton.tsx"; 3 | import styled from "@emotion/styled"; 4 | import {useEffect, useState} from "react"; 5 | import {useLocation} from "react-router-dom"; 6 | import HomeButton from "./HomeButton.tsx"; 7 | import DeleteButton from "./DeleteButton.tsx"; 8 | import {useFetch} from "../hooks/useFetch.tsx"; 9 | import DonationButton from "./DonationButton.tsx"; 10 | import ParticipationButton from "./ParticipationButton.tsx"; 11 | import {Project} from "../models/models.tsx"; 12 | import UserButton from "./UserButton.tsx"; 13 | import FilterButton from "./FilterButton.tsx"; 14 | import SearchButton from "./SearchButton.tsx"; 15 | 16 | export default function NavigationBar() { 17 | const location = useLocation(); 18 | const id = location.pathname.split("/")[2] 19 | const [page, setPage] = useState(""); 20 | const checkPage = useFetch(state => state.checkPage); 21 | const getProjectById = useFetch(state => state.getProjectById); 22 | const [project, setProject] = useState(undefined); 23 | const [participationVisible, setParticipationVisible] = useState(false); 24 | 25 | useEffect(() => { 26 | setPage(checkPage(location.pathname)) 27 | }, [location, checkPage]); 28 | 29 | useEffect(() => { 30 | if (id) { 31 | getProjectById(id) 32 | .then((project) => { 33 | setProject(project); 34 | }) 35 | .catch(error => { 36 | console.error(error); 37 | }); 38 | } 39 | }, [id, getProjectById, location]); 40 | 41 | useEffect(() => { 42 | if (project && project.category === "PARTICIPATION") { 43 | setParticipationVisible(true); 44 | } else { 45 | setParticipationVisible(false); 46 | } 47 | }, [project, project?.category]); 48 | 49 | return ( 50 | 51 | 52 | 53 | {page === "/" && } 54 | {page === "details" && } 55 | {page === "edit" && } 56 | {page === "details" && } 57 | {page === "details" && participationVisible && } 58 | {page === "/" && } 59 | {(page === "/" || page === "filter" || page === "filter-all" || page === "filter-donation" || page === "filter-participation") && 60 | } 61 | 62 | 63 | 64 | ) 65 | } 66 | 67 | const StyledNavigationBar = styled.div` 68 | display: inline-flex; 69 | border-radius: 5px 5px 0 0; 70 | height: 45px; 71 | `; 72 | 73 | const StyledNavigationWrapper = styled.div` 74 | z-index: 1; 75 | border-radius: 10px 10px 0 0; 76 | background: var(--blue, #163E56); 77 | box-shadow: 0 -4px 4px 0px rgba(0, 0, 0, 0.25); 78 | width: 100%; 79 | bottom: 0; 80 | left: 0; 81 | position: fixed; 82 | height: 60px; 83 | display: flex; 84 | justify-content: center; 85 | align-items: center; 86 | `; 87 | -------------------------------------------------------------------------------- /frontend/src/pages/RegisterPage.tsx: -------------------------------------------------------------------------------- 1 | import {ChangeEvent, FormEvent, useState} from "react"; 2 | import {useNavigate} from "react-router-dom"; 3 | import {useFetch} from "../hooks/useFetch.tsx"; 4 | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; 5 | import AppRegistrationIcon from '@mui/icons-material/AppRegistration'; 6 | import {StyledBody, StyledButton, StyledForm, StyledSpan, StyledTextField} from "../GlobalStyles.tsx"; 7 | 8 | export default function RegisterPage() { 9 | 10 | const [username, setUsername] = useState(""); 11 | const [password, setPassword] = useState(""); 12 | const [repeatedPassword, setRepeatedPassword] = useState(""); 13 | const navigate = useNavigate(); 14 | const register = useFetch((state) => state.register); 15 | const regex = /^(?=.*[a-zA-Z])(?=.*\d).{6,20}$/; 16 | 17 | function handleUsernameInput(event: ChangeEvent) { 18 | setUsername(event.currentTarget.value); 19 | } 20 | 21 | function handlePasswordInput(event: ChangeEvent) { 22 | setPassword(event.currentTarget.value); 23 | } 24 | 25 | function handleRepeatedPasswordInput(event: ChangeEvent) { 26 | setRepeatedPassword(event.currentTarget.value); 27 | } 28 | 29 | function handleRegistration(event: FormEvent) { 30 | event.preventDefault(); 31 | register(username, password, repeatedPassword, setPassword, setRepeatedPassword, navigate); 32 | } 33 | 34 | return ( 35 | 36 | 37 | 41 | At least 4 characters 42 | 48 | 49 | At least 6 characters, must contain numbers and letters 50 | 51 | 57 | navigate("/login")} variant="outlined" 58 | endIcon={}>BACK 59 | }>REGISTER 61 | 62 | 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: "Deploy App" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-frontend: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: '18' 17 | 18 | - name: Build Frontend 19 | working-directory: frontend 20 | run: | 21 | npm install 22 | npm run build 23 | 24 | - uses: actions/upload-artifact@v3 25 | with: 26 | name: frontend-build 27 | path: frontend/dist/ 28 | build-backend: 29 | runs-on: ubuntu-latest 30 | needs: build-frontend 31 | steps: 32 | - uses: actions/checkout@v3 33 | 34 | - uses: actions/download-artifact@v3 35 | with: 36 | name: frontend-build 37 | path: backend/src/main/resources/static 38 | 39 | - name: Set up JDK 40 | uses: actions/setup-java@v3 41 | with: 42 | #Set Java Version 43 | java-version: '20' 44 | distribution: 'adopt' 45 | cache: 'maven' 46 | 47 | - name: Build with maven 48 | run: mvn -B package --file backend/pom.xml 49 | 50 | #Set backend/target/"MyApp".jar 51 | - uses: actions/upload-artifact@v3 52 | with: 53 | name: app.jar 54 | path: backend/target/capstone.jar 55 | 56 | push-to-docker-hub: 57 | runs-on: ubuntu-latest 58 | needs: build-backend 59 | steps: 60 | - uses: actions/checkout@v3 61 | - uses: actions/download-artifact@v3 62 | with: 63 | name: app.jar 64 | path: backend/target 65 | 66 | - name: Login to DockerHub 67 | uses: docker/login-action@v3 68 | with: 69 | #Set dockerhub username 70 | username: ben21 71 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 72 | 73 | - name: Build and push 74 | uses: docker/build-push-action@v3 75 | with: 76 | push: true 77 | #Set dockerhub project (replace "bartfastiel/deploy-to-aws-with-github-actions") 78 | tags: ben21/capstone:latest 79 | context: . 80 | 81 | deploy: 82 | runs-on: ubuntu-latest 83 | needs: push-to-docker-hub 84 | steps: 85 | - name: Restart Docker Compose Services 86 | uses: appleboy/ssh-action@master 87 | with: 88 | #Set hostname of your AWS EC2 instance 89 | host: 164.90.173.123 90 | #Set App Name ("myapp" - "tango") 91 | username: ben 92 | password: ${{ secrets.SSH_PASSWORD }} 93 | #Set App Name ("myapp" - "tango") 94 | #Set dockerhub project (replace "bartfastiel/deploy-to-aws-with-github-actions") 95 | script: | 96 | cd /home/ben/services/ 97 | docker compose pull app-be-human 98 | docker stop be-human 99 | docker rm be-human 100 | docker compose up -d --no-deps app-be-human 101 | - name: Check the deployed service URL 102 | uses: jtalk/url-health-check-action@v3 103 | with: 104 | #Set URL of your AWS EC2 instance 105 | url: http://be-human.schaefer-inet.de 106 | max-attempts: 3 107 | retry-delay: 5s 108 | retry-all: true -------------------------------------------------------------------------------- /backend/src/test/java/de/neuefische/capstone/backend/imageupload/ImageUploadIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.imageupload; 2 | 3 | import com.cloudinary.Cloudinary; 4 | import com.cloudinary.Uploader; 5 | import org.junit.jupiter.api.Test; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | import org.springframework.boot.test.mock.mockito.MockBean; 10 | import org.springframework.http.MediaType; 11 | import org.springframework.mock.web.MockMultipartFile; 12 | import org.springframework.security.test.context.support.WithMockUser; 13 | import org.springframework.test.annotation.DirtiesContext; 14 | import org.springframework.test.web.servlet.MockMvc; 15 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; 16 | 17 | import java.io.File; 18 | import java.util.Map; 19 | 20 | import static org.mockito.ArgumentMatchers.any; 21 | import static org.mockito.Mockito.mock; 22 | import static org.mockito.Mockito.when; 23 | 24 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 25 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; 26 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 27 | 28 | @SpringBootTest 29 | @AutoConfigureMockMvc 30 | class ImageUploadIntegrationTest { 31 | @Autowired 32 | MockMvc mockMvc; 33 | 34 | @MockBean 35 | Cloudinary cloudinary; 36 | 37 | Uploader uploader = mock(Uploader.class); 38 | 39 | @WithMockUser 40 | @Test 41 | void getAllImageProfilesAndExpectEmptyList() throws Exception { 42 | mockMvc.perform(MockMvcRequestBuilders.get("/api/upload")) 43 | .andExpect(status().isOk()) 44 | .andExpect(content().json(""" 45 | [] 46 | """)); 47 | } 48 | 49 | @WithMockUser 50 | @Test 51 | @DirtiesContext 52 | void expectSuccessfulPost() throws Exception { 53 | MockMultipartFile data = new MockMultipartFile("data", 54 | null, 55 | MediaType.APPLICATION_JSON_VALUE, 56 | """ 57 | {"name":"docker-image"} 58 | """ 59 | .getBytes() 60 | ); 61 | 62 | MockMultipartFile file = new MockMultipartFile("file", 63 | "testImage.png", 64 | MediaType.IMAGE_PNG_VALUE, 65 | "testImage".getBytes() 66 | ); 67 | 68 | File fileToUpload = File.createTempFile("image", null); 69 | file.transferTo(fileToUpload); 70 | 71 | 72 | when(cloudinary.uploader()).thenReturn(uploader); 73 | when(uploader.upload(any(), any())).thenReturn(Map.of("url", "test-url")); 74 | 75 | mockMvc.perform(multipart("/api/upload") 76 | .file(data) 77 | .file(file) 78 | .with(csrf())) 79 | 80 | 81 | .andExpect(status().isCreated()) 82 | .andExpect(content().json(""" 83 | {"name": "docker-image", 84 | "url": "test-url"} 85 | """)) 86 | .andExpect(jsonPath("$.id").isNotEmpty()); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /frontend/src/pages/SearchGallary.tsx: -------------------------------------------------------------------------------- 1 | import {useFetch} from "../hooks/useFetch.tsx"; 2 | import styled from "@emotion/styled"; 3 | import {Project} from "../models/models.tsx"; 4 | import React, {useEffect, useState} from "react"; 5 | import {Autocomplete, Stack} from "@mui/material"; 6 | import ProjectCard from "../components/ProjectCard.tsx"; 7 | import {StyledBody, StyledGallery, StyledSearchBar, StyledTextField} from "../GlobalStyles.tsx"; 8 | 9 | export default function SearchGallery() { 10 | 11 | const projects = useFetch((state) => state.projects); 12 | const fetchProjects = useFetch((state) => state.fetchProjects); 13 | const [searchTerm, setSearchTerm] = useState(""); 14 | 15 | useEffect(() => { 16 | fetchProjects(); 17 | }, [fetchProjects]); 18 | 19 | function searchProjects(projects: Project[], searchTerm: string) { 20 | if (!searchTerm) { 21 | return projects; // Return the original list if no search term provided 22 | } 23 | 24 | const lowerCaseSearchTerm = searchTerm.toLowerCase(); 25 | 26 | return projects.filter(project => { 27 | const projectValues = Object.values(project).map(value => 28 | typeof value === 'string' ? value.toLowerCase() : '' 29 | ); 30 | 31 | return projectValues.some(value => value.includes(lowerCaseSearchTerm)); 32 | }); 33 | } 34 | 35 | function handleChange(event: React.ChangeEvent) { 36 | setSearchTerm(event.target.value); 37 | } 38 | 39 | function handleAutoCompleteChange( 40 | _: React.SyntheticEvent, 41 | value: string | null 42 | ) { 43 | if (value !== null) { 44 | setSearchTerm(value); 45 | } else { 46 | setSearchTerm(""); 47 | } 48 | } 49 | 50 | const filteredProjects = searchProjects(projects, searchTerm); 51 | 52 | return ( 53 | 54 | 55 | 56 | 57 | option.name)} 64 | renderInput={(params) => }/> 66 | 67 | 68 | 69 | 70 | 71 | {filteredProjects.map((project) => ( 72 | 73 | ))} 74 | 75 | 76 | 77 | ) 78 | } 79 | 80 | const StyledSearchResultsDiv = styled.div` 81 | margin-top: 80px; 82 | `; 83 | 84 | const StyledFixedSearchBarDiv = styled.div` 85 | position: fixed; 86 | z-index: 1; 87 | top: 50px; 88 | width: 100%; 89 | height: 125px; 90 | background-color: #FF644A; 91 | `; 92 | -------------------------------------------------------------------------------- /backend/src/test/java/de/neuefische/capstone/backend/security/MongoUserIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend.security; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 6 | import org.springframework.boot.test.context.SpringBootTest; 7 | import org.springframework.http.MediaType; 8 | import org.springframework.security.test.context.support.WithMockUser; 9 | import org.springframework.test.web.servlet.MockMvc; 10 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; 11 | import org.springframework.test.web.servlet.result.MockMvcResultMatchers; 12 | 13 | import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 14 | 15 | @SpringBootTest 16 | @AutoConfigureMockMvc 17 | class MongoUserIntegrationTest { 18 | 19 | @Autowired 20 | private MockMvc mockMvc; 21 | 22 | @Test 23 | void getAnonymousUser_whenGetUserName() throws Exception { 24 | // GIVEN that user is not logged in 25 | // WHEN 26 | mockMvc.perform(MockMvcRequestBuilders.get("/api/users/me")) 27 | // THEN 28 | .andExpect(MockMvcResultMatchers.status().isOk()) 29 | .andExpect(MockMvcResultMatchers.content().string("anonymousUser")); 30 | } 31 | 32 | @Test 33 | void getUserObject_whenGetUserName() throws Exception { 34 | // GIVEN that user is not logged in 35 | // WHEN 36 | mockMvc.perform(MockMvcRequestBuilders.get("/api/users/me-object")) 37 | // THEN 38 | .andExpect(MockMvcResultMatchers.status().isOk()) 39 | .andExpect(MockMvcResultMatchers.jsonPath("username").value("anonymousUser")) 40 | .andExpect(MockMvcResultMatchers.jsonPath("id").value("unknown")) 41 | .andExpect(MockMvcResultMatchers.jsonPath("donations").isEmpty()) 42 | .andExpect(MockMvcResultMatchers.jsonPath("participations").isEmpty()); 43 | } 44 | 45 | @Test 46 | @WithMockUser(username = "testUser", password = "testPassword") 47 | void getUsername_whenLoggedInGetUserName() throws Exception { 48 | // Given that user is logged in 49 | // When 50 | mockMvc.perform(MockMvcRequestBuilders.post("/api/users/login") 51 | .with(csrf())) 52 | //Then 53 | .andExpect(MockMvcResultMatchers.status().isOk()) 54 | .andExpect(MockMvcResultMatchers.content().string("testUser")); 55 | } 56 | 57 | @Test 58 | @WithMockUser(username = "testUser", password = "testPassword") 59 | void expectStatusOk_whenLogoutUser() throws Exception { 60 | //Given 61 | mockMvc.perform(MockMvcRequestBuilders.post("/api/users/login") 62 | .with(csrf())); 63 | 64 | //When 65 | mockMvc.perform(MockMvcRequestBuilders.post("/api/users/logout") 66 | .with(csrf())) 67 | 68 | //Then 69 | .andExpect(MockMvcResultMatchers.status().isOk()); 70 | } 71 | 72 | @Test 73 | void expectRegistration_whenRegisterUser() throws Exception { 74 | //GIVEN 75 | String testUserWithoutId = """ 76 | { 77 | "username": "themeTest", 78 | "password": "secretPass3" 79 | } 80 | """; 81 | 82 | //WHEN 83 | mockMvc.perform(MockMvcRequestBuilders.post("/api/users/register") 84 | .contentType(MediaType.APPLICATION_JSON) 85 | .content(testUserWithoutId) 86 | .with(csrf())) 87 | //THEN 88 | .andExpect(MockMvcResultMatchers.status().isOk()) 89 | .andExpect(MockMvcResultMatchers.content().string("registered")); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /frontend/src/components/FilterUserData.tsx: -------------------------------------------------------------------------------- 1 | import {User} from "../models/models.tsx"; 2 | import styled from "@emotion/styled"; 3 | import {useNavigate} from "react-router-dom"; 4 | import {useFetch} from "../hooks/useFetch.tsx"; 5 | import {useEffect} from "react"; 6 | 7 | type Props = { 8 | user: User, 9 | filterArgument: string, 10 | } 11 | export default function FilterUserData(props: Props) { 12 | 13 | const navigate = useNavigate(); 14 | const projects = useFetch(state => state.projects); 15 | const fetchProjects = useFetch(state => state.fetchProjects); 16 | 17 | useEffect(() => { 18 | fetchProjects(); 19 | }, [fetchProjects]); 20 | 21 | const formatAmountToCurrency = (amount: string) => { 22 | return parseFloat(amount).toLocaleString("de-DE", { 23 | style: "currency", 24 | currency: "EUR", 25 | minimumFractionDigits: 2, 26 | maximumFractionDigits: 2, 27 | }) 28 | } 29 | 30 | const totalDonations = (props.user.donations.reduce((sum, donation) => sum + parseFloat(donation.amount), 0)).toString(); 31 | 32 | function filter(filter: string) { 33 | if (filter === "My Donations") { 34 | return ( 35 | <> 36 | {props.user.donations.map((donation) => 37 | navigate(`/details/${donation.projectId}`)} 38 | key={donation.id}> 39 | {donation.projectName} 40 | {formatAmountToCurrency(donation.amount)} 41 | )} 42 | 43 | Sum: {formatAmountToCurrency(totalDonations)} 44 | 45 | 46 | ) 47 | } else if (filter === "My Participations") { 48 | return ( 49 | <> 50 | {props.user.participations.map((participation) => 51 | navigate(`/details/${participation.projectId}`)} 52 | key={participation.id}>{participation.projectName})} 53 | 54 | Sum: {props.user.participations.length} 55 | 56 | 57 | ) 58 | } else if (filter === "My Projects") { 59 | const userProjects = projects.filter(project => project.userId === props.user.id); 60 | 61 | return ( 62 | <> 63 | {userProjects.map((project) => 64 | navigate(`/details/${project.id}`)} 65 | key={project.id}>{project.name})} 66 | 67 | Sum: {userProjects.length} 68 | 69 | 70 | ) 71 | } 72 | } 73 | 74 | 75 | return ( 76 | <> 77 | {filter(props.filterArgument)} 78 | 79 | ) 80 | } 81 | 82 | const StyledListDiv = styled.div` 83 | display: flex; 84 | justify-content: space-between; 85 | align-items: center; 86 | border: none; 87 | background-color: #FFB34F; 88 | border-radius: 4px; 89 | padding: 5px; 90 | margin: 0; 91 | cursor: pointer; 92 | `; 93 | 94 | const StyledP = styled.p` 95 | margin: 0; 96 | font-family: "Roboto", sans-serif; 97 | font-weight: 400; 98 | `; 99 | 100 | const StyledSumP = styled.p` 101 | margin: 0; 102 | font-family: "Roboto", sans-serif; 103 | font-weight: 600; 104 | `; 105 | 106 | const StyledTotalPWrapper = styled.div` 107 | display: flex; 108 | justify-content: right; 109 | `; 110 | -------------------------------------------------------------------------------- /backend/src/test/java/de/neuefische/capstone/backend/ProjectCalculationsTest.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend; 2 | 3 | import de.neuefische.capstone.backend.models.*; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import java.math.BigDecimal; 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | 10 | import static org.junit.jupiter.api.Assertions.assertEquals; 11 | 12 | class ProjectCalculationsTest { 13 | 14 | ProjectCalculations projectCalculations = new ProjectCalculations(); 15 | 16 | @Test 17 | void returnProjectWithProgress_whenCalculateProgressForParticipation() { 18 | //Given 19 | Participation newParticipation = new Participation( 20 | "01A", 21 | "01B", 22 | "ProjectName", 23 | "ParticipationName", 24 | "userId123" 25 | ); 26 | 27 | Project project = new Project( 28 | "01A", 29 | "test", 30 | "test", 31 | Category.PARTICIPATION, 32 | List.of(Demand.DONATIONINKIND), 33 | 0, 34 | 100, 35 | "test", 36 | new ArrayList<>(), 37 | List.of(newParticipation), 38 | "userId123", 39 | new Image("", "", "") 40 | ); 41 | 42 | Project expectedProject = new Project( 43 | "01A", 44 | "test", 45 | "test", 46 | Category.PARTICIPATION, 47 | List.of(Demand.DONATIONINKIND), 48 | 1, 49 | 100, 50 | "test", 51 | new ArrayList<>(), 52 | List.of(newParticipation), 53 | "userId123", 54 | new Image("", "", "") 55 | ); 56 | 57 | //When 58 | Project actualProject = projectCalculations.calculateProgressForParticipations(project); 59 | 60 | //Then 61 | assertEquals(expectedProject, actualProject); 62 | } 63 | 64 | @Test 65 | void returnProject_whenCategoryWrong() { 66 | //Given 67 | Participation newParticipation = new Participation( 68 | "01A", 69 | "01B", 70 | "ProjectName", 71 | "ParticipationName", 72 | "userId123" 73 | ); 74 | 75 | Project project = new Project( 76 | "01A", 77 | "test", 78 | "test", 79 | Category.DONATION, 80 | List.of(Demand.DONATIONINKIND), 81 | 0, 82 | 100, 83 | "test", 84 | new ArrayList<>(), 85 | List.of(newParticipation), 86 | "userId123", 87 | new Image("", "", "") 88 | ); 89 | 90 | //When 91 | Project actualProject = projectCalculations.calculateProgressForParticipations(project); 92 | 93 | //Then 94 | assertEquals(project, actualProject); 95 | } 96 | 97 | @Test 98 | void returnProjectWithProgress_whenCalculateProgressForDonation() { 99 | //Given 100 | Donation newDonation = new Donation( 101 | "01A", 102 | "01B", 103 | "ProjectName", 104 | "DonationName", 105 | new BigDecimal(50), 106 | "userId123" 107 | ); 108 | 109 | Project project = new Project( 110 | "01A", 111 | "test", 112 | "test", 113 | Category.DONATION, 114 | List.of(Demand.DONATIONINKIND), 115 | 0, 116 | 100, 117 | "test", 118 | List.of(newDonation), 119 | new ArrayList<>(), 120 | "userId123", 121 | new Image("", "", "") 122 | ); 123 | 124 | Project expectedProject = new Project( 125 | "01A", 126 | "test", 127 | "test", 128 | Category.DONATION, 129 | List.of(Demand.DONATIONINKIND), 130 | 50, 131 | 100, 132 | "test", 133 | List.of(newDonation), 134 | new ArrayList<>(), 135 | "userId123", 136 | new Image("", "", "") 137 | ); 138 | 139 | //When 140 | Project actualProject = projectCalculations.calculateProgressForDonations(project); 141 | 142 | //Then 143 | assertEquals(expectedProject, actualProject); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /backend/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.1.2 9 | 10 | 11 | com.example 12 | backend 13 | 0.0.1-SNAPSHOT 14 | backend 15 | backend 16 | 17 | 20 18 | ben-21 19 | https://sonarcloud.io 20 | ben-21_capstone-neuefische-backend 21 | 22 | 23 | 24 | org.springframework.boot 25 | spring-boot-starter-data-mongodb 26 | 27 | 28 | org.springframework.boot 29 | spring-boot-starter-web 30 | 31 | 32 | org.projectlombok 33 | lombok 34 | true 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-test 39 | test 40 | 41 | 42 | de.flapdoodle.embed 43 | de.flapdoodle.embed.mongo.spring30x 44 | test 45 | 4.6.2 46 | 47 | 48 | org.springframework.boot 49 | spring-boot-starter-security 50 | 51 | 52 | org.bouncycastle 53 | bcprov-jdk15on 54 | 1.70 55 | 56 | 57 | org.springframework.security 58 | spring-security-test 59 | test 60 | 61 | 62 | org.springframework.boot 63 | spring-boot-starter-validation 64 | 65 | 66 | jakarta.validation 67 | jakarta.validation-api 68 | 3.0.2 69 | 70 | 71 | com.cloudinary 72 | cloudinary-http44 73 | 1.34.0 74 | 75 | 76 | 77 | capstone 78 | 79 | 80 | org.springframework.boot 81 | spring-boot-maven-plugin 82 | 83 | 84 | 85 | org.projectlombok 86 | lombok 87 | 88 | 89 | 90 | 91 | 92 | org.jacoco 93 | jacoco-maven-plugin 94 | 0.8.10 95 | 96 | 97 | jacoco-initialize 98 | 99 | prepare-agent 100 | 101 | 102 | 103 | jacoco-site 104 | package 105 | 106 | report 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /frontend/src/pages/AddDonationOrParticipation.tsx: -------------------------------------------------------------------------------- 1 | import {useFetch} from "../hooks/useFetch.tsx"; 2 | import React, {useEffect, useState} from "react"; 3 | import styled from "@emotion/styled"; 4 | import {useLocation, useNavigate, useParams} from "react-router-dom"; 5 | import CancelIcon from '@mui/icons-material/Cancel'; 6 | import {toast} from "react-toastify"; 7 | import {DonationCreation, Project, ParticipationCreation} from "../models/models.tsx"; 8 | import ProjectCard from "../components/ProjectCard.tsx"; 9 | import AttachMoneyIcon from "@mui/icons-material/AttachMoney"; 10 | import VolunteerActivismIcon from "@mui/icons-material/VolunteerActivism"; 11 | import {StyledBody, StyledButton, StyledTextField} from "../GlobalStyles.tsx"; 12 | 13 | export default function AddDonationOrParticipation() { 14 | 15 | const getProjectById = useFetch(state => state.getProjectById); 16 | const [project, setProject] = useState(undefined); 17 | const {id} = useParams(); 18 | const [amount, setAmount] = useState(""); 19 | const navigate = useNavigate(); 20 | const location = useLocation(); 21 | const checkPage = useFetch(state => state.checkPage); 22 | const [page, setPAge] = useState(""); 23 | const postDonation = useFetch(state => state.postDonation); 24 | const postParticipation = useFetch(state => state.postParticipation); 25 | 26 | useEffect(() => { 27 | if (id) { 28 | getProjectById(id) 29 | .then((project) => { 30 | setProject(project); 31 | }) 32 | .catch(error => { 33 | console.error(error); 34 | }); 35 | } else { 36 | toast.error("Something went wrong"); 37 | navigate("/"); 38 | } 39 | }, [id, navigate, getProjectById]); 40 | 41 | useEffect(() => { 42 | setPAge(checkPage(location.pathname)); 43 | }, [location, checkPage]); 44 | 45 | function handleChange(event: React.ChangeEvent) { 46 | setAmount(event.target.value); 47 | } 48 | 49 | function handleSubmit(event: React.FormEvent) { 50 | event.preventDefault(); 51 | if (project && page === "donate") { 52 | const donation: DonationCreation = { 53 | projectId: project.id, 54 | projectName: project.name, 55 | amount: amount 56 | } 57 | postDonation(project.id, donation) 58 | .then(() => { 59 | navigate(`/details/${project.id}`); 60 | }); 61 | } 62 | 63 | if (project && page === "participate") { 64 | const participation: ParticipationCreation = { 65 | projectId: project.id, 66 | projectName: project.name, 67 | } 68 | postParticipation(project.id, participation) 69 | .then(() => { 70 | navigate(`/details/${project.id}`); 71 | }); 72 | } 73 | } 74 | 75 | function handleCancelButton() { 76 | if (project) { 77 | navigate(`/details/${project.id}`) 78 | window.scrollTo(0, 0); 79 | } else { 80 | toast.error("Something went wrong"); 81 | navigate("/"); 82 | } 83 | } 84 | 85 | return ( 86 | 87 | {project && } 88 | 89 | 90 | {page === "donate" && 91 | } 95 | {page === "donate" && 96 | }>DONATE} 97 | {page === "participate" && 98 | }>PARTICIPATE} 100 | }>CANCEL 102 | 103 | 104 | ) 105 | } 106 | 107 | const StyledDonationParticipationForm = styled.form` 108 | display: flex; 109 | flex-direction: column; 110 | align-items: center; 111 | justify-content: center; 112 | gap: 1.1em; 113 | background-color: #EBE7D8; 114 | border-radius: 4px; 115 | padding: 20px 10px 10px 10px; 116 | margin-top: -30px; 117 | `; 118 | -------------------------------------------------------------------------------- /frontend/src/components/ProjectCard.tsx: -------------------------------------------------------------------------------- 1 | import Card from '@mui/material/Card'; 2 | import CardMedia from '@mui/material/CardMedia'; 3 | import {CardActionArea} from '@mui/material'; 4 | import styled from "@emotion/styled"; 5 | import {Project} from "../models/models.tsx"; 6 | import {useLocation, useNavigate} from "react-router-dom"; 7 | import {useEffect, useState} from "react"; 8 | import {useFetch} from "../hooks/useFetch.tsx"; 9 | import ProgressBar from "./ProgressBar.tsx"; 10 | import ProgressBarGalleryView from "./ProgressBarGalleryView.tsx"; 11 | import TopDonators from "./TopDonators.tsx"; 12 | 13 | 14 | type Props = { 15 | project: Project; 16 | } 17 | 18 | export default function ProjectCard(props: Props) { 19 | const navigate = useNavigate(); 20 | const [demandsUserFriendly, setDemandsUserFriendly] = useState([]); 21 | const checkPage = useFetch(state => state.checkPage); 22 | const location = useLocation(); 23 | const [page, setPage] = useState(""); 24 | const mapDemandsToUserFriendly = useFetch(state => state.mapDemandsToUserFriendly); 25 | 26 | useEffect(() => { 27 | setDemandsUserFriendly(mapDemandsToUserFriendly(props.project.demands)); 28 | }, [props.project, mapDemandsToUserFriendly]); 29 | 30 | useEffect(() => { 31 | setPage(checkPage(location.pathname)); 32 | }, [location, checkPage]); 33 | 34 | return ( 35 | navigate(`/details/${props.project.id}`)}> 36 | 37 | {props.project.image.url && 38 | } 44 | 45 | {props.project.name} 46 | 47 | {page === "details" && 48 | <> 49 | 50 | {props.project.description} 51 | 52 | 53 | 54 | {props.project.donations.map(donation => parseFloat(donation.amount)).reduce((a, b) => a + b, 0)} EUR 55 | 56 | {props.project.category === "DONATION" && 57 | 58 | of {props.project.goal} EUR collected 59 | 60 | } 61 | {props.project.category === "DONATION" && } 62 | 63 | {props.project.category === "PARTICIPATION" && 64 | <> 65 | 66 | {props.project.participations.length} Participations 67 | 68 | 69 | of {props.project.goal} needed 70 | 71 | {props.project.category === "PARTICIPATION" && } 72 | } 73 | 74 | Demands: 75 | 76 | 77 | {demandsUserFriendly.map((demand) => {demand})} 79 | 80 | } 81 | {page === "details" && } 82 | 83 | Location: 84 | 85 | 86 | {props.project.location} 87 | 88 | {(page === "/" || page === "filter") && } 89 | 90 | 91 | ); 92 | } 93 | 94 | const StyledCard = styled(Card)` 95 | width: 100%; 96 | background-color: #EBE7D8; 97 | margin: 0; 98 | padding: 0; 99 | border-radius: 4px; 100 | `; 101 | 102 | const StyledHeadLine = styled.h1` 103 | padding-left: 10px; 104 | margin-top: 10px; 105 | margin-bottom: 6px; 106 | `; 107 | 108 | const StyledSubHeadLine = styled.h2` 109 | padding-top: 20px; 110 | padding-left: 10px; 111 | margin: 0; 112 | font-family: "Robot", sans-serif; 113 | font-weight: 400; 114 | `; 115 | 116 | const StyledDonationParticipation = styled.h2` 117 | padding-top: 10px; 118 | margin: 0; 119 | display: flex; 120 | justify-content: center; 121 | font-family: "Robot", sans-serif; 122 | font-weight: 600; 123 | `; 124 | 125 | const StyledSubDonationParticipation = styled.div` 126 | padding: 0; 127 | margin: 0; 128 | display: flex; 129 | justify-content: center; 130 | font-family: "Robot", sans-serif; 131 | font-weight: 400; 132 | `; 133 | 134 | const StyledDescription = styled.div` 135 | padding: 0 10px 10px 10px; 136 | margin: 0; 137 | font-family: "Robot", sans-serif; 138 | font-weight: 300; 139 | font-size: 1em; 140 | `; 141 | 142 | const StyledDemandsWrapper = styled.div` 143 | display: flex; 144 | justify-content: space-between; 145 | `; 146 | 147 | const StyledDemands = styled.div` 148 | flex: 1; 149 | display: flex; 150 | align-items: center; 151 | justify-content: center; 152 | text-align: center; 153 | border: none; 154 | width: 96px; 155 | height: 44px; 156 | border-radius: 4px; 157 | background: var(--orange, #FFB34F); 158 | font-family: "Robot", sans-serif; 159 | font-weight: 500; 160 | font-size: 1em; 161 | margin: 10px; 162 | `; 163 | -------------------------------------------------------------------------------- /backend/src/main/java/de/neuefische/capstone/backend/ProjectService.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend; 2 | 3 | import de.neuefische.capstone.backend.models.*; 4 | import de.neuefische.capstone.backend.security.MongoUserService; 5 | import de.neuefische.capstone.backend.security.MongoUserWithoutPassword; 6 | import de.neuefische.capstone.backend.services.IdService; 7 | import org.springframework.security.core.context.SecurityContextHolder; 8 | import org.springframework.stereotype.Service; 9 | import org.springframework.web.server.MethodNotAllowedException; 10 | 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | import java.util.NoSuchElementException; 14 | 15 | @Service 16 | public class ProjectService { 17 | 18 | private final ProjectRepo projectRepo; 19 | private final IdService idService; 20 | private final ProjectCalculations projectCalculations; 21 | private final MongoUserService mongoUserService; 22 | 23 | public ProjectService(ProjectRepo projectRepo, IdService idService, ProjectCalculations projectCalculations, MongoUserService mongoUserService) { 24 | this.projectRepo = projectRepo; 25 | this.idService = idService; 26 | this.projectCalculations = projectCalculations; 27 | this.mongoUserService = mongoUserService; 28 | } 29 | 30 | private static final String NOT_FOUND_MESSAGE1 = "No project with "; 31 | private static final String NOT_FOUND_MESSAGE2 = " found"; 32 | 33 | public List getAllProjects() { 34 | return projectRepo.findAll(); 35 | } 36 | 37 | public Project addProject(ProjectCreation projectCreation) { 38 | 39 | String username = SecurityContextHolder.getContext().getAuthentication().getName(); 40 | MongoUserWithoutPassword user = mongoUserService.findByUsername(username); 41 | 42 | Project newProject = new Project( 43 | idService.createRandomId(), 44 | projectCreation.name(), 45 | projectCreation.description(), 46 | projectCreation.category(), 47 | projectCreation.demands(), 48 | 0, 49 | projectCreation.goal(), 50 | projectCreation.location(), 51 | new ArrayList<>(), 52 | new ArrayList<>(), 53 | user.id(), 54 | projectCreation.image() 55 | ); 56 | return projectRepo.insert(newProject); 57 | } 58 | 59 | public Project getProjectById(String projectId) { 60 | return projectRepo.findById(projectId).orElseThrow(() -> new NoSuchElementException(NOT_FOUND_MESSAGE1 + projectId + NOT_FOUND_MESSAGE2)); 61 | } 62 | 63 | public Project updateProject(String id, ProjectNoId projectNoId) { 64 | if (!projectRepo.existsById(id)) throw new NoSuchElementException(NOT_FOUND_MESSAGE1 + id + NOT_FOUND_MESSAGE2); 65 | 66 | String username = SecurityContextHolder.getContext().getAuthentication().getName(); 67 | MongoUserWithoutPassword user = mongoUserService.findByUsername(username); 68 | 69 | if (!user.id().equals(projectNoId.userId())) 70 | throw new MethodNotAllowedException("You are not allowed to edit this project", null); 71 | 72 | Project updatedProject = new Project( 73 | id, 74 | projectNoId.name(), 75 | projectNoId.description(), 76 | projectNoId.category(), 77 | projectNoId.demands(), 78 | projectNoId.progress(), 79 | projectNoId.goal(), 80 | projectNoId.location(), 81 | projectNoId.donations(), 82 | projectNoId.participations(), 83 | projectNoId.userId(), 84 | projectNoId.image()); 85 | 86 | return projectRepo.save(updatedProject); 87 | } 88 | 89 | public void deleteProject(String projectId) { 90 | if (!projectRepo.existsById(projectId)) throw new NoSuchElementException(NOT_FOUND_MESSAGE1 + projectId + NOT_FOUND_MESSAGE2); 91 | projectRepo.deleteById(projectId); 92 | } 93 | 94 | public Project addDonation(String projectId, DonationCreation donationCreation) { 95 | 96 | String username = SecurityContextHolder.getContext().getAuthentication().getName(); 97 | MongoUserWithoutPassword user = mongoUserService.findByUsername(username); 98 | 99 | Donation newDonation = new Donation( 100 | idService.createRandomId(), 101 | donationCreation.projectId(), 102 | donationCreation.projectName(), 103 | user.username(), 104 | donationCreation.amount(), 105 | user.id() 106 | ); 107 | 108 | user.donations().add(newDonation); 109 | mongoUserService.updateUser(user); 110 | 111 | Project project = projectRepo.findById(projectId).orElseThrow(() -> new NoSuchElementException(NOT_FOUND_MESSAGE1 + projectId + NOT_FOUND_MESSAGE2)); 112 | project.donations().add(newDonation); 113 | 114 | return projectRepo.save(projectCalculations.calculateProgressForDonations(project)); 115 | } 116 | 117 | public Project addParticipation(String projectId, ParticipationCreation participationCreation) { 118 | String username = SecurityContextHolder.getContext().getAuthentication().getName(); 119 | MongoUserWithoutPassword user = mongoUserService.findByUsername(username); 120 | 121 | Participation newParticipation = new Participation( 122 | idService.createRandomId(), 123 | participationCreation.projectId(), 124 | participationCreation.projectName(), 125 | user.username(), 126 | user.id() 127 | ); 128 | 129 | user.participations().add(newParticipation); 130 | mongoUserService.updateUser(user); 131 | 132 | Project project = projectRepo.findById(projectId).orElseThrow(() -> new NoSuchElementException(NOT_FOUND_MESSAGE1 + projectId + NOT_FOUND_MESSAGE2)); 133 | project.participations().add(newParticipation); 134 | 135 | return projectRepo.save(projectCalculations.calculateProgressForParticipations(project)); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Be Human - Capstone Project 2 |

3 | Be Human 4 |

5 | 6 | > "Be Human" is my Fullstack capstone project, developed during the neuefische Java Full Stack Bootcamp, showcasing skills in Java Backend Development and React / TypeScript Development by implementing crud operations. 7 | 8 | 9 | ## SonarCloud 10 | ### backend 11 | [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=ben-21_capstone-neuefische-backend&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=ben-21_capstone-neuefische-backend) 12 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ben-21_capstone-neuefische-backend&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=ben-21_capstone-neuefische-backend) 13 | [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=ben-21_capstone-neuefische-backend&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=ben-21_capstone-neuefische-backend) 14 | 15 | ### frontend 16 | [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=ben-21_capstone-neuefische-frontend&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=ben-21_capstone-neuefische-frontend) 17 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ben-21_capstone-neuefische-frontend&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=ben-21_capstone-neuefische-frontend) 18 | [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=ben-21_capstone-neuefische-frontend&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=ben-21_capstone-neuefische-frontend) 19 | 20 | 21 | ## Table of Contents 22 | 23 | - [CI / CD](#ci--cd) 24 | - [Overview](#overview) 25 | - [Wireframes](#wireframes) 26 | - [Models / Database-Structure](#models-and-database-structure) 27 | - [Tech Stack](#tech-stack) 28 | - [Features](#features) 29 | - [Usage](#usage) 30 | - [License](#license) 31 | 32 | ## CI / CD 33 | The project runs on my own cloud server and is developed using continous integration and contionous deployment. For this I set up a NGINX-Webserver and a Docker Environment. My App, the NGINX-Webserver and Certbot (for SSL Certificates) run as a container. All is configured and started using one Docker-Compose file. 34 | CI and CD is triggered by merging a branch into the Main-Branch. For both I used Github Actions (build with Maven and SonarCloud for CI - own yml. Files for CD to my DigitalOcean Cloudserver). 35 | 36 | ## Overview 37 | 38 | The deployed project can be found at [Be Human](https://be-human.schaefer-inet.de) 39 | The app is **only for mobile view** designed - please use your phone or switch your browser, via inspect/ dev-tools, to mobile view. 40 | 41 | Title: Be Human - A Humanitarian Project Platform 42 | 43 | Summary: 44 | "Be Human" is an innovative application developed using a comprehensive tech stack that includes Java, Spring Boot, REST, Maven, JUnit, React, TypeScript, MongoDB, and Docker. The platform serves as a hub for fostering participation in humanitarian initiatives. 45 | 46 | Key Features - CRUD-Operations: 47 | The app allows users to engage in existing humanitarian projects and create new ones. Projects are categorized into two main types: Donation Projects and Aid Projects. Users can contribute financially to both types and actively participate as volunteers in Aid Projects. Each project can define specific goals, such as donation targets or the number of volunteers needed. 48 | 49 | Motivation through Progress: 50 | The application calculates and visually presents project progress, both numerically and as a percentage, based on registered donations or volunteers and the predefined goal. This progress tracking serves as motivation for users to actively contribute. 51 | 52 | Project Filtering and Search: 53 | Users can easily navigate through projects using the filter function, which categorizes projects. Additionally, a comprehensive search function enables users to explore projects based on various attributes. 54 | 55 | User Management and Security: 56 | The backend incorporates Spring Security for robust user management. While projects are publicly viewable, contributing or participating requires user registration and login. Registered users can create and modify projects, with each user having only access to their own projects exclusively regarding changing content of a project. 57 | 58 | Insightful Analytics: 59 | The platform offers insightful analytics for both projects and users. For projects, users can view total donation amounts and the number of volunteers involved. The platform also features a unique privacy-focused approach for recognizing top donors, displaying avatars composed of initials instead of full usernames. 60 | 61 | Personalized User Insights: 62 | Users gain access to personalized insights, including lists of their own projects and projects they've participated in, segmented into Donation Projects and Aid Projects. Users can view cumulative donation amounts and their total contributions to projects, promoting a sense of accomplishment. 63 | 64 | In Conclusion: 65 | "Be Human" is a compelling application that leverages an array of cutting-edge technologies to facilitate engagement in humanitarian projects. The platform's user-friendly interface, robust security features, motivational progress tracking, and insightful analytics converge to encourage active participation in noble causes. Everyone shall be reminded of the importance of performing acts of kindness. 66 | 67 | ## Wireframes 68 | ![image](https://github.com/Ben-21/capstone-neuefische/assets/118177877/c976f272-1c18-4cd6-b213-9ea95f9c2c13) 69 | 70 | ## Models and Database Structure 71 | ![image](https://github.com/Ben-21/capstone-neuefische/assets/118177877/d9a1a9ae-9079-4deb-b80d-cb53c0ffa25c) 72 | 73 | 74 | 75 | 76 | 77 | ## Tech Stack 78 | 79 | - Java 80 | - Spring Boot 81 | - Maven 82 | - JUnit 83 | - TypeScript 84 | - React 85 | - MongoDB 86 | - REST 87 | - Docker 88 | 89 | ## Features 90 | 91 | - CRUD Operations (create, read, update, delete) 92 | - Objects stored in MongoDB 93 | - Project 94 | - User 95 | - Donation 96 | - Volunteer 97 | - Progress Calculation 98 | - Top Tier Calculation 99 | - Image Upload 100 | 101 | ## Usage 102 | 103 | 1. ATTENTION: App is only build for mobile use! Please turn on mobile view in your webbrowser. 104 | 2. Clone the repository: `git clone https://github.com/your-username/Be-Human.git` 105 | 3. Start backend and frontend inside your IDE (e.g. IntelliJ) 106 | 4. Define accesss to MongoDB via environment variable "MONGO_DB_URI" (e.g. mongodb://localhost:27017/capstoneDB) 107 | 108 | ## License 109 | 110 | Copyright [2023] [Ben-21] 111 | 112 | --- 113 | 114 | -------------------------------------------------------------------------------- /backend/mvnw.cmd: -------------------------------------------------------------------------------- 1 | @REM ---------------------------------------------------------------------------- 2 | @REM Licensed to the Apache Software Foundation (ASF) under one 3 | @REM or more contributor license agreements. See the NOTICE file 4 | @REM distributed with this work for additional information 5 | @REM regarding copyright ownership. The ASF licenses this file 6 | @REM to you under the Apache License, Version 2.0 (the 7 | @REM "License"); you may not use this file except in compliance 8 | @REM with the License. You may obtain a copy of the License at 9 | @REM 10 | @REM https://www.apache.org/licenses/LICENSE-2.0 11 | @REM 12 | @REM Unless required by applicable law or agreed to in writing, 13 | @REM software distributed under the License is distributed on an 14 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | @REM KIND, either express or implied. See the License for the 16 | @REM specific language governing permissions and limitations 17 | @REM under the License. 18 | @REM ---------------------------------------------------------------------------- 19 | 20 | @REM ---------------------------------------------------------------------------- 21 | @REM Apache Maven Wrapper startup batch script, version 3.2.0 22 | @REM 23 | @REM Required ENV vars: 24 | @REM JAVA_HOME - location of a JDK home dir 25 | @REM 26 | @REM Optional ENV vars 27 | @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands 28 | @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending 29 | @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven 30 | @REM e.g. to debug Maven itself, use 31 | @REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 32 | @REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files 33 | @REM ---------------------------------------------------------------------------- 34 | 35 | @REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' 36 | @echo off 37 | @REM set title of command window 38 | title %0 39 | @REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' 40 | @if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% 41 | 42 | @REM set %HOME% to equivalent of $HOME 43 | if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") 44 | 45 | @REM Execute a user defined script before this one 46 | if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre 47 | @REM check for pre script, once with legacy .bat ending and once with .cmd ending 48 | if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* 49 | if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* 50 | :skipRcPre 51 | 52 | @setlocal 53 | 54 | set ERROR_CODE=0 55 | 56 | @REM To isolate internal variables from possible post scripts, we use another setlocal 57 | @setlocal 58 | 59 | @REM ==== START VALIDATION ==== 60 | if not "%JAVA_HOME%" == "" goto OkJHome 61 | 62 | echo. 63 | echo Error: JAVA_HOME not found in your environment. >&2 64 | echo Please set the JAVA_HOME variable in your environment to match the >&2 65 | echo location of your Java installation. >&2 66 | echo. 67 | goto error 68 | 69 | :OkJHome 70 | if exist "%JAVA_HOME%\bin\java.exe" goto init 71 | 72 | echo. 73 | echo Error: JAVA_HOME is set to an invalid directory. >&2 74 | echo JAVA_HOME = "%JAVA_HOME%" >&2 75 | echo Please set the JAVA_HOME variable in your environment to match the >&2 76 | echo location of your Java installation. >&2 77 | echo. 78 | goto error 79 | 80 | @REM ==== END VALIDATION ==== 81 | 82 | :init 83 | 84 | @REM Find the project base dir, i.e. the directory that contains the folder ".mvn". 85 | @REM Fallback to current working directory if not found. 86 | 87 | set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% 88 | IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir 89 | 90 | set EXEC_DIR=%CD% 91 | set WDIR=%EXEC_DIR% 92 | :findBaseDir 93 | IF EXIST "%WDIR%"\.mvn goto baseDirFound 94 | cd .. 95 | IF "%WDIR%"=="%CD%" goto baseDirNotFound 96 | set WDIR=%CD% 97 | goto findBaseDir 98 | 99 | :baseDirFound 100 | set MAVEN_PROJECTBASEDIR=%WDIR% 101 | cd "%EXEC_DIR%" 102 | goto endDetectBaseDir 103 | 104 | :baseDirNotFound 105 | set MAVEN_PROJECTBASEDIR=%EXEC_DIR% 106 | cd "%EXEC_DIR%" 107 | 108 | :endDetectBaseDir 109 | 110 | IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig 111 | 112 | @setlocal EnableExtensions EnableDelayedExpansion 113 | for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a 114 | @endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% 115 | 116 | :endReadAdditionalConfig 117 | 118 | SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" 119 | set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" 120 | set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 121 | 122 | set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 123 | 124 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 125 | IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B 126 | ) 127 | 128 | @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 129 | @REM This allows using the maven wrapper in projects that prohibit checking in binary data. 130 | if exist %WRAPPER_JAR% ( 131 | if "%MVNW_VERBOSE%" == "true" ( 132 | echo Found %WRAPPER_JAR% 133 | ) 134 | ) else ( 135 | if not "%MVNW_REPOURL%" == "" ( 136 | SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 137 | ) 138 | if "%MVNW_VERBOSE%" == "true" ( 139 | echo Couldn't find %WRAPPER_JAR%, downloading it ... 140 | echo Downloading from: %WRAPPER_URL% 141 | ) 142 | 143 | powershell -Command "&{"^ 144 | "$webclient = new-object System.Net.WebClient;"^ 145 | "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ 146 | "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ 147 | "}"^ 148 | "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ 149 | "}" 150 | if "%MVNW_VERBOSE%" == "true" ( 151 | echo Finished downloading %WRAPPER_JAR% 152 | ) 153 | ) 154 | @REM End of extension 155 | 156 | @REM If specified, validate the SHA-256 sum of the Maven wrapper jar file 157 | SET WRAPPER_SHA_256_SUM="" 158 | FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( 159 | IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B 160 | ) 161 | IF NOT %WRAPPER_SHA_256_SUM%=="" ( 162 | powershell -Command "&{"^ 163 | "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ 164 | "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ 165 | " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ 166 | " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ 167 | " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ 168 | " exit 1;"^ 169 | "}"^ 170 | "}" 171 | if ERRORLEVEL 1 goto error 172 | ) 173 | 174 | @REM Provide a "standardized" way to retrieve the CLI args that will 175 | @REM work with both Windows and non-Windows executions. 176 | set MAVEN_CMD_LINE_ARGS=%* 177 | 178 | %MAVEN_JAVA_EXE% ^ 179 | %JVM_CONFIG_MAVEN_PROPS% ^ 180 | %MAVEN_OPTS% ^ 181 | %MAVEN_DEBUG_OPTS% ^ 182 | -classpath %WRAPPER_JAR% ^ 183 | "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ 184 | %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* 185 | if ERRORLEVEL 1 goto error 186 | goto end 187 | 188 | :error 189 | set ERROR_CODE=1 190 | 191 | :end 192 | @endlocal & set ERROR_CODE=%ERROR_CODE% 193 | 194 | if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost 195 | @REM check for post script, once with legacy .bat ending and once with .cmd ending 196 | if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" 197 | if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" 198 | :skipRcPost 199 | 200 | @REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' 201 | if "%MAVEN_BATCH_PAUSE%"=="on" pause 202 | 203 | if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% 204 | 205 | cmd /C exit /B %ERROR_CODE% 206 | -------------------------------------------------------------------------------- /frontend/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/src/hooks/useFetch.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Demand, 3 | 4 | DonationCreation, 5 | Image, 6 | Project, 7 | ProjectCreation, 8 | User, 9 | ParticipationCreation 10 | } from "../models/models.tsx"; 11 | import {create} from "zustand"; 12 | import axios from "axios"; 13 | import {toast} from 'react-toastify'; 14 | import {NavigateFunction} from "react-router-dom"; 15 | 16 | type State = { 17 | projects: Project[], 18 | fetchProjects: () => void, 19 | postProject: (requestBody: ProjectCreation) => Promise, 20 | getProjectById: (id: string) => Promise, 21 | putProject: (requestBody: Project) => Promise, 22 | deleteProject: (id: string) => void, 23 | isLoading: boolean, 24 | checkPage: (page: string) => string, 25 | page: string, 26 | mapDemandsToUserFriendly: (demands: Demand[]) => string[], 27 | mapDemandsToEnum: (string: string[]) => Demand[], 28 | postDonation: (projectId: string, donationCreation: DonationCreation) => Promise, 29 | postParticipation: (projectId: string, participationCreation: ParticipationCreation) => Promise, 30 | userName: string, 31 | login: (username: string, password: string, navigate: NavigateFunction) => void, 32 | me: () => void, 33 | register: (username: string, 34 | password: string, 35 | repeatedPassword: string, 36 | setPassword: (password: string) => void, 37 | setRepeatedPassword: (repeatedPassword: string) => void, 38 | navigate: NavigateFunction) 39 | => void, 40 | user: User, 41 | meObject: () => void, 42 | addImage: (data: FormData) => Promise, 43 | addedImage: Image, 44 | setAddedImage: (image: Image) => void, 45 | resetAddedImage: () => void, 46 | }; 47 | 48 | export const useFetch = create((set, get) => ({ 49 | projects: [], 50 | isLoading: true, 51 | page: "", 52 | userName: "", 53 | user: 54 | { 55 | id: "", 56 | username: "", 57 | donations: [], 58 | participations: [] 59 | }, 60 | addedImage: { 61 | id: "", 62 | name: "", 63 | url: "" 64 | }, 65 | 66 | fetchProjects: () => { 67 | set({isLoading: true}) 68 | axios 69 | .get("/api/projects") 70 | .then((response) => response.data) 71 | .then((data) => { 72 | set({projects: data}) 73 | }) 74 | .catch(console.error) 75 | .then(() => set({isLoading: false})) 76 | }, 77 | 78 | postProject: (requestBody: ProjectCreation) => { 79 | return axios 80 | .post("/api/projects", requestBody) 81 | .then(response => { 82 | set({projects: [...get().projects, response.data]}) 83 | }) 84 | .then(() => toast.success("Project successfully added")) 85 | .catch((error) => { 86 | toast.error("Something went wrong"); 87 | console.error(error); 88 | }) 89 | }, 90 | 91 | getProjectById: (id: string) => { 92 | if (!id) { 93 | throw new Error("Id is undefined") 94 | } 95 | return axios 96 | .get(`/api/projects/${id}`) 97 | .then(response => response.data) 98 | .catch(error => { 99 | toast.error("Something went wrong"); 100 | console.error(error); 101 | }) 102 | }, 103 | 104 | putProject: (requestBody: Project) => { 105 | const {id, ...projectNoId} = requestBody; 106 | return axios 107 | .put(`/api/projects/${id}`, projectNoId) 108 | .then(response => { 109 | set({projects: get().projects.map(project => project.id === id ? response.data : project)}) 110 | }) 111 | .then(() => toast.success("Project successfully updated")) 112 | .catch((error) => { 113 | toast.error("Something went wrong"); 114 | console.error(error); 115 | }) 116 | }, 117 | 118 | deleteProject: (id: string) => { 119 | const {fetchProjects} = get(); 120 | axios 121 | .delete(`/api/projects/${id}`) 122 | .then(fetchProjects) 123 | .then(() => toast.success("Project successfully deleted")) 124 | .catch((error) => { 125 | toast.error("Something went wrong"); 126 | console.error(error); 127 | }) 128 | }, 129 | 130 | checkPage: (path) => { 131 | const queryParams = new URLSearchParams(window.location.search); 132 | const filterQueryParam = queryParams.get('filter'); 133 | const pathSegments = path.split("/"); 134 | 135 | if (pathSegments[1] === "filter") { 136 | if (filterQueryParam === "all") { 137 | set({page: "filter-all"}); 138 | } else if (filterQueryParam === "DONATION") { 139 | set({page: "filter-donation"}); 140 | } else if (filterQueryParam === "PARTICIPATION") { 141 | set({page: "filter-participation"}); 142 | } else { 143 | set({page: "filter"}); 144 | } 145 | } else { 146 | const pages: { [key: string]: string } = { 147 | "": "/", 148 | details: "details", 149 | edit: "edit", 150 | add: "add", 151 | donate: "donate", 152 | participate: "participate", 153 | login: "login", 154 | register: "register", 155 | profile: "profile", 156 | search: "search" 157 | }; 158 | const page = pathSegments[1]; 159 | set({page: pages[page] || page}); 160 | } 161 | return get().page; 162 | }, 163 | 164 | mapDemandsToUserFriendly: (demands: Demand[]) => { 165 | const finalDemands: string[] = []; 166 | demands.forEach(demand => { 167 | switch (demand) { 168 | case "MONEYDONATION": 169 | finalDemands.push("Money Donation") 170 | break; 171 | case "DONATIONINKIND": 172 | finalDemands.push("Donation in Kind") 173 | break; 174 | case "FOODDONATION": 175 | finalDemands.push("Food Donation") 176 | break; 177 | case "DRUGDONATION": 178 | finalDemands.push("Drug Donation") 179 | break; 180 | } 181 | }); 182 | return finalDemands; 183 | }, 184 | 185 | mapDemandsToEnum: (string: string[]) => { 186 | const finalDemands: Demand[] = []; 187 | string.forEach(demand => { 188 | switch (demand) { 189 | case "Money Donation": 190 | finalDemands.push("MONEYDONATION") 191 | break; 192 | case "Donation in Kind": 193 | finalDemands.push("DONATIONINKIND") 194 | break; 195 | case "Food Donation": 196 | finalDemands.push("FOODDONATION") 197 | break; 198 | case "Drug Donation": 199 | finalDemands.push("DRUGDONATION") 200 | break; 201 | } 202 | }); 203 | return finalDemands; 204 | }, 205 | 206 | postDonation: (projectId: string, requestBody: DonationCreation) => { 207 | const {fetchProjects} = get(); 208 | return axios.post(`/api/projects/donate/${projectId}`, requestBody) 209 | .then(fetchProjects) 210 | .then(() => toast.success("Donation successfully added")) 211 | .catch((error) => { 212 | toast.error("Something went wrong"); 213 | console.error(error); 214 | }) 215 | }, 216 | 217 | postParticipation: (projectId: string, requestBody: ParticipationCreation) => { 218 | const {fetchProjects} = get(); 219 | return axios.post(`/api/projects/participate/${projectId}`, requestBody) 220 | .then(fetchProjects) 221 | .then(() => toast.success("Participation successfully added")) 222 | .catch((error) => { 223 | toast.error("Something went wrong"); 224 | console.error(error); 225 | }) 226 | }, 227 | 228 | login: (username: string, password: string, navigate: NavigateFunction) => { 229 | axios.post("/api/users/login", null, { 230 | auth: { 231 | username: username, 232 | password: password 233 | } 234 | }) 235 | .then(response => { 236 | set({userName: response.data.username}) 237 | navigate("/") 238 | }) 239 | .then(() => toast.success("Login successful")) 240 | .catch((error) => { 241 | toast.error("You are not registered. Please register first"); 242 | console.error(error); 243 | }); 244 | }, 245 | 246 | me: () => { 247 | axios.get("/api/users/me") 248 | .then(response => set({userName: response.data})) 249 | }, 250 | 251 | meObject: () => { 252 | axios.get("/api/users/me-object") 253 | .then(response => set({user: response.data})) 254 | 255 | }, 256 | 257 | register: (userName: string, password: string, repeatedPassword: string, setPassword: (password: string) => void, setRepeatedPassword: (repeatedPassword: string) => void, navigate: NavigateFunction) => { 258 | const newUserData = { 259 | "username": `${userName}`, 260 | "password": `${password}` 261 | } 262 | 263 | if (password === repeatedPassword) { 264 | 265 | axios.post("/api/users/register", newUserData) 266 | .then(response => { 267 | console.log(response); 268 | navigate("/login"); 269 | }) 270 | .then(() => toast.success("Registration successful")) 271 | .catch((error) => { 272 | console.error(error); 273 | if (error.response.data.errors) { 274 | toast.error(error.response.data.errors[0].defaultMessage); 275 | } else { 276 | toast.error(error.response.data.message); 277 | } 278 | }) 279 | } else { 280 | toast.error("Passwords do not match"); 281 | setPassword(""); 282 | setRepeatedPassword(""); 283 | } 284 | }, 285 | 286 | addImage: (data: FormData) => { 287 | set({addedImage: {id: "", name: "", url: ""}}) 288 | return axios 289 | .post('/api/upload', data, { 290 | headers: { 291 | "Content-Type": "multipart/form-data", 292 | }, 293 | }).then(response => { 294 | set({addedImage: response.data}) 295 | 296 | toast.success(`Image ${get().addedImage.name} successfully added`); 297 | }) 298 | .catch((error) => { 299 | console.log(error) 300 | toast.error('Error adding ImageProfile' + error.response?.statusText); 301 | }); 302 | }, 303 | 304 | setAddedImage: (image: Image) => { 305 | set({addedImage: image}) 306 | }, 307 | 308 | resetAddedImage: () => { 309 | const resetImage: Image = { 310 | id: "", 311 | name: "", 312 | url: "" 313 | } 314 | set({addedImage: resetImage}) 315 | }, 316 | })); 317 | -------------------------------------------------------------------------------- /backend/mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # https://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.2.0 23 | # 24 | # Required ENV vars: 25 | # ------------------ 26 | # JAVA_HOME - location of a JDK home dir 27 | # 28 | # Optional ENV vars 29 | # ----------------- 30 | # MAVEN_OPTS - parameters passed to the Java VM when running Maven 31 | # e.g. to debug Maven itself, use 32 | # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 33 | # MAVEN_SKIP_RC - flag to disable loading of mavenrc files 34 | # ---------------------------------------------------------------------------- 35 | 36 | if [ -z "$MAVEN_SKIP_RC" ] ; then 37 | 38 | if [ -f /usr/local/etc/mavenrc ] ; then 39 | . /usr/local/etc/mavenrc 40 | fi 41 | 42 | if [ -f /etc/mavenrc ] ; then 43 | . /etc/mavenrc 44 | fi 45 | 46 | if [ -f "$HOME/.mavenrc" ] ; then 47 | . "$HOME/.mavenrc" 48 | fi 49 | 50 | fi 51 | 52 | # OS specific support. $var _must_ be set to either true or false. 53 | cygwin=false; 54 | darwin=false; 55 | mingw=false 56 | case "$(uname)" in 57 | CYGWIN*) cygwin=true ;; 58 | MINGW*) mingw=true;; 59 | Darwin*) darwin=true 60 | # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home 61 | # See https://developer.apple.com/library/mac/qa/qa1170/_index.html 62 | if [ -z "$JAVA_HOME" ]; then 63 | if [ -x "/usr/libexec/java_home" ]; then 64 | JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME 65 | else 66 | JAVA_HOME="/Library/Java/Home"; export JAVA_HOME 67 | fi 68 | fi 69 | ;; 70 | esac 71 | 72 | if [ -z "$JAVA_HOME" ] ; then 73 | if [ -r /etc/gentoo-release ] ; then 74 | JAVA_HOME=$(java-config --jre-home) 75 | fi 76 | fi 77 | 78 | # For Cygwin, ensure paths are in UNIX format before anything is touched 79 | if $cygwin ; then 80 | [ -n "$JAVA_HOME" ] && 81 | JAVA_HOME=$(cygpath --unix "$JAVA_HOME") 82 | [ -n "$CLASSPATH" ] && 83 | CLASSPATH=$(cygpath --path --unix "$CLASSPATH") 84 | fi 85 | 86 | # For Mingw, ensure paths are in UNIX format before anything is touched 87 | if $mingw ; then 88 | [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && 89 | JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" 90 | fi 91 | 92 | if [ -z "$JAVA_HOME" ]; then 93 | javaExecutable="$(which javac)" 94 | if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then 95 | # readlink(1) is not available as standard on Solaris 10. 96 | readLink=$(which readlink) 97 | if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then 98 | if $darwin ; then 99 | javaHome="$(dirname "\"$javaExecutable\"")" 100 | javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" 101 | else 102 | javaExecutable="$(readlink -f "\"$javaExecutable\"")" 103 | fi 104 | javaHome="$(dirname "\"$javaExecutable\"")" 105 | javaHome=$(expr "$javaHome" : '\(.*\)/bin') 106 | JAVA_HOME="$javaHome" 107 | export JAVA_HOME 108 | fi 109 | fi 110 | fi 111 | 112 | if [ -z "$JAVACMD" ] ; then 113 | if [ -n "$JAVA_HOME" ] ; then 114 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 115 | # IBM's JDK on AIX uses strange locations for the executables 116 | JAVACMD="$JAVA_HOME/jre/sh/java" 117 | else 118 | JAVACMD="$JAVA_HOME/bin/java" 119 | fi 120 | else 121 | JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" 122 | fi 123 | fi 124 | 125 | if [ ! -x "$JAVACMD" ] ; then 126 | echo "Error: JAVA_HOME is not defined correctly." >&2 127 | echo " We cannot execute $JAVACMD" >&2 128 | exit 1 129 | fi 130 | 131 | if [ -z "$JAVA_HOME" ] ; then 132 | echo "Warning: JAVA_HOME environment variable is not set." 133 | fi 134 | 135 | # traverses directory structure from process work directory to filesystem root 136 | # first directory with .mvn subdirectory is considered project base directory 137 | find_maven_basedir() { 138 | if [ -z "$1" ] 139 | then 140 | echo "Path not specified to find_maven_basedir" 141 | return 1 142 | fi 143 | 144 | basedir="$1" 145 | wdir="$1" 146 | while [ "$wdir" != '/' ] ; do 147 | if [ -d "$wdir"/.mvn ] ; then 148 | basedir=$wdir 149 | break 150 | fi 151 | # workaround for JBEAP-8937 (on Solaris 10/Sparc) 152 | if [ -d "${wdir}" ]; then 153 | wdir=$(cd "$wdir/.." || exit 1; pwd) 154 | fi 155 | # end of workaround 156 | done 157 | printf '%s' "$(cd "$basedir" || exit 1; pwd)" 158 | } 159 | 160 | # concatenates all lines of a file 161 | concat_lines() { 162 | if [ -f "$1" ]; then 163 | # Remove \r in case we run on Windows within Git Bash 164 | # and check out the repository with auto CRLF management 165 | # enabled. Otherwise, we may read lines that are delimited with 166 | # \r\n and produce $'-Xarg\r' rather than -Xarg due to word 167 | # splitting rules. 168 | tr -s '\r\n' ' ' < "$1" 169 | fi 170 | } 171 | 172 | log() { 173 | if [ "$MVNW_VERBOSE" = true ]; then 174 | printf '%s\n' "$1" 175 | fi 176 | } 177 | 178 | BASE_DIR=$(find_maven_basedir "$(dirname "$0")") 179 | if [ -z "$BASE_DIR" ]; then 180 | exit 1; 181 | fi 182 | 183 | MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR 184 | log "$MAVEN_PROJECTBASEDIR" 185 | 186 | ########################################################################################## 187 | # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central 188 | # This allows using the maven wrapper in projects that prohibit checking in binary data. 189 | ########################################################################################## 190 | wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" 191 | if [ -r "$wrapperJarPath" ]; then 192 | log "Found $wrapperJarPath" 193 | else 194 | log "Couldn't find $wrapperJarPath, downloading it ..." 195 | 196 | if [ -n "$MVNW_REPOURL" ]; then 197 | wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 198 | else 199 | wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" 200 | fi 201 | while IFS="=" read -r key value; do 202 | # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) 203 | safeValue=$(echo "$value" | tr -d '\r') 204 | case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; 205 | esac 206 | done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" 207 | log "Downloading from: $wrapperUrl" 208 | 209 | if $cygwin; then 210 | wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") 211 | fi 212 | 213 | if command -v wget > /dev/null; then 214 | log "Found wget ... using wget" 215 | [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" 216 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 217 | wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 218 | else 219 | wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" 220 | fi 221 | elif command -v curl > /dev/null; then 222 | log "Found curl ... using curl" 223 | [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" 224 | if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then 225 | curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" 226 | else 227 | curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" 228 | fi 229 | else 230 | log "Falling back to using Java to download" 231 | javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" 232 | javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" 233 | # For Cygwin, switch paths to Windows format before running javac 234 | if $cygwin; then 235 | javaSource=$(cygpath --path --windows "$javaSource") 236 | javaClass=$(cygpath --path --windows "$javaClass") 237 | fi 238 | if [ -e "$javaSource" ]; then 239 | if [ ! -e "$javaClass" ]; then 240 | log " - Compiling MavenWrapperDownloader.java ..." 241 | ("$JAVA_HOME/bin/javac" "$javaSource") 242 | fi 243 | if [ -e "$javaClass" ]; then 244 | log " - Running MavenWrapperDownloader.java ..." 245 | ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" 246 | fi 247 | fi 248 | fi 249 | fi 250 | ########################################################################################## 251 | # End of extension 252 | ########################################################################################## 253 | 254 | # If specified, validate the SHA-256 sum of the Maven wrapper jar file 255 | wrapperSha256Sum="" 256 | while IFS="=" read -r key value; do 257 | case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; 258 | esac 259 | done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" 260 | if [ -n "$wrapperSha256Sum" ]; then 261 | wrapperSha256Result=false 262 | if command -v sha256sum > /dev/null; then 263 | if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then 264 | wrapperSha256Result=true 265 | fi 266 | elif command -v shasum > /dev/null; then 267 | if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then 268 | wrapperSha256Result=true 269 | fi 270 | else 271 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." 272 | echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." 273 | exit 1 274 | fi 275 | if [ $wrapperSha256Result = false ]; then 276 | echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 277 | echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 278 | echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 279 | exit 1 280 | fi 281 | fi 282 | 283 | MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" 284 | 285 | # For Cygwin, switch paths to Windows format before running java 286 | if $cygwin; then 287 | [ -n "$JAVA_HOME" ] && 288 | JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") 289 | [ -n "$CLASSPATH" ] && 290 | CLASSPATH=$(cygpath --path --windows "$CLASSPATH") 291 | [ -n "$MAVEN_PROJECTBASEDIR" ] && 292 | MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") 293 | fi 294 | 295 | # Provide a "standardized" way to retrieve the CLI args that will 296 | # work with both Windows and non-Windows executions. 297 | MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" 298 | export MAVEN_CMD_LINE_ARGS 299 | 300 | WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain 301 | 302 | # shellcheck disable=SC2086 # safe args 303 | exec "$JAVACMD" \ 304 | $MAVEN_OPTS \ 305 | $MAVEN_DEBUG_OPTS \ 306 | -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ 307 | "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ 308 | ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" 309 | -------------------------------------------------------------------------------- /frontend/src/pages/AddEditProject.tsx: -------------------------------------------------------------------------------- 1 | import {useFetch} from "../hooks/useFetch.tsx"; 2 | import React, {ChangeEvent, useEffect, useState} from "react"; 3 | import {ImageCreation, Project, ProjectCreation} from "../models/models.tsx"; 4 | import { 5 | Box, 6 | Chip, 7 | Input, 8 | InputLabel, 9 | MenuItem, 10 | OutlinedInput, 11 | Select, 12 | SelectChangeEvent, 13 | Theme, 14 | useTheme 15 | } from "@mui/material"; 16 | import {useNavigate, useParams} from "react-router-dom"; 17 | import SaveIcon from '@mui/icons-material/Save'; 18 | import CancelIcon from '@mui/icons-material/Cancel'; 19 | import {toast} from "react-toastify"; 20 | import CardMedia from "@mui/material/CardMedia"; 21 | import { 22 | StyledBody, 23 | StyledButton, StyledChipFormControl, 24 | StyledForm, 25 | StyledTextField, 26 | StyledToggleButton, 27 | StyledToggleGroup 28 | } from "../GlobalStyles.tsx"; 29 | import styled from "@emotion/styled"; 30 | 31 | export default function AddEditProject() { 32 | 33 | const navigate = useNavigate(); 34 | const {id} = useParams(); 35 | const getProjectById = useFetch(state => state.getProjectById); 36 | const putProject = useFetch(state => state.putProject); 37 | const postProject = useFetch(state => state.postProject); 38 | const [project, setProject] = useState(undefined); 39 | const [formData, setFormData] = useState({ 40 | name: "", 41 | description: "", 42 | location: "", 43 | goal: "", 44 | }); 45 | const [category, setCategory] = useState<"DONATION" | "PARTICIPATION">("DONATION"); 46 | const chipTheme = useTheme(); 47 | const [selectedDemands, setSelectedDemands] = useState([]); 48 | const possibleDemands = [ 49 | "Money Donation", 50 | "Donation in Kind", 51 | "Food Donation", 52 | "Drug Donation" 53 | ]; 54 | const mapDemandsToUserFriendly = useFetch(state => state.mapDemandsToUserFriendly); 55 | const mapDemandsToEnum = useFetch(state => state.mapDemandsToEnum); 56 | const [imageName, setImageName] = useState(""); 57 | const [image, setImage] = useState(); 58 | const addImage = useFetch(state => state.addImage); 59 | const addedImage = useFetch(state => state.addedImage); 60 | const [showImage, setShowImage] = useState(false); 61 | const resetAddedImage = useFetch(state => state.resetAddedImage); 62 | const setAddedImage = useFetch(state => state.setAddedImage); 63 | 64 | useEffect(() => { 65 | if (id) { 66 | getProjectById(id) 67 | .then((project) => { 68 | setProject(project); 69 | }) 70 | } 71 | }, [id, getProjectById]); 72 | 73 | useEffect(() => { 74 | if (project) { 75 | setFormData({ 76 | name: project.name.toString(), 77 | description: project.description.toString(), 78 | location: project.location.toString(), 79 | goal: project.goal.toString(), 80 | }) 81 | setSelectedDemands(mapDemandsToUserFriendly(project.demands)); 82 | setCategory(project.category); 83 | setAddedImage(project.image); 84 | } 85 | }, [setAddedImage, id, project, mapDemandsToUserFriendly]) 86 | 87 | function initialiseAllFields() { 88 | setFormData({ 89 | name: "", 90 | description: "", 91 | location: "", 92 | goal: "", 93 | }) 94 | setSelectedDemands([]); 95 | setCategory("DONATION"); 96 | setShowImage(false); 97 | resetAddedImage(); 98 | } 99 | 100 | function handleSubmit(event: React.FormEvent) { 101 | event.preventDefault(); 102 | 103 | if (!project) { 104 | const requestBody: ProjectCreation = { 105 | name: formData.name, 106 | description: formData.description, 107 | category: category, 108 | demands: mapDemandsToEnum(selectedDemands), 109 | location: formData.location, 110 | goal: formData.goal, 111 | image: addedImage, 112 | }; 113 | postProject(requestBody) 114 | .then(() => { 115 | initialiseAllFields(); 116 | }); 117 | } 118 | 119 | if (project) { 120 | const requestBody: Project = { 121 | id: project.id, 122 | name: formData.name, 123 | description: formData.description, 124 | category: category, 125 | demands: mapDemandsToEnum(selectedDemands), 126 | progress: project.progress, 127 | goal: formData.goal, 128 | location: formData.location, 129 | donations: project.donations, 130 | participations: project.participations, 131 | userId: project.userId, 132 | image: addedImage, 133 | }; 134 | putProject(requestBody) 135 | .then(() => { 136 | initialiseAllFields(); 137 | navigate(`/details/${project.id}`) 138 | }); 139 | } 140 | } 141 | 142 | function handleChange(event: React.ChangeEvent) { 143 | const {name, value} = event.target; 144 | setFormData((prevFormData) => ({ 145 | ...prevFormData, 146 | [name]: value, 147 | })); 148 | } 149 | 150 | function handleCategoryChange(_: React.MouseEvent, newCategory: "DONATION" | "PARTICIPATION") { 151 | setCategory(newCategory) 152 | } 153 | 154 | function handleCancelButton() { 155 | if (project) { 156 | initialiseAllFields(); 157 | navigate(`/details/${project.id}`) 158 | } else { 159 | initialiseAllFields(); 160 | navigate("/") 161 | } 162 | window.scrollTo(0, 0); 163 | } 164 | 165 | function getStyles(name: string, personName: readonly string[], theme: Theme) { 166 | return { 167 | fontWeight: 168 | personName.indexOf(name) === -1 169 | ? theme.typography.fontWeightRegular 170 | : theme.typography.fontWeightMedium, 171 | }; 172 | } 173 | 174 | const handleChipChange = (event: SelectChangeEvent) => { 175 | const { 176 | target: {value}, 177 | } = event; 178 | setSelectedDemands( 179 | typeof value === 'string' ? value.split(',') : value, 180 | ); 181 | }; 182 | 183 | function handleImageNameChange(event: ChangeEvent) { 184 | setImageName(event.target.value); 185 | } 186 | 187 | function handleImageInput(event: ChangeEvent) { 188 | if (event.target.files) { 189 | setImage(event.target.files[0]) 190 | } 191 | } 192 | 193 | function handleImageSubmit() { 194 | if (imageName.trim() === "") { 195 | toast.error("Please enter a name for the image") 196 | return 197 | } 198 | if (!image) { 199 | toast.error("Please select an image") 200 | return 201 | } 202 | 203 | const data = new FormData() 204 | const ImageCreation: ImageCreation = { 205 | name: imageName 206 | } 207 | 208 | if (image) { 209 | data.append("file", image) 210 | } 211 | 212 | data.append("data", new Blob([JSON.stringify(ImageCreation)], {type: "application/json"})) 213 | addImage(data) 214 | .then(() => { 215 | setImageName("") 216 | setImage(undefined) 217 | setShowImage(true) 218 | }); 219 | } 220 | 221 | return ( 222 | 223 | 224 | 227 | 232 | 236 | 239 | Donation 240 | Participation 241 | 242 | 245 | 246 | Demands 247 | 271 | 272 | 276 | 277 | }>IMAGE 278 | UPLOAD 279 | {showImage && 280 | <> 281 | IMAGE PREVIEW 282 | 288 | } 289 | }>SAVE PROJECT 290 | }>CANCEL 292 | 293 | 294 | ) 295 | } 296 | 297 | const ITEM_HEIGHT = 48; 298 | const ITEM_PADDING_TOP = 8; 299 | const MenuProps = { 300 | PaperProps: { 301 | style: { 302 | maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, 303 | width: 250, 304 | backgroundColor: '#EBE7D8', 305 | }, 306 | }, 307 | }; 308 | 309 | const StyledHeadLine = styled.h3` 310 | padding: 0; 311 | margin: 0; 312 | font-family: "Robot", sans-serif; 313 | font-weight: 400; 314 | color: #163E56; 315 | `; 316 | -------------------------------------------------------------------------------- /backend/src/test/java/de/neuefische/capstone/backend/ProjectServiceTest.java: -------------------------------------------------------------------------------- 1 | package de.neuefische.capstone.backend; 2 | 3 | import de.neuefische.capstone.backend.models.*; 4 | import de.neuefische.capstone.backend.security.MongoUserService; 5 | import de.neuefische.capstone.backend.security.MongoUserWithoutPassword; 6 | import de.neuefische.capstone.backend.services.IdService; 7 | import org.junit.jupiter.api.Test; 8 | import org.mockito.Mock; 9 | import org.springframework.security.core.Authentication; 10 | import org.springframework.security.core.context.SecurityContext; 11 | import org.springframework.security.core.context.SecurityContextHolder; 12 | 13 | import java.math.BigDecimal; 14 | import java.util.ArrayList; 15 | import java.util.List; 16 | import java.util.NoSuchElementException; 17 | import java.util.Optional; 18 | 19 | import static org.junit.jupiter.api.Assertions.assertEquals; 20 | import static org.junit.jupiter.api.Assertions.assertThrows; 21 | import static org.mockito.Mockito.*; 22 | 23 | 24 | class ProjectServiceTest { 25 | 26 | ProjectRepo projectRepo = mock(ProjectRepo.class); 27 | IdService idService = mock(IdService.class); 28 | ProjectCalculations projectCalculations = mock(ProjectCalculations.class); 29 | MongoUserService mongoUserService = mock(MongoUserService.class); 30 | ProjectService projectService = new ProjectService(projectRepo, idService, projectCalculations, mongoUserService); 31 | SecurityContext securityContext = mock(SecurityContext.class); 32 | @Mock 33 | Authentication authentication = mock(Authentication.class); 34 | 35 | 36 | @Test 37 | void whenProjectAdded_thenReturnId() { 38 | //Given 39 | String expectedId = "01A"; 40 | 41 | //When 42 | when(idService.createRandomId()) 43 | .thenReturn("01A"); 44 | String actualId = idService.createRandomId(); 45 | 46 | //Then 47 | verify(idService).createRandomId(); 48 | assertEquals(expectedId, actualId); 49 | } 50 | 51 | @Test 52 | void whenProjectAdded_thenReturnAddedProject() { 53 | //Given 54 | List listOfDemands = new ArrayList<>(List.of(Demand.DONATIONINKIND)); 55 | 56 | Project projectWithId = new Project( 57 | "01A", 58 | "Earthquake Turkey", 59 | "Help for the people in Turkey", 60 | Category.PARTICIPATION, 61 | listOfDemands, 62 | 0, 63 | 0, 64 | "Turkey", 65 | new ArrayList<>(), 66 | new ArrayList<>(), 67 | "userId123", 68 | new Image("", "", "")); 69 | 70 | 71 | //When 72 | when(idService.createRandomId()) 73 | .thenReturn("01A"); 74 | when(projectRepo.insert(projectWithId)) 75 | .thenReturn(projectWithId); 76 | when(securityContext.getAuthentication()) 77 | .thenReturn(authentication); 78 | when(authentication.getName()) 79 | .thenReturn("test"); 80 | SecurityContextHolder.setContext(securityContext); 81 | when(mongoUserService.findByUsername("test")) 82 | .thenReturn(new MongoUserWithoutPassword("userId123", "test", new ArrayList<>(), new ArrayList<>())); 83 | 84 | 85 | Project actualProject = projectService.addProject(new ProjectCreation( 86 | "Earthquake Turkey", 87 | "Help for the people in Turkey", 88 | Category.PARTICIPATION, 89 | listOfDemands, 90 | "Turkey", 91 | 0, 92 | new Image("", "", ""))); 93 | 94 | 95 | //Then 96 | verify(projectRepo).insert(projectWithId); 97 | verify(idService).createRandomId(); 98 | assertEquals(projectWithId, actualProject); 99 | } 100 | 101 | @Test 102 | void returnListOfProjects() { 103 | //Given 104 | List expectedProjectList = new ArrayList<>(List.of(new Project( 105 | "01A", 106 | "Earthquake Turkey", 107 | "Help for the people in Turkey", 108 | Category.PARTICIPATION, 109 | List.of(Demand.DONATIONINKIND, Demand.MONEYDONATION), 110 | 50, 111 | 0, 112 | "Turkey", 113 | new ArrayList<>(), 114 | new ArrayList<>(), 115 | "userId123", 116 | new Image("", "", "")))); 117 | 118 | //When 119 | when(projectRepo.findAll()) 120 | .thenReturn(expectedProjectList); 121 | 122 | List actualProjectList = projectService.getAllProjects(); 123 | 124 | //Then 125 | verify(projectRepo).findAll(); 126 | assertEquals(1, projectService.getAllProjects().size()); 127 | assertEquals(expectedProjectList, actualProjectList); 128 | } 129 | 130 | @Test 131 | void returnProjectById() { 132 | //Given 133 | String id = "01A"; 134 | Project expectedProject = new Project( 135 | "01A", 136 | "Earthquake Turkey", 137 | "Help for the people in Turkey", 138 | Category.PARTICIPATION, 139 | List.of(Demand.DONATIONINKIND, Demand.MONEYDONATION), 140 | 50, 141 | 0, 142 | "Turkey", 143 | new ArrayList<>(), 144 | new ArrayList<>(), 145 | "userId123", 146 | new Image("", "", "")); 147 | 148 | //When 149 | when(projectRepo.findById(id)) 150 | .thenReturn(Optional.of(expectedProject)); 151 | 152 | Project actualProject = projectService.getProjectById(id); 153 | 154 | //Then 155 | verify(projectRepo).findById(id); 156 | assertEquals(expectedProject, actualProject); 157 | } 158 | 159 | @Test 160 | void whenProjectUpdated_thenReturnUpdatedProject() { 161 | //Given 162 | String id = "01A"; 163 | ProjectNoId projectWithoutId = new ProjectNoId( 164 | "Earthquake Turkey", 165 | "Help for the people in Turkey", 166 | Category.PARTICIPATION, 167 | List.of(Demand.DONATIONINKIND, Demand.MONEYDONATION), 168 | 50, 169 | 0, 170 | "Turkey", 171 | new ArrayList<>(), 172 | new ArrayList<>(), 173 | "userId123", 174 | new Image("", "", "")); 175 | 176 | Project expectedProject = new Project( 177 | "01A", 178 | projectWithoutId.name(), 179 | projectWithoutId.description(), 180 | projectWithoutId.category(), 181 | projectWithoutId.demands(), 182 | projectWithoutId.progress(), 183 | projectWithoutId.goal(), 184 | projectWithoutId.location(), 185 | projectWithoutId.donations(), 186 | projectWithoutId.participations(), 187 | projectWithoutId.userId(), 188 | projectWithoutId.image()); 189 | 190 | //When 191 | when(projectRepo.save(expectedProject)) 192 | .thenReturn(expectedProject); 193 | when(projectRepo.existsById(id)) 194 | .thenReturn(true); 195 | when(securityContext.getAuthentication()) 196 | .thenReturn(authentication); 197 | when(authentication.getName()) 198 | .thenReturn("test"); 199 | SecurityContextHolder.setContext(securityContext); 200 | when(mongoUserService.findByUsername("test")) 201 | .thenReturn(new MongoUserWithoutPassword("userId123", "test", new ArrayList<>(), new ArrayList<>())); 202 | 203 | Project actualProject = projectService.updateProject("01A", projectWithoutId); 204 | 205 | 206 | //Then 207 | verify(projectRepo).save(expectedProject); 208 | assertEquals(expectedProject, actualProject); 209 | } 210 | 211 | @Test 212 | void whenNoId_thenThrowException() { 213 | //Given 214 | String id = "01A"; 215 | ProjectNoId projectWithoutId = new ProjectNoId( 216 | "Earthquake Turkey", 217 | "Help for the people in Turkey", 218 | Category.PARTICIPATION, 219 | List.of(Demand.DONATIONINKIND, Demand.MONEYDONATION), 220 | 50, 221 | 0, 222 | "Turkey", 223 | new ArrayList<>(), 224 | new ArrayList<>(), 225 | "userId123", 226 | new Image("", "", "")); 227 | 228 | 229 | //When 230 | when(projectRepo.existsById("01A")) 231 | .thenReturn(false); 232 | 233 | 234 | //Then 235 | assertThrows(NoSuchElementException.class, () -> projectService.updateProject(id, projectWithoutId)); 236 | verify(projectRepo).existsById(id); 237 | } 238 | 239 | @Test 240 | void whenProjectDeleted_verifyRepoCalls() { 241 | //Given 242 | String id = "01A"; 243 | 244 | 245 | //When 246 | when(projectRepo.existsById(id)) 247 | .thenReturn(true); 248 | projectService.deleteProject(id); 249 | 250 | 251 | //Then 252 | verify(projectRepo).existsById(id); 253 | verify(projectRepo).deleteById(id); 254 | } 255 | 256 | @Test 257 | void whenNoneExistingProjectDeleted_thenThrowException() { 258 | //Given 259 | String id = "01A"; 260 | 261 | //When 262 | when(projectRepo.existsById(id)) 263 | .thenReturn(false); 264 | 265 | 266 | //Then 267 | assertThrows(NoSuchElementException.class, () -> projectService.deleteProject(id)); 268 | verify(projectRepo).existsById(id); 269 | verify(projectRepo, never()).deleteById(id); 270 | } 271 | 272 | @Test 273 | void whenDonationAdded_thenReturnProject() { 274 | //Given 275 | String projectId = "01A"; 276 | 277 | Project repoProject = new Project( 278 | "01A", 279 | "Earthquake Turkey", 280 | "Help for the people in Turkey", 281 | Category.PARTICIPATION, 282 | List.of(Demand.DONATIONINKIND, Demand.MONEYDONATION), 283 | 0, 284 | 100, 285 | "Turkey", 286 | new ArrayList<>(), 287 | new ArrayList<>(), 288 | "userId123", 289 | new Image("", "", "")); 290 | 291 | DonationCreation donationToAdd = new DonationCreation( 292 | repoProject.id(), 293 | repoProject.name(), 294 | new BigDecimal(50)); 295 | 296 | Donation finalDonation = new Donation( 297 | "dono-02A", 298 | repoProject.id(), 299 | repoProject.name(), 300 | "test", 301 | new BigDecimal(50), 302 | "userId123"); 303 | 304 | Project projectToSave = new Project( 305 | "01A", 306 | "Earthquake Turkey", 307 | "Help for the people in Turkey", 308 | Category.PARTICIPATION, 309 | List.of(Demand.DONATIONINKIND, Demand.MONEYDONATION), 310 | 0, 311 | 100, 312 | "Turkey", 313 | List.of(finalDonation), 314 | new ArrayList<>(), 315 | "userId123", 316 | new Image("", "", "")); 317 | 318 | Project projectWithProgress = new Project( 319 | "01A", 320 | "Earthquake Turkey", 321 | "Help for the people in Turkey", 322 | Category.PARTICIPATION, 323 | List.of(Demand.DONATIONINKIND, Demand.MONEYDONATION), 324 | 50, 325 | 100, 326 | "Turkey", 327 | List.of(finalDonation), 328 | new ArrayList<>(), 329 | "userId123", 330 | new Image("", "", "")); 331 | 332 | 333 | //When 334 | when(projectRepo.findById(projectId)) 335 | .thenReturn(Optional.of(repoProject)); 336 | when(projectCalculations.calculateProgressForDonations(projectToSave)) 337 | .thenReturn(projectWithProgress); 338 | when(projectRepo.save(projectWithProgress)) 339 | .thenReturn(projectWithProgress); 340 | when(idService.createRandomId()) 341 | .thenReturn("dono-02A"); 342 | when(securityContext.getAuthentication()) 343 | .thenReturn(authentication); 344 | when(authentication.getName()) 345 | .thenReturn("test"); 346 | SecurityContextHolder.setContext(securityContext); 347 | when(mongoUserService.findByUsername("test")) 348 | .thenReturn(new MongoUserWithoutPassword("userId123", "test", new ArrayList<>(), new ArrayList<>())); 349 | 350 | 351 | Project actualProject = projectService.addDonation(projectId, donationToAdd); 352 | 353 | 354 | //Then 355 | verify(projectRepo).findById(projectId); 356 | verify(projectCalculations).calculateProgressForDonations(projectToSave); 357 | verify(projectRepo).save(projectWithProgress); 358 | verify(idService).createRandomId(); 359 | assertEquals(projectWithProgress, actualProject); 360 | } 361 | 362 | @Test 363 | void returnProject_whenAddParticipation() { 364 | //Given 365 | String projectId = "01A"; 366 | 367 | Project repoProject = new Project( 368 | "01A", 369 | "Earthquake Turkey", 370 | "Help for the people in Turkey", 371 | Category.PARTICIPATION, 372 | List.of(Demand.DONATIONINKIND, Demand.MONEYDONATION), 373 | 0, 374 | 100, 375 | "Turkey", 376 | new ArrayList<>(), 377 | new ArrayList<>(), 378 | "userId123", 379 | new Image("", "", "")); 380 | 381 | ParticipationCreation participationToAdd = new ParticipationCreation( 382 | repoProject.id(), 383 | repoProject.name() 384 | ); 385 | 386 | Participation finalParticipation = new Participation( 387 | "vol-02A", 388 | repoProject.id(), 389 | repoProject.name(), 390 | "test", 391 | "userId123" 392 | ); 393 | 394 | Project projectToSave = new Project( 395 | "01A", 396 | "Earthquake Turkey", 397 | "Help for the people in Turkey", 398 | Category.PARTICIPATION, 399 | List.of(Demand.DONATIONINKIND, Demand.MONEYDONATION), 400 | 0, 401 | 100, 402 | "Turkey", 403 | new ArrayList<>(), 404 | List.of(finalParticipation), 405 | "userId123", 406 | new Image("", "", "")); 407 | 408 | Project projectWithProgress = new Project( 409 | "01A", 410 | "Earthquake Turkey", 411 | "Help for the people in Turkey", 412 | Category.PARTICIPATION, 413 | List.of(Demand.DONATIONINKIND, Demand.MONEYDONATION), 414 | 1, 415 | 100, 416 | "Turkey", 417 | new ArrayList<>(), 418 | List.of(finalParticipation), 419 | "userId123", 420 | new Image("", "", "")); 421 | 422 | 423 | //When 424 | when(projectRepo.findById(projectId)) 425 | .thenReturn(Optional.of(repoProject)); 426 | when(projectCalculations.calculateProgressForParticipations(projectToSave)) 427 | .thenReturn(projectWithProgress); 428 | when(projectRepo.save(projectWithProgress)) 429 | .thenReturn(projectWithProgress); 430 | when(idService.createRandomId()) 431 | .thenReturn("vol-02A"); 432 | when(securityContext.getAuthentication()) 433 | .thenReturn(authentication); 434 | when(authentication.getName()) 435 | .thenReturn("test"); 436 | SecurityContextHolder.setContext(securityContext); 437 | when(mongoUserService.findByUsername("test")) 438 | .thenReturn(new MongoUserWithoutPassword("userId123", "test", new ArrayList<>(), new ArrayList<>())); 439 | 440 | Project actualProject = projectService.addParticipation(projectId, participationToAdd); 441 | 442 | 443 | //Then 444 | verify(projectRepo).findById(projectId); 445 | verify(projectCalculations).calculateProgressForParticipations(projectToSave); 446 | verify(projectRepo).save(projectWithProgress); 447 | verify(idService).createRandomId(); 448 | assertEquals(projectWithProgress, actualProject); 449 | } 450 | } 451 | --------------------------------------------------------------------------------