diff --git a/.github/workflows/cd-backend.yml b/.github/workflows/cd-backend.yml new file mode 100644 index 0000000..66ff441 --- /dev/null +++ b/.github/workflows/cd-backend.yml @@ -0,0 +1,64 @@ +name: CD for Backend + +on: + pull_request: + branches: + - release-* + paths: + - 'backend/**' + +jobs: + cd: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./backend + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: 21 + distribution: 'adopt' + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: Run Gradle build + run: ./gradlew build + + - name: Zip Gradle build + working-directory: ./backend/build/libs + run: zip backend.zip backend-0.1.jar + + - name: Terraform init + run: terraform init -migrate-state + working-directory: ./deployment/cd_backend + + - name: Terraform deployment + working-directory: ./deployment/cd_backend + run: | + SECURITY_GROUP_ID=$(aws ec2 describe-security-groups --filters "Name=group-name,Values=${RDS_CONN_SECURITY_GROUP_NAME}" --query "SecurityGroups[0].GroupId" --output text) + TF_VAR_rds_connection_sg_id=$SECURITY_GROUP_ID TF_VAR_rds_instance_username=$RDS_INSTANCE_USERNAME TF_VAR_rds_instance_password=$RDS_INSTANCE_PASSWORD TF_VAR_rds_instance_endpoint=$RDS_INSTANCE_ENDPOINT terraform apply -var-file="../variables.tfvars" -replace="aws_instance.ec2_backend" -auto-approve + + env: + RDS_CONN_SECURITY_GROUP_NAME: ${{ secrets.RDS_CONN_SECURITY_GROUP_NAME }} + RDS_INSTANCE_USERNAME: ${{ secrets.RDS_INSTANCE_USERNAME }} + RDS_INSTANCE_PASSWORD: ${{ secrets.RDS_INSTANCE_PASSWORD }} + RDS_INSTANCE_ENDPOINT: ${{ secrets.RDS_INSTANCE_ENDPOINT }} diff --git a/.github/workflows/cd-frontend.yml b/.github/workflows/cd-frontend.yml new file mode 100644 index 0000000..5b3773f --- /dev/null +++ b/.github/workflows/cd-frontend.yml @@ -0,0 +1,58 @@ +name: CD for frontend + +on: + pull_request: + branches: + - release-* + paths: + - 'frontend/**' + + +jobs: + cd: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: '18' + + - name: Cache node_modules + uses: actions/cache@v3 + with: + path: frontend/node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} + restore-keys: ${{ runner.os }}-node-modules + + + - name: Install dependencies + run: npm install + + - name: Generate environment.ts + run: | + chmod +x ./generate-environment.sh + ./generate-environment.sh + env: + OIDC_CLIENT_ID: ${{ secrets.OIDC_CLIENT_ID }} + API_URL: ${{ secrets.API_URL }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + + - name: Run ng build + run: npm run build + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Sync to S3 + run: aws s3 sync "./dist/fleet-assistant/browser" "s3://fleet-assistant-hosting-bucket/" \ No newline at end of file diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml new file mode 100644 index 0000000..f13f186 --- /dev/null +++ b/.github/workflows/ci-backend.yml @@ -0,0 +1,57 @@ +name: CI for Backend + +on: + pull_request: + branches: + - master + - develop + paths: + - 'backend/**' + +jobs: + ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./backend + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: 21 + distribution: 'adopt' + + - name: Cache SonarCloud packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: Run Gradle tests + run: ./gradlew test --no-daemon + + - name: Generate JaCoCo Report and run Sonar Analysis + run: ./gradlew jacocoTestReport sonar --no-daemon + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: Generate JaCoCo Badge + uses: cicirello/jacoco-badge-generator@v2 + with: + generate-branches-badge: true + jacoco-csv-file: backend/build/reports/jacoco/test/jacocoTestReport.csv \ No newline at end of file diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml new file mode 100644 index 0000000..4cdb8a9 --- /dev/null +++ b/.github/workflows/ci-frontend.yml @@ -0,0 +1,47 @@ +name: CI for frontend + +on: + pull_request: + branches: + - master + - develop + paths: + - 'frontend/**' + +jobs: + ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: '18' + + - name: Cache node_modules + uses: actions/cache@v3 + with: + path: frontend/node_modules + key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }} + restore-keys: ${{ runner.os }}-node-modules + + + - name: Install dependencies + run: npm install + + - name: Generate environment.ts + run: | + chmod +x ./generate-environment.sh + ./generate-environment.sh + + - name: Run tests + run: npm run test + + - name: Run linter tests + run: npm run lint \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0571e4f..5decee0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,10 @@ **/node_modules **/.angular *.css -*.css.map \ No newline at end of file +*.css.map +**/package-lock.json +**/dist +environment.ts +**terraform** +**/.venv +**/coverage \ No newline at end of file diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 0000000..2637695 --- /dev/null +++ b/backend/build.gradle @@ -0,0 +1,74 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.3' + id 'io.spring.dependency-management' version '1.1.6' + id "org.sonarqube" version "4.4.1.3373" + id 'jacoco' +} + +group = 'org.fleetassistant' +version = '0.1' + +java { + sourceCompatibility = '21' +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.security:spring-security-oauth2-jose' + implementation 'org.springframework.security:spring-security-oauth2-resource-server' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.zalando:problem-spring-web:0.27.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + implementation 'org.projectlombok:lombok' + implementation 'org.liquibase:liquibase-core' + implementation 'org.mapstruct:mapstruct:1.5.3.Final' + implementation 'org.apache.commons:commons-lang3:3.12.0' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'com.github.ben-manes.caffeine:caffeine:3.0.4' + implementation 'org.springframework.boot:spring-boot-starter-cache:3.1.0' + annotationProcessor 'org.projectlombok:lombok' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final' + runtimeOnly 'org.postgresql:postgresql' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2' + testImplementation 'org.mockito:mockito-core:5.2.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.2.0' + testImplementation 'com.h2database:h2:2.1.214' +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + csv.required = true + } +} + +sonar { + properties { + property "sonar.projectKey", "pwr-thesis_fleet-assistant" + property "sonar.organization", "pwr-thesis" + property "sonar.host.url", "https://sonarcloud.io" + property "sonar.gradle.skipCompile", true + property "sonar.coverage.exclusions", "**/BackendApplication.java,**/DefaultExceptionHandler.java,**/nonrest/*.java,**/rest/*.java,**/controller/*.java,**/model/*.java" + } +} \ No newline at end of file diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..0c0dda0 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,30 @@ +services: + postgres: + container_name: postgres + image: postgres + environment: + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + POSTGRES_DB: fleet_assistant + volumes: + - postgres:/data/postgres + ports: + - '5432:5432' + + pgadmin: + container_name: pgadmin + image: dpage/pgadmin4 + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-pgadmin4@pgadmin.org} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} + PGADMIN_CONFIG_SERVER_MODE: 'False' + volumes: + - pgadmin:/var/lib/pgadmin + ports: + - '5050:80' + depends_on: + - postgres + restart: always +volumes: + postgres: + pgadmin: \ No newline at end of file diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/backend/gradle/wrapper/gradle-wrapper.jar differ diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..0aaefbc --- /dev/null +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/gradlew b/backend/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/backend/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/backend/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/backend/settings.gradle b/backend/settings.gradle new file mode 100644 index 0000000..0f5036d --- /dev/null +++ b/backend/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'backend' diff --git a/backend/src/main/java/org/fleetassistant/backend/BackendApplication.java b/backend/src/main/java/org/fleetassistant/backend/BackendApplication.java new file mode 100644 index 0000000..aa51951 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/BackendApplication.java @@ -0,0 +1,11 @@ +package org.fleetassistant.backend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class BackendApplication { + public static void main(String[] args) { + SpringApplication.run(BackendApplication.class, args); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/auth/AuthenticationController.java b/backend/src/main/java/org/fleetassistant/backend/auth/AuthenticationController.java new file mode 100644 index 0000000..3bd0069 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/auth/AuthenticationController.java @@ -0,0 +1,28 @@ +package org.fleetassistant.backend.auth; + +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.auth.models.AuthenticationRequest; +import org.fleetassistant.backend.auth.models.AuthenticationResponse; +import org.fleetassistant.backend.auth.models.RegisterRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("api/v1/auth/") +@RequiredArgsConstructor +public class AuthenticationController { + private final AuthenticationService authenticationService; + + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterRequest request) { + return ResponseEntity.ok(authenticationService.register(request)); + } + + @PostMapping("/authenticate") + public ResponseEntity authenticate(@RequestBody AuthenticationRequest request) { + return ResponseEntity.ok(authenticationService.authenticate(request)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/auth/AuthenticationService.java b/backend/src/main/java/org/fleetassistant/backend/auth/AuthenticationService.java new file mode 100644 index 0000000..95f97b1 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/auth/AuthenticationService.java @@ -0,0 +1,78 @@ +package org.fleetassistant.backend.auth; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.fleetassistant.backend.auth.credentials.CredentialsService; +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.auth.credentials.model.Role; +import org.fleetassistant.backend.auth.models.AuthenticationRequest; +import org.fleetassistant.backend.auth.models.AuthenticationResponse; +import org.fleetassistant.backend.auth.models.RegisterRequest; +import org.fleetassistant.backend.dto.User; +import org.fleetassistant.backend.exceptionhandler.rest.ObjectAlreadyExistsException; +import org.fleetassistant.backend.jwt.service.JwtService; +import org.fleetassistant.backend.jwt.service.TokenGenerator; +import org.fleetassistant.backend.user.model.Manager; +import org.fleetassistant.backend.user.service.ManagerService; +import org.fleetassistant.backend.user.service.UserService; +import org.fleetassistant.backend.utils.EntityToDtoMapper; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import static org.fleetassistant.backend.utils.Constants.ALREADY_REGISTERED; +import static org.fleetassistant.backend.utils.Constants.USER_DOESNT_EXIST; + + +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthenticationService { + private final CredentialsService credentialsService; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final TokenGenerator tokenGenerator; + private final JwtService jwtService; + private final ManagerService managerService; + private final UserService userService; + private final EntityToDtoMapper entityToDtoMapper; + + @Transactional + public AuthenticationResponse register(RegisterRequest request) { + if (credentialsService.ifCredentialsExist(request.getEmail())) { + throw new ObjectAlreadyExistsException(ALREADY_REGISTERED); + } + Credentials credentials = credentialsService.create(request.getEmail(), + passwordEncoder.encode(request.getPassword()), Role.MANAGER); + Manager manager = managerService.createManager(request); + manager.setCredentials(credentials); + + return AuthenticationResponse.builder() + .token(tokenGenerator.createToken(credentials)) + .user(entityToDtoMapper.userToUserDto(manager)) + .build(); + } + + public AuthenticationResponse authenticate(AuthenticationRequest request) { + authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(request.getEmail(), + request.getPassword())); + Credentials credentials = credentialsService.loadUserByUsername(request.getEmail()); + if (credentials == null) { + throw new UsernameNotFoundException(USER_DOESNT_EXIST); + } + User user = userService.getUserByEmail(request.getEmail()); + return AuthenticationResponse.builder() + .token(tokenGenerator.createToken(credentials)) + .user(user) + .build(); + } + + public String refreshToken(String jwt) { + String email = jwtService.extractUsername(jwt); + Credentials credentials = credentialsService.loadUserByUsername(email); + return tokenGenerator.createAccessToken(credentials); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/auth/credentials/CredentialsRepository.java b/backend/src/main/java/org/fleetassistant/backend/auth/credentials/CredentialsRepository.java new file mode 100644 index 0000000..76a69dd --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/auth/credentials/CredentialsRepository.java @@ -0,0 +1,12 @@ +package org.fleetassistant.backend.auth.credentials; + +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CredentialsRepository extends JpaRepository { + Optional findByEmail(String email); +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/auth/credentials/CredentialsService.java b/backend/src/main/java/org/fleetassistant/backend/auth/credentials/CredentialsService.java new file mode 100644 index 0000000..a94e5f4 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/auth/credentials/CredentialsService.java @@ -0,0 +1,50 @@ +package org.fleetassistant.backend.auth.credentials; + +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.auth.credentials.model.Role; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CredentialsService implements UserDetailsService { + private final CredentialsRepository credentialsRepository; + + @Override + public Credentials loadUserByUsername(String email) { + return credentialsRepository.findByEmail(email).orElse(null); + } + + public Credentials create(String email, Role role) { + Credentials newCredentials = createCredentials(email, role).build(); + return credentialsRepository.save(newCredentials); + } + + + public Credentials create(String email, String password, Role role) { + Credentials newCredentials = createCredentials(email, role) + .password(password) + .build(); + return credentialsRepository.save(newCredentials); + } + + public boolean ifCredentialsExist(String email) { + return credentialsRepository.findByEmail(email).isPresent(); + } + + public static Credentials getCredentials() { + SecurityContext securityContextHolder = SecurityContextHolder.getContext(); + Credentials credentials = (Credentials) securityContextHolder.getAuthentication().getPrincipal(); + return credentials; + } + + private Credentials.CredentialsBuilder createCredentials(String email, Role role) { + return Credentials.builder() + .email(email) + .role(role); + + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/auth/credentials/model/Credentials.java b/backend/src/main/java/org/fleetassistant/backend/auth/credentials/model/Credentials.java new file mode 100644 index 0000000..ac40907 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/auth/credentials/model/Credentials.java @@ -0,0 +1,58 @@ +package org.fleetassistant.backend.auth.credentials.model; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Credentials implements UserDetails { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false, length = 50, unique = true) + private String email; + private String password; + @Enumerated(EnumType.STRING) + @Column(length = 7) + private Role role; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(role.name())); + } + + @Override + public String getUsername() { + return getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/auth/credentials/model/Role.java b/backend/src/main/java/org/fleetassistant/backend/auth/credentials/model/Role.java new file mode 100644 index 0000000..237a943 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/auth/credentials/model/Role.java @@ -0,0 +1,5 @@ +package org.fleetassistant.backend.auth.credentials.model; + +public enum Role { + MANAGER, DRIVER +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/auth/models/AuthenticationRequest.java b/backend/src/main/java/org/fleetassistant/backend/auth/models/AuthenticationRequest.java new file mode 100644 index 0000000..d3a6306 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/auth/models/AuthenticationRequest.java @@ -0,0 +1,15 @@ +package org.fleetassistant.backend.auth.models; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AuthenticationRequest { + private String email; + private String password; +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/auth/models/AuthenticationResponse.java b/backend/src/main/java/org/fleetassistant/backend/auth/models/AuthenticationResponse.java new file mode 100644 index 0000000..b4afb90 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/auth/models/AuthenticationResponse.java @@ -0,0 +1,17 @@ +package org.fleetassistant.backend.auth.models; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.fleetassistant.backend.dto.User; +import org.fleetassistant.backend.dto.Token; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AuthenticationResponse { + private User user; + private Token token; +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/auth/models/RegisterRequest.java b/backend/src/main/java/org/fleetassistant/backend/auth/models/RegisterRequest.java new file mode 100644 index 0000000..f732f79 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/auth/models/RegisterRequest.java @@ -0,0 +1,18 @@ +package org.fleetassistant.backend.auth.models; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class RegisterRequest { + private String name; + private String surname; + private String email; + private String password; + private String number; +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/dto/Location.java b/backend/src/main/java/org/fleetassistant/backend/dto/Location.java new file mode 100644 index 0000000..2ed0dfa --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/dto/Location.java @@ -0,0 +1,9 @@ +package org.fleetassistant.backend.dto; + +import lombok.Builder; + +@Builder +public record Location(Long id, + Double longitude, + Double latitude) { +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/dto/Token.java b/backend/src/main/java/org/fleetassistant/backend/dto/Token.java new file mode 100644 index 0000000..cfe0daf --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/dto/Token.java @@ -0,0 +1,10 @@ +package org.fleetassistant.backend.dto; + +import lombok.Builder; + +@Builder +public record Token( + Long userId, + String accessToken, + String refreshToken) { +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/dto/User.java b/backend/src/main/java/org/fleetassistant/backend/dto/User.java new file mode 100644 index 0000000..e463950 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/dto/User.java @@ -0,0 +1,9 @@ +package org.fleetassistant.backend.dto; + + +import lombok.Builder; +import org.fleetassistant.backend.auth.credentials.model.Role; + +@Builder +public record User(Long id, String name, String surname, Role role, String email) { +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/dto/Vehicle.java b/backend/src/main/java/org/fleetassistant/backend/dto/Vehicle.java new file mode 100644 index 0000000..34dc898 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/dto/Vehicle.java @@ -0,0 +1,21 @@ +package org.fleetassistant.backend.dto; + +import lombok.Builder; + +import java.time.LocalDate; +import java.util.List; + +@Builder +public record Vehicle( + Long id, + String name, + String vin, + String plateNumber, + String countryCode, + LocalDate insuranceDate, + LocalDate lastInspectionDate, + LocalDate nextInspectionDate, + LocalDate productionDate, + List locations, + Long driverId) { +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/DefaultExceptionHandler.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/DefaultExceptionHandler.java new file mode 100644 index 0000000..5fa855b --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/DefaultExceptionHandler.java @@ -0,0 +1,81 @@ +package org.fleetassistant.backend.exceptionhandler; + +import org.fleetassistant.backend.exceptionhandler.nonrest.IncorrectTokenTypeException; +import org.fleetassistant.backend.exceptionhandler.rest.*; +import org.hibernate.ObjectNotFoundException; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.zalando.problem.Problem; +import org.zalando.problem.Status; + +import static org.fleetassistant.backend.utils.Constants.*; + +@Order(Ordered.HIGHEST_PRECEDENCE) +@ControllerAdvice +public class DefaultExceptionHandler { + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + FieldError fieldError = e.getBindingResult().getFieldError(); + String errorMessage = fieldError != null ? fieldError.getDefaultMessage() : "Invalid request"; + Problem problem = buildProblem(Status.BAD_REQUEST, "Invalid request", errorMessage); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problem); + } + + @ExceptionHandler(EmailNotFoundException.class) + public ResponseEntity handleEmailNotFoundException(RuntimeException ex) { + Problem problem = buildProblem(Status.BAD_REQUEST, EMAIL_NOT_FOUND, ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problem); + } + + @ExceptionHandler({ObjectNotFoundException.class, NoSuchObjectException.class}) + public ResponseEntity handleNotFoundException(RuntimeException ex) { + Problem problem = buildProblem(Status.NOT_FOUND, NOT_FOUND, ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem); + } + + @ExceptionHandler(ObjectAlreadyExistsException.class) + public ResponseEntity handleObjectAlreadyExists(RuntimeException ex) { + Problem problem = buildProblem(Status.CONFLICT, ALREADY_EXIST, ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(problem); + } + + @ExceptionHandler(WrongAuthenticationInstanceException.class) + public ResponseEntity handleWrongInstanceDetectedException(RuntimeException ex) { + Problem problem = buildProblem(Status.FORBIDDEN, WRONG_AUTHENTICATION_INSTANCE, ex.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(problem); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentials() { + Problem problem = buildProblem(Status.UNAUTHORIZED, AUTHENTICATION_EXCEPTION, WRONG_EMAIL_OR_PASSWORD); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(problem); + } + + @ExceptionHandler({CacheException.class, IncorrectTokenTypeException.class}) + public ResponseEntity handleCacheError() { + Problem problem = buildProblem(Status.SERVICE_UNAVAILABLE, Status.SERVICE_UNAVAILABLE.toString(), + "Service unavailable"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(problem); + } + + @ExceptionHandler({InvalidTokenException.class, TokenRequiredException.class}) + public ResponseEntity handleTokenExceptions(RuntimeException ex) { + Problem problem = buildProblem(Status.BAD_REQUEST, TOKEN_ERROR, ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problem); + } + + public static Problem buildProblem(Status status, String title, String detail) { + return Problem.builder() + .withStatus(status) + .withTitle(title) + .withDetail(detail) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/CacheError.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/CacheError.java new file mode 100644 index 0000000..765326c --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/CacheError.java @@ -0,0 +1,8 @@ + +package org.fleetassistant.backend.exceptionhandler.nonrest; + +public class CacheError extends RuntimeException { + public CacheError(String s) { + super(s); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/IncorrectTokenTypeException.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/IncorrectTokenTypeException.java new file mode 100644 index 0000000..91197d2 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/IncorrectTokenTypeException.java @@ -0,0 +1,10 @@ +package org.fleetassistant.backend.exceptionhandler.nonrest; + +public class IncorrectTokenTypeException extends RuntimeException { + + public static final String INCORRECT_TOKEN_TYPE_EXCEPTION = "Incorrect token type exception"; + + public IncorrectTokenTypeException() { + super(INCORRECT_TOKEN_TYPE_EXCEPTION); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/KeyPairCreationException.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/KeyPairCreationException.java new file mode 100644 index 0000000..f9909dd --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/KeyPairCreationException.java @@ -0,0 +1,7 @@ +package org.fleetassistant.backend.exceptionhandler.nonrest; + +public class KeyPairCreationException extends RuntimeException { + public KeyPairCreationException(String e) { + super(e); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/KeyPairNotFoundException.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/KeyPairNotFoundException.java new file mode 100644 index 0000000..bf56cb0 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/nonrest/KeyPairNotFoundException.java @@ -0,0 +1,7 @@ +package org.fleetassistant.backend.exceptionhandler.nonrest; + +public class KeyPairNotFoundException extends RuntimeException { + public KeyPairNotFoundException(String e) { + super(e); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/CacheException.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/CacheException.java new file mode 100644 index 0000000..16fe409 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/CacheException.java @@ -0,0 +1,7 @@ +package org.fleetassistant.backend.exceptionhandler.rest; + +public class CacheException extends RuntimeException { + public CacheException(String s) { + super(s); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/EmailNotFoundException.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/EmailNotFoundException.java new file mode 100644 index 0000000..e7b7f64 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/EmailNotFoundException.java @@ -0,0 +1,7 @@ +package org.fleetassistant.backend.exceptionhandler.rest; + +public class EmailNotFoundException extends RuntimeException{ + public EmailNotFoundException(String e) { + super(e); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/InvalidTokenException.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/InvalidTokenException.java new file mode 100644 index 0000000..c76b12f --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/InvalidTokenException.java @@ -0,0 +1,7 @@ +package org.fleetassistant.backend.exceptionhandler.rest; + +public class InvalidTokenException extends RuntimeException { + public InvalidTokenException(String e) { + super(e); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/NoSuchObjectException.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/NoSuchObjectException.java new file mode 100644 index 0000000..cc855d4 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/NoSuchObjectException.java @@ -0,0 +1,7 @@ +package org.fleetassistant.backend.exceptionhandler.rest; + +public class NoSuchObjectException extends RuntimeException { + public NoSuchObjectException(String exception) { + super(exception); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/ObjectAlreadyExistsException.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/ObjectAlreadyExistsException.java new file mode 100644 index 0000000..cdd0ec4 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/ObjectAlreadyExistsException.java @@ -0,0 +1,7 @@ +package org.fleetassistant.backend.exceptionhandler.rest; + +public class ObjectAlreadyExistsException extends RuntimeException { + public ObjectAlreadyExistsException(String exception) { + super(exception); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/TokenRequiredException.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/TokenRequiredException.java new file mode 100644 index 0000000..03883c0 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/TokenRequiredException.java @@ -0,0 +1,7 @@ +package org.fleetassistant.backend.exceptionhandler.rest; + +public class TokenRequiredException extends RuntimeException { + public TokenRequiredException(String e) { + super(e); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/WrongAuthenticationInstanceException.java b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/WrongAuthenticationInstanceException.java new file mode 100644 index 0000000..8910c71 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/exceptionhandler/rest/WrongAuthenticationInstanceException.java @@ -0,0 +1,7 @@ +package org.fleetassistant.backend.exceptionhandler.rest; + +public class WrongAuthenticationInstanceException extends RuntimeException { + public WrongAuthenticationInstanceException(String e) { + super(e); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/jwt/model/TokenType.java b/backend/src/main/java/org/fleetassistant/backend/jwt/model/TokenType.java new file mode 100644 index 0000000..98388d8 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/jwt/model/TokenType.java @@ -0,0 +1,23 @@ +package org.fleetassistant.backend.jwt.model; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +public enum TokenType { + ACCESS_TOKEN(60, ChronoUnit.MINUTES), + REFRESH_TOKEN(2, ChronoUnit.DAYS), + EMAIL_VALIDATION(1, ChronoUnit.DAYS); + private final long duration; + private final ChronoUnit chronoUnit; + + TokenType(long duration, ChronoUnit unit) { + this.duration = duration; + this.chronoUnit = unit; + } + + public Date getExpiryDate() { + Instant expiryInstant = Instant.now().plus(duration, chronoUnit); + return Date.from(expiryInstant); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/jwt/service/JwtService.java b/backend/src/main/java/org/fleetassistant/backend/jwt/service/JwtService.java new file mode 100644 index 0000000..e2068e4 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/jwt/service/JwtService.java @@ -0,0 +1,95 @@ +package org.fleetassistant.backend.jwt.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.exceptionhandler.nonrest.IncorrectTokenTypeException; +import org.fleetassistant.backend.jwt.model.TokenType; +import org.hibernate.cache.CacheException; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.function.Function; + +import static org.fleetassistant.backend.utils.Constants.*; + +@Service +@RequiredArgsConstructor +@Slf4j +public class JwtService { + private final CacheManager cacheManager; + private final KeyService keyService; + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + public Claims extractAllClaims(String token) { + return Jwts.parserBuilder().setSigningKey(keyService.getSignKey()) + .build().parseClaimsJws(token).getBody(); + } + + public JwsHeader extractAllHeaders(String token) { + return Jwts.parserBuilder().setSigningKey(keyService.getSignKey()) + .build().parseClaimsJws(token).getHeader(); + } + + public void invalidateTokenIfExist(long userId, TokenType tokenType) throws IncorrectTokenTypeException { + Cache cache; + if (tokenType == TokenType.ACCESS_TOKEN) { + cache = cacheManager.getCache(ACCESS_TOKENS); + } else if (tokenType == TokenType.REFRESH_TOKEN) { + cache = cacheManager.getCache(REFRESH_TOKENS); + } else { + throw new IncorrectTokenTypeException(); + } + if (cache == null) throw new CacheException(CACHE_NOT_FOUND); + + var oldTokens = cache.get(userId); + + if (oldTokens != null) { + String cachedJwt = (String) oldTokens.get(); + addTokenToBlackList(userId, cachedJwt); + } + } + + private void addTokenToBlackList(long userId, String cachedJwt) { + Cache blackList = cacheManager.getCache(BLACK_LIST); + if (blackList == null) throw new CacheException(CACHE_NOT_FOUND); + + var bannedUserTokensCache = blackList.get(userId); + List jwtList = new ArrayList<>(); + if (bannedUserTokensCache != null) { + var bannedTokens = bannedUserTokensCache.get(); + jwtList = bannedTokens == null ? new ArrayList<>() : (List) bannedTokens; + } + jwtList.add(cachedJwt); + blackList.put(userId, jwtList); + } + + + public String createSignedJwt(Credentials user, TokenType tokenType) { + return Jwts.builder() + .claim(ID, user.getId()) + .setSubject(user.getUsername()) + .claim(ROLE, user.getRole()) + .claim(EMAIL, user.getEmail()) + .claim(TYPE, tokenType) + .setIssuedAt(Date.from(Instant.now())) + .setExpiration(tokenType.getExpiryDate()) + .signWith(keyService.getSignKey()).compact(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/jwt/service/KeyService.java b/backend/src/main/java/org/fleetassistant/backend/jwt/service/KeyService.java new file mode 100644 index 0000000..8d7d4fc --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/jwt/service/KeyService.java @@ -0,0 +1,19 @@ +package org.fleetassistant.backend.jwt.service; + +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; + +@Component +public class KeyService { + @Value("${spring.jwt.secret}") + private String secret; + + public Key getSignKey() { + byte[] keyBytes = Decoders.BASE64.decode(secret); + return Keys.hmacShaKeyFor(keyBytes); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/jwt/service/TokenGenerator.java b/backend/src/main/java/org/fleetassistant/backend/jwt/service/TokenGenerator.java new file mode 100644 index 0000000..5605f92 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/jwt/service/TokenGenerator.java @@ -0,0 +1,49 @@ +package org.fleetassistant.backend.jwt.service; + +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.exceptionhandler.rest.CacheException; +import org.fleetassistant.backend.dto.Token; +import org.fleetassistant.backend.jwt.model.TokenType; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; + +import static org.fleetassistant.backend.utils.Constants.*; + + +@Component +@RequiredArgsConstructor +public class TokenGenerator { + private final CacheManager cacheManager; + private final JwtService jwtService; + + public String createAccessToken(Credentials credentials) { + jwtService.invalidateTokenIfExist(credentials.getId(), TokenType.ACCESS_TOKEN); + String jwt = jwtService.createSignedJwt(credentials, TokenType.ACCESS_TOKEN); + return putTokenInCache(ACCESS_TOKENS, credentials, jwt); + } + + public String createRefreshToken(Credentials credentials) { + jwtService.invalidateTokenIfExist(credentials.getId(), TokenType.REFRESH_TOKEN); + String jwt = jwtService.createSignedJwt(credentials, TokenType.REFRESH_TOKEN); + return putTokenInCache(REFRESH_TOKENS, credentials, jwt); + } + + public Token createToken(Credentials credentials) { + return Token.builder() + .userId(credentials.getId()) + .accessToken(createAccessToken(credentials)) + .refreshToken(createRefreshToken(credentials)).build(); + } + + private String putTokenInCache(String refreshTokens, Credentials credentials, String jwt) { + var cache = cacheManager.getCache(refreshTokens); + if (cache == null) throw new CacheException(CACHE_NOT_FOUND); + cache.put(credentials.getId(), jwt); + return jwt; + } + + public String createValidationToken(Credentials credentials) { + return jwtService.createSignedJwt(credentials, TokenType.EMAIL_VALIDATION); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/location/LocationRepository.java b/backend/src/main/java/org/fleetassistant/backend/location/LocationRepository.java new file mode 100644 index 0000000..6ce3f20 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/location/LocationRepository.java @@ -0,0 +1,11 @@ +package org.fleetassistant.backend.location; + +import org.fleetassistant.backend.location.model.Location; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LocationRepository extends JpaRepository { + Location findFirstByVehicle_IdOrderByIdDesc(Long vehicleId); + void deleteAllByVehicle_Id(Long vehicleId); +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/location/LocationService.java b/backend/src/main/java/org/fleetassistant/backend/location/LocationService.java new file mode 100644 index 0000000..ba60390 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/location/LocationService.java @@ -0,0 +1,25 @@ +package org.fleetassistant.backend.location; + +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.location.model.Location; +import org.fleetassistant.backend.utils.EntityToDtoMapper; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LocationService { + private final LocationRepository locationRepository; + private final EntityToDtoMapper entityToDtoMapper; + + public org.fleetassistant.backend.dto.Location create(Location location) { + return entityToDtoMapper.locationToLocationDto(locationRepository.save(location)); + } + + public org.fleetassistant.backend.dto.Location readLastLocation(Long vehicleId) { + return entityToDtoMapper.locationToLocationDto(locationRepository.findFirstByVehicle_IdOrderByIdDesc(vehicleId)); + } + + public void deleteAllByVehicleId(Long vehicleId) { + locationRepository.deleteAllByVehicle_Id(vehicleId); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/location/model/Location.java b/backend/src/main/java/org/fleetassistant/backend/location/model/Location.java new file mode 100644 index 0000000..1dc62f2 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/location/model/Location.java @@ -0,0 +1,45 @@ +package org.fleetassistant.backend.location.model; + +import jakarta.persistence.*; +import lombok.*; +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; +import org.fleetassistant.backend.vehicle.model.Vehicle; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Location { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) + private Double longitude; + @Column(nullable = false) + private Double latitude; + @CreatedDate + private LocalDateTime timestamp; + @ManyToOne + private Vehicle vehicle; + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Location location = (Location) o; + return new EqualsBuilder().append(longitude, location.longitude) + .append(latitude, location.latitude) + .append(id, location.id) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37).append(id).append(longitude).append(latitude).toHashCode(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/user/controller/UserController.java b/backend/src/main/java/org/fleetassistant/backend/user/controller/UserController.java new file mode 100644 index 0000000..f7fe561 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/user/controller/UserController.java @@ -0,0 +1,25 @@ +package org.fleetassistant.backend.user.controller; + +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.auth.credentials.CredentialsService; +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.dto.User; +import org.fleetassistant.backend.user.service.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + + +@RestController +@RequestMapping("api/v1/user/") +@RequiredArgsConstructor +public class UserController { + private final UserService userService; + + @GetMapping("/data") + public ResponseEntity getData() { + Credentials credentials = CredentialsService.getCredentials(); + return ResponseEntity.ok(userService.getUserByEmail(credentials.getEmail())); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/user/model/Driver.java b/backend/src/main/java/org/fleetassistant/backend/user/model/Driver.java new file mode 100644 index 0000000..104378f --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/user/model/Driver.java @@ -0,0 +1,34 @@ +package org.fleetassistant.backend.user.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDate; + +@Entity +@Getter +@Setter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class Driver extends User { + @Column(nullable = false, length = 20) + private String drivingLicenseNumber; + @Column(nullable = false, length = 3) + private String drivingLicenseCountryCode; + @Column(nullable = false) + private LocalDate birthDate; + @CreatedDate + private LocalDate createdOn; + @ManyToOne + @JoinColumn(name = "manager_id") + private Manager manager; +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/user/model/Manager.java b/backend/src/main/java/org/fleetassistant/backend/user/model/Manager.java new file mode 100644 index 0000000..a8da075 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/user/model/Manager.java @@ -0,0 +1,25 @@ +package org.fleetassistant.backend.user.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import lombok.*; +import lombok.experimental.SuperBuilder; +import org.fleetassistant.backend.vehicle.model.Vehicle; + +import java.time.LocalDate; +import java.util.List; + +@Entity +@Getter +@Setter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class Manager extends User { + @Builder.Default + private LocalDate createdOn = LocalDate.now(); + @OneToMany(mappedBy = "manager") + private List assignedDrivers; + @OneToMany(mappedBy = "manager") + private List assignedVehicles; +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/user/model/User.java b/backend/src/main/java/org/fleetassistant/backend/user/model/User.java new file mode 100644 index 0000000..f60daf0 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/user/model/User.java @@ -0,0 +1,54 @@ +package org.fleetassistant.backend.user.model; + +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; +import org.fleetassistant.backend.auth.credentials.model.Credentials; + +@Entity +@Table(name = "fauser") +@Getter +@Setter +@ToString +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Inheritance(strategy = InheritanceType.JOINED) +public abstract class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(length = 50) + private String name; + @Column(length = 50) + private String surname; + @Column(length = 14) + private String phone; + @OneToOne + private Credentials credentials; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + User User = (User) o; + return new EqualsBuilder() + .append(id, User.id) + .append(name, User.name) + .append(surname, User.surname) + .append(credentials, User.credentials) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37) + .append(id) + .append(name) + .append(surname) + .append(credentials) + .toHashCode(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/user/repository/DriverRepository.java b/backend/src/main/java/org/fleetassistant/backend/user/repository/DriverRepository.java new file mode 100644 index 0000000..7a4b3f3 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/user/repository/DriverRepository.java @@ -0,0 +1,10 @@ +package org.fleetassistant.backend.user.repository; + +import org.fleetassistant.backend.user.model.Driver; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface DriverRepository extends JpaRepository { + Optional findById(Long id); +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/user/repository/ManagerRepository.java b/backend/src/main/java/org/fleetassistant/backend/user/repository/ManagerRepository.java new file mode 100644 index 0000000..1149f41 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/user/repository/ManagerRepository.java @@ -0,0 +1,10 @@ +package org.fleetassistant.backend.user.repository; + +import org.fleetassistant.backend.user.model.Manager; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ManagerRepository extends JpaRepository { + Optional findByCredentials_Email(String email); +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/user/repository/UserRepository.java b/backend/src/main/java/org/fleetassistant/backend/user/repository/UserRepository.java new file mode 100644 index 0000000..6f0d67c --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/user/repository/UserRepository.java @@ -0,0 +1,10 @@ +package org.fleetassistant.backend.user.repository; + +import org.fleetassistant.backend.user.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByCredentials_Email(String email); +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/user/service/DriverService.java b/backend/src/main/java/org/fleetassistant/backend/user/service/DriverService.java new file mode 100644 index 0000000..fcaf7d6 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/user/service/DriverService.java @@ -0,0 +1,17 @@ +package org.fleetassistant.backend.user.service; + +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.exceptionhandler.rest.NoSuchObjectException; +import org.fleetassistant.backend.user.model.Driver; +import org.fleetassistant.backend.user.repository.DriverRepository; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DriverService { + private final DriverRepository driverRepository; + + public Driver getDriverById(Long id) { + return driverRepository.findById(id).orElseThrow(() -> new NoSuchObjectException("Driver with id " + id + " not found")); + } +} diff --git a/backend/src/main/java/org/fleetassistant/backend/user/service/ManagerService.java b/backend/src/main/java/org/fleetassistant/backend/user/service/ManagerService.java new file mode 100644 index 0000000..5de21d7 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/user/service/ManagerService.java @@ -0,0 +1,40 @@ +package org.fleetassistant.backend.user.service; + +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.auth.models.RegisterRequest; +import org.fleetassistant.backend.exceptionhandler.rest.NoSuchObjectException; +import org.fleetassistant.backend.user.model.Manager; +import org.fleetassistant.backend.user.repository.ManagerRepository; +import org.fleetassistant.backend.user.repository.UserRepository; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ManagerService { + private final UserRepository userRepository; + private final ManagerRepository managerRepository; + + public Manager createManager(Jwt jwt) { + String givenName = jwt.getClaim("given_name"); + String familyName = jwt.getClaim("family_name"); + Manager manager = Manager.builder() + .name(givenName == null ? "" : givenName) + .surname(familyName == null ? "" : familyName) + .build(); + return userRepository.save(manager); + } + + public Manager createManager(RegisterRequest request) { + Manager manager = Manager.builder() + .name(request.getName()) + .surname(request.getSurname()) + .phone(request.getNumber()) + .build(); + return userRepository.save(manager); + } + + public Manager getManagerByEmail(String email) { + return managerRepository.findByCredentials_Email(email).orElseThrow(() -> new NoSuchObjectException("Manager with email: " + email + " not found")); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/user/service/UserService.java b/backend/src/main/java/org/fleetassistant/backend/user/service/UserService.java new file mode 100644 index 0000000..c097df3 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/user/service/UserService.java @@ -0,0 +1,30 @@ +package org.fleetassistant.backend.user.service; + +import lombok.RequiredArgsConstructor; + +import org.fleetassistant.backend.dto.User; +import org.fleetassistant.backend.exceptionhandler.rest.NoSuchObjectException; +import org.fleetassistant.backend.user.repository.UserRepository; +import org.fleetassistant.backend.utils.Constants; +import org.fleetassistant.backend.utils.EntityToDtoMapper; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + private final EntityToDtoMapper entityToDtoMapper; + + public User getUserByEmail(String email) { + return entityToDtoMapper.userToUserDto(getUserEntityByEmail(email)); + } + + public Long getUserIdByEmail(String email) { + return getUserEntityByEmail(email).getId(); + } + + private org.fleetassistant.backend.user.model.User getUserEntityByEmail(String email) { + return userRepository.findByCredentials_Email(email) + .orElseThrow(() -> new NoSuchObjectException(Constants.USER_DOESNT_EXIST)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/utils/Constants.java b/backend/src/main/java/org/fleetassistant/backend/utils/Constants.java new file mode 100644 index 0000000..2c67a2e --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/utils/Constants.java @@ -0,0 +1,31 @@ +package org.fleetassistant.backend.utils; + +import lombok.experimental.UtilityClass; + +import java.util.List; + +@UtilityClass +public class Constants { + public static final String ALREADY_REGISTERED = "Such user has already registered"; + public static final String USER_DOESNT_EXIST = "Such user doesnt exist"; + public static final String ACCESS_TOKENS = "accessTokens"; + public static final String REFRESH_TOKENS = "refreshTokens"; + public static final String BLACK_LIST = "blackList"; + public static final String AUTHENTICATION_BEARER_TOKEN = "Bearer "; + public static final String CACHE_NOT_FOUND = "Cache not found"; + public static final String ID = "id"; + public static final String ROLE = "role"; + public static final String EMAIL = "email"; + public static final String NAME = "name"; + public static final String TYPE = "type"; + public static final String SURNAME = "surname"; + public static final String WRONG_AUTHENTICATION_INSTANCE = "Wrong Authentication Instance"; + public static final String EMAIL_NOT_FOUND = "Email Not Found"; + public static final String NOT_FOUND = "Not Found"; + public static final String ALREADY_EXIST = "Already Exist"; + public static final String TOKEN_NEEDED = "Request should contain token."; + public static final String TOKEN_HAS_BEEN_BANNED = "Token has been banned"; + public static final String AUTHENTICATION_EXCEPTION = "Authentication Exception"; + public static final String WRONG_EMAIL_OR_PASSWORD = "Wrong email or password"; + public static final String TOKEN_ERROR = "Invalid Token Error"; +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/utils/EntityToDtoMapper.java b/backend/src/main/java/org/fleetassistant/backend/utils/EntityToDtoMapper.java new file mode 100644 index 0000000..c34caea --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/utils/EntityToDtoMapper.java @@ -0,0 +1,30 @@ +package org.fleetassistant.backend.utils; + +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.location.model.Location; +import org.fleetassistant.backend.user.model.User; +import org.fleetassistant.backend.vehicle.model.Vehicle; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + + +@Mapper(componentModel = "spring") +public interface EntityToDtoMapper { + Vehicle vehicleDtoToVehicle(org.fleetassistant.backend.dto.Vehicle vehicleDto); + + @Mapping(target = "driverId", source = "driver.id") + org.fleetassistant.backend.dto.Vehicle vehicleToVehicleDto(Vehicle vehicle); + + Location locationDtoToLocation(org.fleetassistant.backend.dto.Location location); + org.fleetassistant.backend.dto.Location locationToLocationDto(Location location); + default org.fleetassistant.backend.dto.User userToUserDto(User user) { + Credentials credentials = user.getCredentials(); + return org.fleetassistant.backend.dto.User.builder() + .id(user.getId()) + .name(user.getName()) + .surname(user.getSurname()) + .role(credentials.getRole()) + .email(credentials.getEmail()) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/utils/RequestLoggingFilterConfig.java b/backend/src/main/java/org/fleetassistant/backend/utils/RequestLoggingFilterConfig.java new file mode 100644 index 0000000..e1f4b5a --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/utils/RequestLoggingFilterConfig.java @@ -0,0 +1,20 @@ +package org.fleetassistant.backend.utils; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.CommonsRequestLoggingFilter; + +@Configuration +public class RequestLoggingFilterConfig { + @Bean + public CommonsRequestLoggingFilter logFilter() { + CommonsRequestLoggingFilter filter + = new CommonsRequestLoggingFilter(); + filter.setIncludeQueryString(true); + filter.setIncludePayload(true); + filter.setMaxPayloadLength(10000); + filter.setIncludeHeaders(false); + filter.setAfterMessagePrefix("REQUEST DATA: "); + return filter; + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/utils/config/ApplicationConfig.java b/backend/src/main/java/org/fleetassistant/backend/utils/config/ApplicationConfig.java new file mode 100644 index 0000000..57557cb --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/utils/config/ApplicationConfig.java @@ -0,0 +1,42 @@ +package org.fleetassistant.backend.utils.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.zalando.problem.jackson.ProblemModule; +import org.zalando.problem.violations.ConstraintViolationProblemModule; + +@Configuration +@EnableAutoConfiguration(exclude = ErrorMvcAutoConfiguration.class) +public class ApplicationConfig { + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper().registerModules( + new ProblemModule(), + new ConstraintViolationProblemModule(), + new JavaTimeModule()); + } + + @Bean + public MessageSource messageSource() { + ReloadableResourceBundleMessageSource messageSource + = new ReloadableResourceBundleMessageSource(); + + messageSource.setBasename("classpath:messages"); + messageSource.setDefaultEncoding("UTF-8"); + return messageSource; + } + + @Bean + public LocalValidatorFactoryBean getValidator() { + LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean(); + bean.setValidationMessageSource(messageSource()); + return bean; + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/utils/config/TokensConfig.java b/backend/src/main/java/org/fleetassistant/backend/utils/config/TokensConfig.java new file mode 100644 index 0000000..37983d7 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/utils/config/TokensConfig.java @@ -0,0 +1,40 @@ +package org.fleetassistant.backend.utils.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +import static org.fleetassistant.backend.utils.Constants.*; + +@Configuration +@RequiredArgsConstructor +public class TokensConfig { + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") + private String jwkSetUri; + @Bean + public Caffeine caffeineConfig() { + return Caffeine.newBuilder().expireAfterWrite(2, TimeUnit.DAYS); + } + + @Bean + public CacheManager cacheManager(Caffeine caffeine) { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + cacheManager.setCacheNames(Arrays.asList(ACCESS_TOKENS, REFRESH_TOKENS, BLACK_LIST)); + cacheManager.setCaffeine(caffeine); + return cacheManager; + } + + @Bean + public JwtDecoder oauthTokenDecoder() { + return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/utils/config/security/CustomAuthFilter.java b/backend/src/main/java/org/fleetassistant/backend/utils/config/security/CustomAuthFilter.java new file mode 100644 index 0000000..43b51f1 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/utils/config/security/CustomAuthFilter.java @@ -0,0 +1,39 @@ +package org.fleetassistant.backend.utils.config.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.utils.config.security.decoders.CustomJwtDecoder; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class CustomAuthFilter extends OncePerRequestFilter { + private final CustomJwtDecoder decoder; + private final JwtToUserConverter jwtToUserConverter; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { + try { + String token = request.getHeader("Authorization"); + if (token != null && token.startsWith("Bearer ")) { + token = token.substring(7); + + Jwt x = decoder.decode(token); + UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = jwtToUserConverter.convert(x); + if (usernamePasswordAuthenticationToken != null) { + SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); + } + } + filterChain.doFilter(request, response); + } catch (Exception e) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + } + } +} diff --git a/backend/src/main/java/org/fleetassistant/backend/utils/config/security/JwtToUserConverter.java b/backend/src/main/java/org/fleetassistant/backend/utils/config/security/JwtToUserConverter.java new file mode 100644 index 0000000..b4b262f --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/utils/config/security/JwtToUserConverter.java @@ -0,0 +1,41 @@ +package org.fleetassistant.backend.utils.config.security; + +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.auth.credentials.CredentialsService; +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.auth.credentials.model.Role; +import org.fleetassistant.backend.user.model.User; +import org.fleetassistant.backend.user.service.ManagerService; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtToUserConverter implements Converter { + private final CredentialsService credentialsService; + private final ManagerService managerService; + + @Override + @Transactional + public UsernamePasswordAuthenticationToken convert(Jwt jwt) { + String email = jwt.getClaim("email"); + Credentials credentials = credentialsService.loadUserByUsername(email); + if (credentials == null) { + credentials = credentialsService.create(email, Role.MANAGER); + User user = managerService.createManager(jwt); + user.setCredentials(credentials); + } + + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority(credentials.getRole().name())); + return new UsernamePasswordAuthenticationToken(credentials, jwt, authorities); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/utils/config/security/SecurityConfig.java b/backend/src/main/java/org/fleetassistant/backend/utils/config/security/SecurityConfig.java new file mode 100644 index 0000000..a3f227a --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/utils/config/security/SecurityConfig.java @@ -0,0 +1,81 @@ +package org.fleetassistant.backend.utils.config.security; + +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.auth.credentials.CredentialsService; +import org.fleetassistant.backend.auth.credentials.model.Role; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Collections; +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final CredentialsService credentialsService; + private final CustomAuthFilter customFilter; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .authenticationProvider(authenticationProvider()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterAt(customFilter, BearerTokenAuthenticationFilter.class) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/api/v1/user/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/v1/vehicle/**").hasAuthority(Role.MANAGER.name()) + .requestMatchers("/api/v1/vehicle/{id}").authenticated() + .requestMatchers("/api/v1/vehicle/**").authenticated() + .anyRequest().permitAll()); + + return http.build(); + } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Collections.singletonList("*")); + configuration.setAllowedMethods(List.of("*")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setExposedHeaders(List.of("Authorization")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(credentialsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/utils/config/security/decoders/CustomJwtDecoder.java b/backend/src/main/java/org/fleetassistant/backend/utils/config/security/decoders/CustomJwtDecoder.java new file mode 100644 index 0000000..b49fc8a --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/utils/config/security/decoders/CustomJwtDecoder.java @@ -0,0 +1,23 @@ +package org.fleetassistant.backend.utils.config.security.decoders; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomJwtDecoder implements JwtDecoder { + private final JwtDecoder oauthTokenDecoder; + private final CustomLocalJwtDecoder customLocalJwtDecoder; + + @Override + public Jwt decode(String token) throws JwtException { + try { + return oauthTokenDecoder.decode(token); + } catch (Exception e) { + return customLocalJwtDecoder.decode(token); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/utils/config/security/decoders/CustomLocalJwtDecoder.java b/backend/src/main/java/org/fleetassistant/backend/utils/config/security/decoders/CustomLocalJwtDecoder.java new file mode 100644 index 0000000..b364a74 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/utils/config/security/decoders/CustomLocalJwtDecoder.java @@ -0,0 +1,26 @@ +package org.fleetassistant.backend.utils.config.security.decoders; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.jwt.service.JwtService; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class CustomLocalJwtDecoder implements JwtDecoder { + private final JwtService jwtService; + + @Override + public Jwt decode(String token) throws JwtException { + Claims claims = jwtService.extractAllClaims(token); + return new Jwt( + token, + claims.getIssuedAt().toInstant(), + claims.getExpiration().toInstant(), + jwtService.extractAllHeaders(token), + claims); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/vehicle/VehicleRepository.java b/backend/src/main/java/org/fleetassistant/backend/vehicle/VehicleRepository.java new file mode 100644 index 0000000..97db180 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/vehicle/VehicleRepository.java @@ -0,0 +1,14 @@ +package org.fleetassistant.backend.vehicle; + +import org.fleetassistant.backend.vehicle.model.Vehicle; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface VehicleRepository extends JpaRepository { + Page findAllByManagerId(Long managerId, Pageable pageable); + + Page findAllByDriverId(Long driverId, Pageable pageable); +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/vehicle/VehicleService.java b/backend/src/main/java/org/fleetassistant/backend/vehicle/VehicleService.java new file mode 100644 index 0000000..4539a6e --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/vehicle/VehicleService.java @@ -0,0 +1,121 @@ +package org.fleetassistant.backend.vehicle; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.auth.credentials.CredentialsService; +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.auth.credentials.model.Role; +import org.fleetassistant.backend.exceptionhandler.rest.NoSuchObjectException; +import org.fleetassistant.backend.exceptionhandler.rest.ObjectAlreadyExistsException; +import org.fleetassistant.backend.location.LocationService; +import org.fleetassistant.backend.location.model.Location; +import org.fleetassistant.backend.user.model.Manager; +import org.fleetassistant.backend.user.service.DriverService; +import org.fleetassistant.backend.user.service.ManagerService; +import org.fleetassistant.backend.user.service.UserService; +import org.fleetassistant.backend.utils.EntityToDtoMapper; +import org.fleetassistant.backend.vehicle.model.Vehicle; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.ExampleMatcher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; + +import static org.springframework.data.domain.ExampleMatcher.GenericPropertyMatchers.exact; + +@Service +@RequiredArgsConstructor +public class VehicleService { + public static final String CAR_WITH_VIN_ALREADY_EXISTS = "Car with vin: %s already exists"; + public static final String VEHICLE_WITH_ID_NOT_FOUND = "Car with id: %d not found"; + private final VehicleRepository vehicleRepository; + private final EntityToDtoMapper entityToDtoMapper; + private final LocationService locationService; + private final UserService userService; + private final ManagerService managerService; + private final DriverService driverService; + + @Transactional + public org.fleetassistant.backend.dto.Vehicle create(org.fleetassistant.backend.dto.Vehicle vehicleDTO) { + Vehicle vehicle = entityToDtoMapper.vehicleDtoToVehicle(vehicleDTO); + if (isVehicleExists(vehicle)) + throw new ObjectAlreadyExistsException(String.format(CAR_WITH_VIN_ALREADY_EXISTS, vehicle.getVin())); + + Credentials credentials = CredentialsService.getCredentials(); + Manager manager = managerService.getManagerByEmail(credentials.getEmail()); + vehicle.setManager(manager); + + if (vehicleDTO.driverId() != null) { + var driver = driverService.getDriverById(vehicleDTO.driverId()); + vehicle.setDriver(driver); + } + + vehicle.setNextInspectionDate(calculateNextInspectionDate(vehicle.getProductionDate(), vehicle.getLastInspectionDate())); + return entityToDtoMapper.vehicleToVehicleDto(vehicleRepository.save(vehicle)); + } + + public Page readAll(Pageable pageable) { + Credentials credentials = CredentialsService.getCredentials(); + Long id = userService.getUserIdByEmail(credentials.getEmail()); + if (credentials.getRole().equals(Role.MANAGER)) { + return vehicleRepository.findAllByManagerId(id, pageable).map(entityToDtoMapper::vehicleToVehicleDto); + } else { + return vehicleRepository.findAllByDriverId(id, pageable).map(entityToDtoMapper::vehicleToVehicleDto); + } + } + + + public org.fleetassistant.backend.dto.Vehicle readById(Long id) { + return entityToDtoMapper.vehicleToVehicleDto(readVehicleById(id)); + } + + public boolean isVehicleExists(Vehicle car) { + ExampleMatcher matcher = ExampleMatcher.matching() + .withIgnorePaths("id") + .withIgnorePaths("driver") + .withIgnorePaths("locations") + .withMatcher("vin", exact()) + .withMatcher("plateNumber", exact()) + .withMatcher("countryCode", exact()); + return vehicleRepository.exists(Example.of(car, matcher)); + } + + public org.fleetassistant.backend.dto.Location updateLocation(org.fleetassistant.backend.dto.Location locationDTO, Long id) { + Location location = entityToDtoMapper.locationDtoToLocation(locationDTO); + Vehicle vehicle = readVehicleById(id); + location.setVehicle(vehicle); + return locationService.create(location); + } + + @Transactional + public void deleteLocations(Long id) { + Vehicle vehicle = readVehicleById(id); + locationService.deleteAllByVehicleId(vehicle.getId()); + } + + private Vehicle readVehicleById(Long id) { + return vehicleRepository.findById(id) + .orElseThrow(() -> new NoSuchObjectException(String.format(VEHICLE_WITH_ID_NOT_FOUND, id))); + } + public org.fleetassistant.backend.dto.Vehicle assignDriver(Long id, Long driverId) { + Vehicle vehicle = readVehicleById(id); + vehicle.setDriver(driverService.getDriverById(driverId)); + return entityToDtoMapper.vehicleToVehicleDto(vehicleRepository.save(vehicle)); + } + + static LocalDate calculateNextInspectionDate(LocalDate productionDate, LocalDate lastInspectionDate) { + if (productionDate != null) { + int age = LocalDate.now().getYear() - productionDate.getYear(); + if (age < 4) { + return productionDate.plusYears(4); + } else if (age < 10) { + return lastInspectionDate != null ? lastInspectionDate.plusYears(2) : productionDate.plusYears(2); + } else { + return lastInspectionDate != null ? lastInspectionDate.plusYears(1) : productionDate.plusYears(4); + } + } + return null; + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/vehicle/controller/VehicleController.java b/backend/src/main/java/org/fleetassistant/backend/vehicle/controller/VehicleController.java new file mode 100644 index 0000000..082a029 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/vehicle/controller/VehicleController.java @@ -0,0 +1,79 @@ +package org.fleetassistant.backend.vehicle.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.fleetassistant.backend.dto.Location; +import org.fleetassistant.backend.dto.Vehicle; +import org.fleetassistant.backend.location.LocationService; +import org.fleetassistant.backend.vehicle.VehicleService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; + +import java.time.Duration; + +@RestController +@RequestMapping("/api/v1/vehicle") +@RequiredArgsConstructor +public class VehicleController { + private final VehicleService vehicleService; + private final LocationService locationService; + + @PostMapping + public ResponseEntity create(@RequestBody @Valid Vehicle vehicle) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(vehicleService.create(vehicle)); + } + + @GetMapping + public ResponseEntity> readAll(Pageable pageable) { + return ResponseEntity.ok(vehicleService.readAll(pageable)); + } + + @GetMapping("/{id}") + public ResponseEntity readById(@PathVariable Long id) { + return ResponseEntity.ok(vehicleService.readById(id)); + } + + @PostMapping("/{id}/location") + public ResponseEntity updateLocation(@RequestBody @Valid Location locationDTO, + @PathVariable Long id) { + return ResponseEntity.ok(vehicleService.updateLocation(locationDTO, id)); + } + + @DeleteMapping("/{id}/location") + public ResponseEntity deleteLocations(@PathVariable Long id) { + vehicleService.deleteLocations(id); + return ResponseEntity.noContent().build(); + } + + @GetMapping(path = "/{id}/location-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux> locationStream(@PathVariable Long id) { + Location initialPage = locationService.readLastLocation(id); + + Flux> initialData = Flux.just(ServerSentEvent.builder() + .data(initialPage) + .build()); + Flux> periodicUpdates = Flux.interval(Duration.ofSeconds(5)) + .flatMap(sequence -> { + Location updatedPage = + locationService.readLastLocation(id); + return Flux.just(ServerSentEvent.builder() + .id(String.valueOf(sequence)) + .data(updatedPage) + .build()); + }); + + return Flux.concat(initialData, periodicUpdates); + } + + @PostMapping("/{id}/assign-driver/{driverId}") + public ResponseEntity assignDriver(@PathVariable Long id, @PathVariable Long driverId) { + return ResponseEntity.ok(vehicleService.assignDriver(id, driverId)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/fleetassistant/backend/vehicle/model/Vehicle.java b/backend/src/main/java/org/fleetassistant/backend/vehicle/model/Vehicle.java new file mode 100644 index 0000000..1c9da83 --- /dev/null +++ b/backend/src/main/java/org/fleetassistant/backend/vehicle/model/Vehicle.java @@ -0,0 +1,60 @@ +package org.fleetassistant.backend.vehicle.model; + +import jakarta.persistence.*; +import lombok.*; +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; +import org.fleetassistant.backend.location.model.Location; +import org.fleetassistant.backend.user.model.Driver; +import org.fleetassistant.backend.user.model.Manager; + +import java.time.LocalDate; +import java.util.List; + + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Vehicle { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + @Column(nullable = false, length = 50) + private String vin; + @Column(length = 15) + private String plateNumber; + @Column(length = 3) + private String countryCode; + private LocalDate insuranceDate; + private LocalDate lastInspectionDate; + private LocalDate nextInspectionDate; + private LocalDate productionDate; + @OneToOne + private Driver driver; + @ManyToOne + @JoinColumn(name = "manager_id") + private Manager manager; + @OneToMany(mappedBy = "vehicle") + private List locations; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Vehicle vehicle = (Vehicle) o; + return new EqualsBuilder().append(id, vehicle.id) + .append(vin, vehicle.vin) + .append(plateNumber, vehicle.plateNumber) + .append(countryCode, vehicle.countryCode) + .isEquals(); + } + + @Override + public int hashCode() { + return new HashCodeBuilder(17, 37).append(id).append(vin).append(plateNumber).append(countryCode).toHashCode(); + } +} \ No newline at end of file diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml new file mode 100644 index 0000000..a146e0a --- /dev/null +++ b/backend/src/main/resources/application-local.yml @@ -0,0 +1,11 @@ +spring: + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + format_sql: true + show_sql: false + liquibase: + change-log: classpath:db/changelog/db.changelog-master.yml + enabled: true \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..5e5f67f --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,35 @@ +spring: + application: + name: backend + profiles: + active: local + data: + web: + pageable: + default-page-size: 10 + datasource: + username: ${SQL_USERNAME:admin} + url: ${SQL_URI:jdbc:postgresql://localhost:5432/fleet_assistant} + password: ${SQL_PASSWORD:admin} + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + show_sql: false + liquibase: + change-log: classpath:db/changelog/db.changelog-master.yml + enabled: false + security: + oauth2: + resourceserver: + jwt: + issuer-uri: https://accounts.google.com + jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs + jwt: + secret: ${JWT_SECRET:MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMGBoG93o/faIHuvPr0eryGKSX3FsQUkdeWJo62tTU8xD8hFqDlj7iMrZFz/I7e01bpqx8sGr4sVM8gmoIUj1zUCAwEAAQ==} +logging: + level: + org.springframework.security: DEBUG \ No newline at end of file diff --git a/backend/src/main/resources/banner.txt b/backend/src/main/resources/banner.txt new file mode 100644 index 0000000..afc3bdb --- /dev/null +++ b/backend/src/main/resources/banner.txt @@ -0,0 +1,6 @@ +,------.,--. ,--. ,---. ,--. ,--. ,--. +| .---'| |,---. ,---. ,-' '-. / O \ ,---. ,---.`--' ,---.,-' '-.,--,--.,--,--, ,-' '-. +| `--, | | .-. :| .-. :'-. .-' | .-. |( .-' ( .-',--.( .-''-. .-' ,-. || \'-. .-' +| |` | \ --.\ --. | | | | | |.-' `).-' `) |.-' `) | | \ '-' || || | | | +`--' `--'`----' `----' `--' `--' `--'`----' `----'`--'`----' `--' `--`--'`--''--' `--' +Powered by Spring Boot ${spring-boot.version} \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.yml b/backend/src/main/resources/db/changelog/db.changelog-master.yml new file mode 100644 index 0000000..6853945 --- /dev/null +++ b/backend/src/main/resources/db/changelog/db.changelog-master.yml @@ -0,0 +1,8 @@ +#https://docs.liquibase.com/home.html +databaseChangeLog: + - includeAll: + path: db/changelog/initial/ + - includeAll: + path: db/changelog/test/ +# - includeAll: +# path: db/changelog/update/ \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/initial/db.changelog-init-01-credentials.yml b/backend/src/main/resources/db/changelog/initial/db.changelog-init-01-credentials.yml new file mode 100644 index 0000000..ac51308 --- /dev/null +++ b/backend/src/main/resources/db/changelog/initial/db.changelog-init-01-credentials.yml @@ -0,0 +1,29 @@ +databaseChangeLog: + - changeSet: + id: 1 + author: "269545" + changes: + - createTable: + tableName: credentials + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: email + type: VARCHAR(50) + constraints: + nullable: false + unique: true + - column: + name: password + type: VARCHAR(255) + - column: + name: role + type: VARCHAR(7) + constraints: + nullable: false \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/initial/db.changelog-init-03-fauser.yml b/backend/src/main/resources/db/changelog/initial/db.changelog-init-03-fauser.yml new file mode 100644 index 0000000..252de0b --- /dev/null +++ b/backend/src/main/resources/db/changelog/initial/db.changelog-init-03-fauser.yml @@ -0,0 +1,37 @@ +databaseChangeLog: + - changeSet: + id: 3 + author: "269545" + changes: + - createTable: + tableName: fauser + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + - column: + name: name + type: VARCHAR(50) + constraints: + nullable: false + - column: + name: surname + type: VARCHAR(50) + constraints: + nullable: false + - column: + name: phone + type: VARCHAR(14) + - column: + name: credentials_id + type: BIGINT + - addForeignKeyConstraint: + constraintName: credentials_fk + baseTableName: fauser + baseColumnNames: credentials_id + referencedTableName: credentials + referencedColumnNames: id + onDelete: CASCADE \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/initial/db.changelog-init-05-manager.yml b/backend/src/main/resources/db/changelog/initial/db.changelog-init-05-manager.yml new file mode 100644 index 0000000..36b765f --- /dev/null +++ b/backend/src/main/resources/db/changelog/initial/db.changelog-init-05-manager.yml @@ -0,0 +1,16 @@ +databaseChangeLog: + - changeSet: + id: 5 + author: "269545" + changes: + - createTable: + tableName: manager + columns: + - column: + name: id + type: BIGINT + constraints: + primaryKey: true + - column: + name: created_on + type: DATE \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/initial/db.changelog-init-07-driver.yml b/backend/src/main/resources/db/changelog/initial/db.changelog-init-07-driver.yml new file mode 100644 index 0000000..2a866dc --- /dev/null +++ b/backend/src/main/resources/db/changelog/initial/db.changelog-init-07-driver.yml @@ -0,0 +1,43 @@ +databaseChangeLog: + - changeSet: + id: 7 + author: "269545" + changes: + - createTable: + tableName: driver + columns: + - column: + name: id + type: BIGINT + constraints: + primaryKey: true + - column: + name: driving_license_number + type: VARCHAR(20) + constraints: + nullable: false + - column: + name: driving_license_country_code + type: VARCHAR(3) + constraints: + nullable: false + - column: + name: birth_date + type: DATE + constraints: + nullable: false + - column: + name: created_on + type: DATE + - column: + name: manager_id + type: BIGINT + constraints: + nullable: false + - addForeignKeyConstraint: + constraintName: manager_fk + baseTableName: driver + baseColumnNames: manager_id + referencedTableName: manager + referencedColumnNames: id + onDelete: CASCADE \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/initial/db.changelog-init-09-vehicle.yml b/backend/src/main/resources/db/changelog/initial/db.changelog-init-09-vehicle.yml new file mode 100644 index 0000000..c699042 --- /dev/null +++ b/backend/src/main/resources/db/changelog/initial/db.changelog-init-09-vehicle.yml @@ -0,0 +1,61 @@ +databaseChangeLog: + - changeSet: + id: 9 + author: "269545" + changes: + - createTable: + tableName: vehicle + columns: + - column: + name: id + type: bigint + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: name + type: varchar(255) + - column: + name: vin + type: varchar(50) + constraints: + nullable: false + - column: + name: plate_number + type: varchar(15) + - column: + name: country_code + type: varchar(3) + - column: + name: insurance_date + type: date + - column: + name: last_inspection_date + type: date + - column: + name: next_inspection_date + type: date + - column: + name: production_date + type: date + - column: + name: driver_id + type: bigint + - column: + name: manager_id + type: bigint + - addForeignKeyConstraint: + baseTableName: vehicle + baseColumnNames: manager_id + referencedTableName: manager + referencedColumnNames: id + constraintName: fk_vehicle_manager + onDelete: CASCADE + - addForeignKeyConstraint: + baseTableName: vehicle + baseColumnNames: driver_id + referencedTableName: driver + referencedColumnNames: id + constraintName: fk_vehicle_driver + onDelete: SET NULL \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/initial/db.changelog-init-11-location.yml b/backend/src/main/resources/db/changelog/initial/db.changelog-init-11-location.yml new file mode 100644 index 0000000..f9d5ace --- /dev/null +++ b/backend/src/main/resources/db/changelog/initial/db.changelog-init-11-location.yml @@ -0,0 +1,37 @@ +databaseChangeLog: + - changeSet: + id: 11 + author: "269545" + changes: + - createTable: + tableName: location + columns: + - column: + name: id + type: bigint + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: longitude + type: float + constraints: + nullable: false + - column: + name: latitude + type: float + constraints: + nullable: false + - column: + name: timestamp + type: date + - column: + name: vehicle_id + type: bigint + - addForeignKeyConstraint: + baseTableName: location + baseColumnNames: vehicle_id + referencedTableName: vehicle + referencedColumnNames: id + constraintName: fk_vehicle_location \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/test/db.changelog-test-02-credentials.yml b/backend/src/main/resources/db/changelog/test/db.changelog-test-02-credentials.yml new file mode 100644 index 0000000..cf0e460 --- /dev/null +++ b/backend/src/main/resources/db/changelog/test/db.changelog-test-02-credentials.yml @@ -0,0 +1,53 @@ +databaseChangeLog: + - changeSet: + id: 2 + author: "269545" + changes: + - insert: + tableName: credentials + columns: + - column: + name: email + value: "manager@manager.com" + - column: + name: password + value: "$2a$10$ikr2tYbJZ/d1qt2AiSk7eOimMC24ug.jCWoP9Q3VhkjRh7oMA0xp2" + - column: + name: role + value: "MANAGER" + - insert: + tableName: credentials + columns: + - column: + name: email + value: "manager2@manager.com" + - column: + name: password + value: "$2a$10$ikr2tYbJZ/d1qt2AiSk7eOimMC24ug.jCWoP9Q3VhkjRh7oMA0xp2" + - column: + name: role + value: "MANAGER" + - insert: + tableName: credentials + columns: + - column: + name: email + value: "driver@driver.com" + - column: + name: password + value: "$2a$10$ikr2tYbJZ/d1qt2AiSk7eOimMC24ug.jCWoP9Q3VhkjRh7oMA0xp2" + - column: + name: role + value: "DRIVER" + - insert: + tableName: credentials + columns: + - column: + name: email + value: "driver2@driver.com" + - column: + name: password + value: "$2a$10$ikr2tYbJZ/d1qt2AiSk7eOimMC24ug.jCWoP9Q3VhkjRh7oMA0xp2" + - column: + name: role + value: "DRIVER" \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/test/db.changelog-test-04-fauser.yml b/backend/src/main/resources/db/changelog/test/db.changelog-test-04-fauser.yml new file mode 100644 index 0000000..356b9a8 --- /dev/null +++ b/backend/src/main/resources/db/changelog/test/db.changelog-test-04-fauser.yml @@ -0,0 +1,65 @@ +databaseChangeLog: + - changeSet: + id: 4 + author: "269545" + changes: + - insert: + tableName: fauser + columns: + - column: + name: name + value: "John" + - column: + name: surname + value: "Doe" + - column: + name: phone + value: "1234567890" + - column: + name: credentials_id + value: 1 + - insert: + tableName: fauser + columns: + - column: + name: name + value: "Jane" + - column: + name: surname + value: "Smith" + - column: + name: phone + value: "0987654321" + - column: + name: credentials_id + value: 2 + - insert: + tableName: fauser + columns: + - column: + name: name + value: "Mike" + - column: + name: surname + value: "Brown" + - column: + name: phone + value: "1112223333" + - column: + name: credentials_id + value: 3 + - insert: + tableName: fauser + columns: + - column: + name: name + value: "Sarah" + - column: + name: surname + value: "Johnson" + - column: + name: phone + value: "4445556666" + - column: + name: credentials_id + value: 4 \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/test/db.changelog-test-06-manager.yml b/backend/src/main/resources/db/changelog/test/db.changelog-test-06-manager.yml new file mode 100644 index 0000000..51b8f69 --- /dev/null +++ b/backend/src/main/resources/db/changelog/test/db.changelog-test-06-manager.yml @@ -0,0 +1,23 @@ +databaseChangeLog: + - changeSet: + id: 6 + author: "269545" + changes: + - insert: + tableName: manager + columns: + - column: + name: id + value: 1 + - column: + name: created_on + value: "2024-10-05" + - insert: + tableName: manager + columns: + - column: + name: id + value: 2 + - column: + name: created_on + value: "2024-10-05" \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/test/db.changelog-test-08-driver.yml b/backend/src/main/resources/db/changelog/test/db.changelog-test-08-driver.yml new file mode 100644 index 0000000..d331ae7 --- /dev/null +++ b/backend/src/main/resources/db/changelog/test/db.changelog-test-08-driver.yml @@ -0,0 +1,47 @@ +databaseChangeLog: + - changeSet: + id: 8 + author: "269545" + changes: + - insert: + tableName: driver + columns: + - column: + name: id + value: 3 + - column: + name: driving_license_number + value: "D123456789" + - column: + name: driving_license_country_code + value: "US" + - column: + name: birth_date + value: "1985-05-15" + - column: + name: created_on + value: "2024-10-05" + - column: + name: manager_id + value: 1 + - insert: + tableName: driver + columns: + - column: + name: id + value: 4 + - column: + name: driving_license_number + value: "B123456789" + - column: + name: driving_license_country_code + value: "PL" + - column: + name: birth_date + value: "1985-05-15" + - column: + name: created_on + value: "2024-10-05" + - column: + name: manager_id + value: 1 \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/test/db.changelog-test-10-vehicle.yml b/backend/src/main/resources/db/changelog/test/db.changelog-test-10-vehicle.yml new file mode 100644 index 0000000..6d8f20a --- /dev/null +++ b/backend/src/main/resources/db/changelog/test/db.changelog-test-10-vehicle.yml @@ -0,0 +1,140 @@ +databaseChangeLog: + - changeSet: + id: 10 + author: "269545" + changes: + - insert: + tableName: vehicle + columns: + - column: + name: name + value: "Car A" + - column: + name: vin + value: "1HGCM82633A123456" + - column: + name: plate_number + value: "ABC123" + - column: + name: country_code + value: "USA" + - column: + name: insurance_date + value: "2024-10-20" + - column: + name: last_inspection_date + value: "2024-05-15" + - column: + name: next_inspection_date + value: "2025-05-15" + - column: + name: production_date + value: "2004-01-10" + - column: + name: driver_id + value: 3 + - column: + name: manager_id + value: 1 + + - insert: + tableName: vehicle + columns: + - column: + name: name + value: "Car B" + - column: + name: vin + value: "1HGCM82633A654321" + - column: + name: plate_number + value: "XYZ789" + - column: + name: country_code + value: "USA" + - column: + name: insurance_date + value: "2024-10-20" + - column: + name: last_inspection_date + value: "2024-06-10" + - column: + name: next_inspection_date + value: "2025-06-10" + - column: + name: production_date + value: "2010-02-15" + - column: + name: driver_id + value: 4 + - column: + name: manager_id + value: 1 + + - insert: + tableName: vehicle + columns: + - column: + name: name + value: "Car C" + - column: + name: vin + value: "1HGCM82633A987654" + - column: + name: plate_number + value: "LMN456" + - column: + name: country_code + value: "USA" + - column: + name: insurance_date + value: "2024-10-20" + - column: + name: last_inspection_date + value: "2024-07-05" + - column: + name: next_inspection_date + value: "2025-07-05" + - column: + name: production_date + value: "2010-03-20" + - column: + name: driver_id + value: null + - column: + name: manager_id + value: 1 + + - insert: + tableName: vehicle + columns: + - column: + name: name + value: "Car D" + - column: + name: vin + value: "1HGCM82633A246813" + - column: + name: plate_number + value: "QRS258" + - column: + name: country_code + value: "USA" + - column: + name: insurance_date + value: "2024-10-20" + - column: + name: last_inspection_date + value: "2024-08-20" + - column: + name: next_inspection_date + value: "2025-08-20" + - column: + name: production_date + value: "2012-11-30" + - column: + name: driver_id + value: null + - column: + name: manager_id + value: 1 diff --git a/backend/src/main/resources/db/changelog/test/db.changelog-test-12-location.yml b/backend/src/main/resources/db/changelog/test/db.changelog-test-12-location.yml new file mode 100644 index 0000000..2c84faf --- /dev/null +++ b/backend/src/main/resources/db/changelog/test/db.changelog-test-12-location.yml @@ -0,0 +1,53 @@ +databaseChangeLog: + - changeSet: + id: 12 + author: "269545" + changes: + - insert: + tableName: location + columns: + - column: + name: longitude + value: 17.085214 + - column: + name: latitude + value: 51.104287 + - column: + name: vehicle_id + value: 1 + - insert: + tableName: location + columns: + - column: + name: longitude + value: 17.070022 + - column: + name: latitude + value: 51.112532 + - column: + name: vehicle_id + value: 2 + - insert: + tableName: location + columns: + - column: + name: longitude + value: 17.087017 + - column: + name: latitude + value: 51.117273 + - column: + name: vehicle_id + value: 3 + - insert: + tableName: location + columns: + - column: + name: longitude + value: 17.085214 + - column: + name: latitude + value: 51.104287 + - column: + name: vehicle_id + value: 4 diff --git a/backend/src/test/java/org/fleetassistant/backend/BackendApplicationTests.java b/backend/src/test/java/org/fleetassistant/backend/BackendApplicationTests.java new file mode 100644 index 0000000..d054e15 --- /dev/null +++ b/backend/src/test/java/org/fleetassistant/backend/BackendApplicationTests.java @@ -0,0 +1,11 @@ +package org.fleetassistant.backend; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BackendApplicationTests { + @Test + void contextLoads() { + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/fleetassistant/backend/auth/AuthenticationServiceTest.java b/backend/src/test/java/org/fleetassistant/backend/auth/AuthenticationServiceTest.java new file mode 100644 index 0000000..ea19484 --- /dev/null +++ b/backend/src/test/java/org/fleetassistant/backend/auth/AuthenticationServiceTest.java @@ -0,0 +1,152 @@ +package org.fleetassistant.backend.auth; + +import org.fleetassistant.backend.auth.credentials.CredentialsService; +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.auth.credentials.model.Role; +import org.fleetassistant.backend.auth.models.AuthenticationRequest; +import org.fleetassistant.backend.auth.models.AuthenticationResponse; +import org.fleetassistant.backend.auth.models.RegisterRequest; +import org.fleetassistant.backend.dto.User; +import org.fleetassistant.backend.exceptionhandler.rest.ObjectAlreadyExistsException; +import org.fleetassistant.backend.dto.Token; +import org.fleetassistant.backend.jwt.service.JwtService; +import org.fleetassistant.backend.jwt.service.TokenGenerator; +import org.fleetassistant.backend.user.model.Manager; +import org.fleetassistant.backend.user.service.ManagerService; +import org.fleetassistant.backend.user.service.UserService; +import org.fleetassistant.backend.utils.EntityToDtoMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.fleetassistant.backend.utils.Constants.ALREADY_REGISTERED; +import static org.fleetassistant.backend.utils.Constants.USER_DOESNT_EXIST; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthenticationServiceTest { + @Mock + private CredentialsService credentialsService; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private AuthenticationManager authenticationManager; + + @Mock + private TokenGenerator tokenGenerator; + + @Mock + private JwtService jwtService; + + @Mock + private ManagerService managerService; + + @Mock + private UserService userService; + + @Mock + private EntityToDtoMapper entityToDtoMapper; + + @InjectMocks + private AuthenticationService authenticationService; + + private Credentials credentials; + private Manager manager; + private User user; + + @BeforeEach + void setUp() { + credentials = Credentials.builder() + .email("test@example.com") + .password("password123") + .role(Role.MANAGER) + .build(); + manager = Manager.builder().name("John").surname("Doe").credentials(credentials).build(); + user = User.builder().name("John").surname("Doe").email("test@example.com").role(Role.MANAGER).build(); + } + + @Test + void register_existingUser_throwsException() { + // Given + when(credentialsService.ifCredentialsExist("new@example.com")).thenReturn(true); + + // When / Then + ObjectAlreadyExistsException exception = assertThrows(ObjectAlreadyExistsException.class, + () -> authenticationService.register(new RegisterRequest("John", "Doe", "new@example.com", "password", ""))); + assertEquals(ALREADY_REGISTERED, exception.getMessage()); + } + + @Test + void register_newUser_createsAndReturnsAuthenticationResponse() { + // Given + when(credentialsService.ifCredentialsExist(anyString())).thenReturn(false); + when(passwordEncoder.encode(anyString())).thenReturn("encodedPassword"); + when(credentialsService.create(anyString(), anyString(), any(Role.class))).thenReturn(credentials); + when(managerService.createManager(any(RegisterRequest.class))).thenReturn(manager); + when(tokenGenerator.createToken(any(Credentials.class))).thenReturn(Token.builder().accessToken("access").build()); + when(entityToDtoMapper.userToUserDto(any(Manager.class))).thenReturn(user); + // When + AuthenticationResponse response = authenticationService.register(new RegisterRequest("John", "Doe", "new@example.com", "password", "")); + + // Then + assertNotNull(response); + assertEquals("access", response.getToken().accessToken()); + assertEquals("John", response.getUser().name()); + assertEquals("Doe", response.getUser().surname()); + assertEquals(Role.MANAGER, response.getUser().role()); + assertEquals("test@example.com", response.getUser().email()); + } + + @Test + void authenticate_invalidUser_throwsException() { + // Given + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))).thenReturn(null); + when(credentialsService.loadUserByUsername(anyString())).thenReturn(null); + // When / Then + UsernameNotFoundException exception = assertThrows(UsernameNotFoundException.class, + () -> authenticationService.authenticate(new AuthenticationRequest("invalid@example.com", "password"))); + assertEquals(USER_DOESNT_EXIST, exception.getMessage()); + } + + @Test + void authenticate_validUser_returnsAuthenticationResponse() { + // Given + when(authenticationManager.authenticate(any(UsernamePasswordAuthenticationToken.class))).thenReturn(null); + when(credentialsService.loadUserByUsername(anyString())).thenReturn(credentials); + when(userService.getUserByEmail(anyString())).thenReturn(user); + when(tokenGenerator.createToken(any(Credentials.class))).thenReturn(Token.builder().accessToken("access").build()); + // When + AuthenticationResponse response = authenticationService.authenticate(new AuthenticationRequest("valid@example.com", "password")); + // Then + assertNotNull(response); + assertEquals("access", response.getToken().accessToken()); + assertEquals("John", response.getUser().name()); + assertEquals("Doe", response.getUser().surname()); + assertEquals(Role.MANAGER, response.getUser().role()); + assertEquals("test@example.com", response.getUser().email()); + } + + @Test + void refreshToken_validJwt_returnsNewToken() { + // Given + when(jwtService.extractUsername(anyString())).thenReturn("test@example.com"); + when(credentialsService.loadUserByUsername(anyString())).thenReturn(credentials); + when(tokenGenerator.createAccessToken(any(Credentials.class))).thenReturn("newAccessToken"); + // When + String newToken = authenticationService.refreshToken("validJwt"); + // Then + assertEquals("newAccessToken", newToken); + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/fleetassistant/backend/auth/credentials/CredentialsServiceTest.java b/backend/src/test/java/org/fleetassistant/backend/auth/credentials/CredentialsServiceTest.java new file mode 100644 index 0000000..1867f91 --- /dev/null +++ b/backend/src/test/java/org/fleetassistant/backend/auth/credentials/CredentialsServiceTest.java @@ -0,0 +1,100 @@ +package org.fleetassistant.backend.auth.credentials; + +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.auth.credentials.model.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CredentialsServiceTest { + @Mock + private CredentialsRepository credentialsRepository; + + @InjectMocks + private CredentialsService credentialsService; + + private Credentials credentials; + + @BeforeEach + void setUp() { + credentials = Credentials.builder() + .email("test@example.com") + .password("password123") + .role(Role.MANAGER) + .build(); + } + + @Test + void loadUserByUsername_existingUser_returnsUser() { + // Given + when(credentialsRepository.findByEmail(anyString())).thenReturn(Optional.of(credentials)); + // When + Credentials result = credentialsService.loadUserByUsername("test@example.com"); + // Then + assertNotNull(result); + assertEquals("test@example.com", result.getEmail()); + } + + @Test + void loadUserByUsername_nonExistingUser_returnsNull() { + // Given + when(credentialsRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + // When + Credentials result = credentialsService.loadUserByUsername("nonexistent@example.com"); + // Then + assertNull(result); + } + + @Test + void create_withRole_savesAndReturnsCredentials() { + // Given + when(credentialsRepository.save(any(Credentials.class))).thenReturn(credentials); + // When + Credentials result = credentialsService.create("newuser@example.com", Role.MANAGER); + // Then + assertNotNull(result); + assertEquals("test@example.com", result.getEmail()); + } + + @Test + void create_withPasswordAndRole_savesAndReturnsCredentials() { + // Given + when(credentialsRepository.save(any(Credentials.class))).thenReturn(credentials); + // When + Credentials result = credentialsService.create("newuser@example.com", "password123", Role.MANAGER); + // Then + assertNotNull(result); + assertEquals("test@example.com", result.getEmail()); + } + + @Test + void ifCredentialsExist_existingUser_returnsTrue() { + // Given + when(credentialsRepository.findByEmail(anyString())).thenReturn(Optional.of(credentials)); + // When + boolean result = credentialsService.ifCredentialsExist("test@example.com"); + // Then + assertTrue(result); + } + + @Test + void ifCredentialsExist_nonExistingUser_returnsFalse() { + // Given + when(credentialsRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + // When + boolean result = credentialsService.ifCredentialsExist("nonexistent@example.com"); + // Then + assertFalse(result); + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/fleetassistant/backend/jwt/service/JwtServiceTest.java b/backend/src/test/java/org/fleetassistant/backend/jwt/service/JwtServiceTest.java new file mode 100644 index 0000000..4084202 --- /dev/null +++ b/backend/src/test/java/org/fleetassistant/backend/jwt/service/JwtServiceTest.java @@ -0,0 +1,96 @@ +package org.fleetassistant.backend.jwt.service; + +import io.jsonwebtoken.Claims; +import org.fleetassistant.backend.exceptionhandler.nonrest.IncorrectTokenTypeException; +import org.fleetassistant.backend.jwt.model.TokenType; +import org.fleetassistant.backend.utils.Constants; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +import java.security.Key; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JwtServiceTest { + + @Mock + private CacheManager cacheManager; + + @Mock + private KeyService keyService; + + private final Cache cache = Mockito.mock(Cache.class); + + @Mock + private Key key; + + @InjectMocks + @Spy + private JwtService jwtService; + + private static final String TOKEN = "test.Token.test"; + private static final long USER_ID = 1L; + + @Test + void extractUsername_validToken_shouldReturnUsername() { + // Given + Claims claims = mock(Claims.class); + when(claims.getSubject()).thenReturn("testUser"); + doReturn(claims).when(jwtService).extractAllClaims(TOKEN); + + // When + String username = jwtService.extractUsername(TOKEN); + + // Then + assertEquals("testUser", username); + } + + @Test + void invalidateTokenIfExist_accessTokenExists_shouldAddToBlacklist() throws IncorrectTokenTypeException { + // Given + when(cacheManager.getCache(Constants.ACCESS_TOKENS)).thenReturn(cache); + when(cache.get(USER_ID)).thenReturn(() -> TOKEN); + + Cache blackListCache = mock(Cache.class); + when(cacheManager.getCache(Constants.BLACK_LIST)).thenReturn(blackListCache); + when(blackListCache.get(USER_ID)).thenReturn(null); + + // When + jwtService.invalidateTokenIfExist(USER_ID, TokenType.ACCESS_TOKEN); + + // Then + verify(cacheManager, times(1)).getCache(Constants.ACCESS_TOKENS); + verify(cache, times(1)).get(USER_ID); + verify(cacheManager, times(1)).getCache(Constants.BLACK_LIST); + verify(blackListCache, times(1)).put(eq(USER_ID), anyList()); // Ensure token is added to blacklist + } + + @Test + void addTokenToBlackList_shouldAddTokenToCache() throws IncorrectTokenTypeException { + // Given + Cache accessTokenCache = mock(Cache.class); + Cache blackListCache = mock(Cache.class); + + when(cacheManager.getCache(Constants.ACCESS_TOKENS)).thenReturn(accessTokenCache); + when(cacheManager.getCache(Constants.BLACK_LIST)).thenReturn(blackListCache); + when(accessTokenCache.get(USER_ID)).thenReturn(() -> TOKEN); + + // When + jwtService.invalidateTokenIfExist(USER_ID, TokenType.ACCESS_TOKEN); + + // Then + verify(cacheManager).getCache(Constants.ACCESS_TOKENS); + verify(accessTokenCache).get(USER_ID); + verify(cacheManager).getCache(Constants.BLACK_LIST); + verify(blackListCache).put(eq(USER_ID), anyList()); + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/fleetassistant/backend/jwt/service/TokenGeneratorTest.java b/backend/src/test/java/org/fleetassistant/backend/jwt/service/TokenGeneratorTest.java new file mode 100644 index 0000000..ef79451 --- /dev/null +++ b/backend/src/test/java/org/fleetassistant/backend/jwt/service/TokenGeneratorTest.java @@ -0,0 +1,114 @@ +package org.fleetassistant.backend.jwt.service; + +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.exceptionhandler.rest.CacheException; +import org.fleetassistant.backend.dto.Token; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TokenGeneratorTest { + @Mock + private CacheManager cacheManager; + + @Mock + private JwtService jwtService; + + @Mock + private Cache accessTokenCache; + + @Mock + private Cache refreshTokenCache; + + @InjectMocks + private TokenGenerator tokenGenerator; + + @Test + void createAccessToken_success_createsAndCachesToken() { + // Given + Credentials credentials = mock(Credentials.class); + when(credentials.getId()).thenReturn(123L); + when(jwtService.createSignedJwt(any(), any())).thenReturn("accessToken"); + when(cacheManager.getCache("accessTokens")).thenReturn(accessTokenCache); + // When + String token = tokenGenerator.createAccessToken(credentials); + // Then + assertEquals("accessToken", token); + verify(accessTokenCache).put(123L, "accessToken"); + } + + @Test + void createAccessToken_cacheNotFound_throwsException() { + // Given + Credentials credentials = mock(Credentials.class); + when(credentials.getId()).thenReturn(123L); + when(jwtService.createSignedJwt(any(), any())).thenReturn("accessToken"); + when(cacheManager.getCache("accessTokens")).thenReturn(null); + // When / Then + CacheException exception = assertThrows(CacheException.class, () -> tokenGenerator.createAccessToken(credentials)); + assertEquals("Cache not found", exception.getMessage()); + } + + @Test + void createRefreshToken_success_createsAndCachesToken() { + // Given + Credentials credentials = mock(Credentials.class); + when(credentials.getId()).thenReturn(123L); + when(jwtService.createSignedJwt(any(), any())).thenReturn("refreshToken"); + when(cacheManager.getCache("refreshTokens")).thenReturn(refreshTokenCache); + // When + String token = tokenGenerator.createRefreshToken(credentials); + // Then + assertEquals("refreshToken", token); + verify(refreshTokenCache).put(123L, "refreshToken"); + } + + @Test + void createRefreshToken_cacheNotFound_throwsException() { + // Given + Credentials credentials = mock(Credentials.class); + when(credentials.getId()).thenReturn(123L); + when(jwtService.createSignedJwt(any(), any())).thenReturn("refreshToken"); + when(cacheManager.getCache("refreshTokens")).thenReturn(null); + // When / Then + CacheException exception = assertThrows(CacheException.class, () -> tokenGenerator.createRefreshToken(credentials)); + assertEquals("Cache not found", exception.getMessage()); + } + + @Test + void createToken_success_createsAccessAndRefreshToken() { + // Given + Credentials credentials = mock(Credentials.class); + when(credentials.getId()).thenReturn(123L); + when(jwtService.createSignedJwt(any(), any())).thenReturn("accessToken", "refreshToken"); + when(cacheManager.getCache(anyString())).thenReturn(Mockito.mock(Cache.class)); + // When + Token token = tokenGenerator.createToken(credentials); + // Then + assertNotNull(token); + assertEquals(123L, token.userId()); + assertEquals("accessToken", token.accessToken()); + assertEquals("refreshToken", token.refreshToken()); + } + + @Test + void createValidationToken_success_createsValidationToken() { + // Given + Credentials credentials = mock(Credentials.class); + when(jwtService.createSignedJwt(any(), any())).thenReturn("validationToken"); + // When + String validationToken = tokenGenerator.createValidationToken(credentials); + // Then + assertEquals("validationToken", validationToken); + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/fleetassistant/backend/location/LocationServiceTest.java b/backend/src/test/java/org/fleetassistant/backend/location/LocationServiceTest.java new file mode 100644 index 0000000..7fee8dd --- /dev/null +++ b/backend/src/test/java/org/fleetassistant/backend/location/LocationServiceTest.java @@ -0,0 +1,50 @@ +package org.fleetassistant.backend.location; + +import org.fleetassistant.backend.location.model.Location; +import org.fleetassistant.backend.utils.EntityToDtoMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LocationServiceTest { + @Mock + private LocationRepository locationRepository; + + @Mock + private EntityToDtoMapper entityToDtoMapper; + + @InjectMocks + private LocationService locationService; + + @Test + void create_shouldSaveLocationAndReturnDto() { + Location locationEntity = new Location(); + org.fleetassistant.backend.dto.Location locationDto = org.fleetassistant.backend.dto.Location.builder().build(); + + when(locationRepository.save(locationEntity)).thenReturn(locationEntity); + when(entityToDtoMapper.locationToLocationDto(locationEntity)).thenReturn(locationDto); + + org.fleetassistant.backend.dto.Location result = locationService.create(locationEntity); + + assertEquals(locationDto, result); + verify(locationRepository).save(locationEntity); + verify(entityToDtoMapper).locationToLocationDto(locationEntity); + } + + @Test + void deleteAllByVehicleId_shouldDeleteLocations() { + Long vehicleId = 1L; + + doNothing().when(locationRepository).deleteAllByVehicle_Id(vehicleId); + + locationService.deleteAllByVehicleId(vehicleId); + + verify(locationRepository).deleteAllByVehicle_Id(vehicleId); + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/fleetassistant/backend/user/service/ManagerServiceTest.java b/backend/src/test/java/org/fleetassistant/backend/user/service/ManagerServiceTest.java new file mode 100644 index 0000000..c8019e4 --- /dev/null +++ b/backend/src/test/java/org/fleetassistant/backend/user/service/ManagerServiceTest.java @@ -0,0 +1,74 @@ +package org.fleetassistant.backend.user.service; + +import org.fleetassistant.backend.auth.models.RegisterRequest; +import org.fleetassistant.backend.user.model.Manager; +import org.fleetassistant.backend.user.model.User; +import org.fleetassistant.backend.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.jwt.Jwt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ManagerServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private ManagerService managerService; + + private Jwt jwt; + private RegisterRequest request; + + @BeforeEach + void setUp() { + jwt = mock(Jwt.class); + + request = RegisterRequest.builder() + .name("Jane") + .surname("Doe") + .number("123456789") + .build(); + } + + @Test + void createManager_withJwt_success() { + // Given + Manager expectedManager = Manager.builder().name("John").surname("Doe").build(); + when(userRepository.save(any(Manager.class))).thenReturn(expectedManager); + + // When + User actualManager = managerService.createManager(jwt); + + // Then + assertEquals(expectedManager, actualManager); + assertEquals("John", actualManager.getName()); + assertEquals("Doe", actualManager.getSurname()); + verify(userRepository).save(any(Manager.class)); + } + + @Test + void createManager_withRegisterRequest_success() { + // Given + Manager expectedManager = Manager.builder().name("Jane").surname("Doe").phone("123456789").build(); + when(userRepository.save(any(Manager.class))).thenReturn(expectedManager); + + // When + Manager actualManager = managerService.createManager(request); + + // Then + assertEquals(expectedManager, actualManager); + assertEquals("Jane", actualManager.getName()); + assertEquals("Doe", actualManager.getSurname()); + assertEquals("123456789", actualManager.getPhone()); + verify(userRepository).save(any(Manager.class)); + } +} diff --git a/backend/src/test/java/org/fleetassistant/backend/user/service/UserServiceTest.java b/backend/src/test/java/org/fleetassistant/backend/user/service/UserServiceTest.java new file mode 100644 index 0000000..747f308 --- /dev/null +++ b/backend/src/test/java/org/fleetassistant/backend/user/service/UserServiceTest.java @@ -0,0 +1,102 @@ +package org.fleetassistant.backend.user.service; + +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.auth.credentials.model.Role; +import org.fleetassistant.backend.dto.User; +import org.fleetassistant.backend.exceptionhandler.rest.NoSuchObjectException; +import org.fleetassistant.backend.user.model.Manager; +import org.fleetassistant.backend.user.repository.UserRepository; +import org.fleetassistant.backend.utils.Constants; +import org.fleetassistant.backend.utils.EntityToDtoMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private EntityToDtoMapper entityToDtoMapper; + + @InjectMocks + private UserService userService; + + private Credentials credentials; + private Manager manager; + private User user; + + @BeforeEach + void setUp() { + credentials = Credentials.builder() + .email("test@example.com") + .password("password123") + .role(Role.MANAGER) + .build(); + manager = Manager.builder().name("John").surname("Doe").credentials(credentials).build(); + user = User.builder().name("John").surname("Doe").email("test@example.com").role(Role.MANAGER).build(); + } + + + @Test + void getUserByEmail_success() { + // Given + String email = "johndoe@example.com"; + when(userRepository.findByCredentials_Email(email)).thenReturn(Optional.of(manager)); + when(entityToDtoMapper.userToUserDto(manager)).thenReturn(user); + // When + User foundManager = userService.getUserByEmail(email); + // Then + assertNotNull(foundManager); + assertEquals(manager.getName(), foundManager.name()); + assertEquals(manager.getCredentials().getEmail(), foundManager.email()); + assertEquals(manager.getCredentials().getRole(), foundManager.role()); + verify(userRepository).findByCredentials_Email(email); + } + + @Test + void getUserByEmail_userNotFound_throwsException() { + // Given + String email = "johndoe@example.com"; + when(userRepository.findByCredentials_Email(email)).thenReturn(Optional.empty()); + // When + NoSuchObjectException exception = assertThrows(NoSuchObjectException.class, () -> userService.getUserByEmail(email)); + // Then + assertEquals(Constants.USER_DOESNT_EXIST, exception.getMessage()); + verify(userRepository).findByCredentials_Email(email); + } + + @Test + void getUserIdByEmail_userNotFound_throwsException() { + // Given + String email = "johndoe@example.com"; + when(userRepository.findByCredentials_Email(email)).thenReturn(Optional.empty()); + // When + NoSuchObjectException exception = assertThrows(NoSuchObjectException.class, () -> userService.getUserIdByEmail(email)); + // Then + assertEquals(Constants.USER_DOESNT_EXIST, exception.getMessage()); + verify(userRepository).findByCredentials_Email(email); + } + + @Test + void getUserIdByEmail_success() { + // Given + String email = "johndoe@example.com"; + when(userRepository.findByCredentials_Email(email)).thenReturn(Optional.of(manager)); + // When + Long id = userService.getUserIdByEmail(email); + // Then + assertEquals(manager.getId(), id); + verify(userRepository).findByCredentials_Email(email); + } +} diff --git a/backend/src/test/java/org/fleetassistant/backend/utils/config/security/CustomAuthFilterTest.java b/backend/src/test/java/org/fleetassistant/backend/utils/config/security/CustomAuthFilterTest.java new file mode 100644 index 0000000..fbe002b --- /dev/null +++ b/backend/src/test/java/org/fleetassistant/backend/utils/config/security/CustomAuthFilterTest.java @@ -0,0 +1,83 @@ +package org.fleetassistant.backend.utils.config.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.fleetassistant.backend.utils.config.security.decoders.CustomJwtDecoder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CustomAuthFilterTest { + @Mock + private CustomJwtDecoder decoder; + + @Mock + private JwtToUserConverter jwtToUserConverter; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private FilterChain filterChain; + + @InjectMocks + private CustomAuthFilter customAuthFilter; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldSetAuthenticationWhenTokenIsValid() { + String token = "Bearer valid_token"; + Jwt jwt = mock(Jwt.class); + UsernamePasswordAuthenticationToken authenticationToken = mock(UsernamePasswordAuthenticationToken.class); + + when(request.getHeader("Authorization")).thenReturn(token); + when(decoder.decode("valid_token")).thenReturn(jwt); + when(jwtToUserConverter.convert(jwt)).thenReturn(authenticationToken); + + customAuthFilter.doFilterInternal(request, response, filterChain); + + assertEquals(authenticationToken, SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void shouldReturnUnauthorizedWhenTokenIsInvalid() throws Exception { + String token = "Bearer invalid_token"; + when(request.getHeader("Authorization")).thenReturn(token); + when(decoder.decode("invalid_token")).thenThrow(new RuntimeException("Invalid token")); + + customAuthFilter.doFilterInternal(request, response, filterChain); + + verify(response).setStatus(HttpStatus.UNAUTHORIZED.value()); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void shouldProceedWithoutAuthenticationWhenAuthorizationHeaderIsMissing() throws Exception { + when(request.getHeader("Authorization")).thenReturn(null); + + customAuthFilter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain).doFilter(request, response); + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/fleetassistant/backend/utils/config/security/JwtToUserConverterTest.java b/backend/src/test/java/org/fleetassistant/backend/utils/config/security/JwtToUserConverterTest.java new file mode 100644 index 0000000..5013f07 --- /dev/null +++ b/backend/src/test/java/org/fleetassistant/backend/utils/config/security/JwtToUserConverterTest.java @@ -0,0 +1,83 @@ +package org.fleetassistant.backend.utils.config.security; + +import org.fleetassistant.backend.auth.credentials.CredentialsService; +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.auth.credentials.model.Role; +import org.fleetassistant.backend.user.model.Manager; +import org.fleetassistant.backend.user.service.ManagerService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JwtToUserConverterTest { + + @Mock + private CredentialsService credentialsService; + + @Mock + private ManagerService managerService; + + @InjectMocks + private JwtToUserConverter jwtToUserConverter; + + private Jwt jwt; + private Credentials credentials; + + @BeforeEach + void setUp() { + jwt = mock(Jwt.class); + when(jwt.getClaim("email")).thenReturn("manager@example.com"); + credentials = Credentials.builder() + .email("manager@example.com") + .role(Role.MANAGER) + .build(); + } + + @Test + void convert_existingUser_success() { + // Given + when(credentialsService.loadUserByUsername("manager@example.com")).thenReturn(credentials); + // When + UsernamePasswordAuthenticationToken authToken = jwtToUserConverter.convert(jwt); + // Then + assertNotNull(authToken); + assertEquals(credentials, authToken.getPrincipal()); + assertEquals(jwt, authToken.getCredentials()); + assertEquals(1, authToken.getAuthorities().size()); + assertTrue(authToken.getAuthorities().stream().map(GrantedAuthority::getAuthority).anyMatch(auth -> auth.equals("MANAGER"))); + + verify(credentialsService).loadUserByUsername("manager@example.com"); + verify(managerService, never()).createManager(any(Jwt.class)); + } + + @Test + void convert_newUser_success() { + // Given + when(credentialsService.loadUserByUsername("manager@example.com")).thenReturn(null); + when(credentialsService.create("manager@example.com", Role.MANAGER)).thenReturn(credentials); + Manager manager = Manager.builder().name("John").surname("Doe").build(); + when(managerService.createManager(jwt)).thenReturn(manager); + // When + UsernamePasswordAuthenticationToken authToken = jwtToUserConverter.convert(jwt); + // Then + assertNotNull(authToken); + assertEquals(credentials, authToken.getPrincipal()); + assertEquals(jwt, authToken.getCredentials()); + assertEquals(1, authToken.getAuthorities().size()); + assertTrue(authToken.getAuthorities().stream().map(GrantedAuthority::getAuthority).anyMatch(auth -> auth.equals("MANAGER"))); + verify(credentialsService).loadUserByUsername("manager@example.com"); + verify(credentialsService).create("manager@example.com", Role.MANAGER); + verify(managerService).createManager(jwt); + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/fleetassistant/backend/vehicle/VehicleServiceTest.java b/backend/src/test/java/org/fleetassistant/backend/vehicle/VehicleServiceTest.java new file mode 100644 index 0000000..64a4f35 --- /dev/null +++ b/backend/src/test/java/org/fleetassistant/backend/vehicle/VehicleServiceTest.java @@ -0,0 +1,307 @@ +package org.fleetassistant.backend.vehicle; + +import org.fleetassistant.backend.auth.credentials.model.Credentials; +import org.fleetassistant.backend.auth.credentials.model.Role; +import org.fleetassistant.backend.exceptionhandler.rest.NoSuchObjectException; +import org.fleetassistant.backend.exceptionhandler.rest.ObjectAlreadyExistsException; +import org.fleetassistant.backend.location.LocationService; +import org.fleetassistant.backend.location.model.Location; +import org.fleetassistant.backend.user.model.Driver; +import org.fleetassistant.backend.user.service.DriverService; +import org.fleetassistant.backend.user.service.ManagerService; +import org.fleetassistant.backend.user.service.UserService; +import org.fleetassistant.backend.utils.EntityToDtoMapper; +import org.fleetassistant.backend.vehicle.model.Vehicle; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class VehicleServiceTest { + + @Mock + private VehicleRepository vehicleRepository; + + @Mock + private EntityToDtoMapper entityToDtoMapper; + @Mock + private LocationService locationService; + @Mock + private UserService userService; + @Mock + private ManagerService managerService; + @Mock + private DriverService driverService; + @InjectMocks + private VehicleService vehicleService; + + private Vehicle vehicle; + private org.fleetassistant.backend.dto.Vehicle vehicleDto; + private org.fleetassistant.backend.dto.Location locationDto; + private Location location; + + @BeforeEach + void setUp() { + locationDto = org.fleetassistant.backend.dto.Location.builder() + .id(1L) + .latitude(1.1) + .longitude(1.1) + .build(); + location = Location.builder() + .id(1L) + .latitude(1.1) + .longitude(1.1) + .build(); + vehicle = Vehicle.builder() + .id(1L) + .vin("1FMYU021X7KB19729") + .plateNumber("XYZ1234") + .countryCode("US") + .productionDate(LocalDate.of(2023, 5, 20)) + .lastInspectionDate(LocalDate.of(2023, 12, 10)) + .insuranceDate(LocalDate.of(2023, 12, 10)) + .locations(List.of(location)) + .build(); + + vehicleDto = org.fleetassistant.backend.dto.Vehicle.builder() + .id(1L) + .vin("1FMYU021X7KB19729") + .plateNumber("XYZ1234") + .countryCode("US") + .productionDate(LocalDate.of(2023, 5, 20)) + .lastInspectionDate(LocalDate.of(2023, 12, 10)) + .insuranceDate(LocalDate.of(2023, 12, 10)) + .driverId(1L) + .build(); + + } + + @Test + void create_carDoesNotExist_shouldCreateCar() { + when(entityToDtoMapper.vehicleDtoToVehicle(vehicleDto)).thenReturn(vehicle); + when(vehicleRepository.exists(any())).thenReturn(false); + when(vehicleRepository.save(any())).thenReturn(vehicle); + when(entityToDtoMapper.vehicleToVehicleDto(vehicle)).thenReturn(vehicleDto); + + org.fleetassistant.backend.dto.Vehicle createdVehicleDto = vehicleService.create(vehicleDto); + + assertNotNull(createdVehicleDto); + assertEquals(vehicleDto, createdVehicleDto); + verify(vehicleRepository, times(1)).save(vehicle); + } + + @Test + void create_carExists_shouldThrowObjectAlreadyExistsException() { + when(entityToDtoMapper.vehicleDtoToVehicle(vehicleDto)).thenReturn(vehicle); + when(vehicleRepository.exists(any())).thenReturn(true); + + Exception exception = assertThrows(ObjectAlreadyExistsException.class, () -> vehicleService.create(vehicleDto)); + + assertEquals(String.format(VehicleService.CAR_WITH_VIN_ALREADY_EXISTS, vehicle.getVin()), exception.getMessage()); + verify(vehicleRepository, never()).save(vehicle); + } + + @Test + void readAll_shouldReturnVehicles_MANAGER() { + Pageable pageable = Pageable.ofSize(1); + Page vehiclePage = new PageImpl<>(List.of(vehicle)); + when(entityToDtoMapper.vehicleToVehicleDto(vehicle)).thenReturn(vehicleDto); + when(vehicleRepository.findAllByManagerId(0L, pageable)).thenReturn(vehiclePage); + + Authentication authentication = Mockito.mock(Authentication.class); + SecurityContext securityContext = Mockito.mock(SecurityContext.class); + when(authentication.getPrincipal()).thenReturn(Credentials.builder().email("").role(Role.MANAGER).build()); + when(securityContext.getAuthentication()).thenReturn(authentication); + SecurityContextHolder.setContext(securityContext); + + Page result = vehicleService.readAll(pageable); + + assertNotNull(result); + verify(vehicleRepository, times(1)).findAllByManagerId(any(), any()); + } + + @Test + void readAll_shouldReturnVehicles_DRIVER() { + Pageable pageable = Pageable.ofSize(1); + Page vehiclePage = new PageImpl<>(List.of(vehicle)); + when(entityToDtoMapper.vehicleToVehicleDto(vehicle)).thenReturn(vehicleDto); + when(vehicleRepository.findAllByDriverId(0L, pageable)).thenReturn(vehiclePage); + + Authentication authentication = Mockito.mock(Authentication.class); + SecurityContext securityContext = Mockito.mock(SecurityContext.class); + when(authentication.getPrincipal()).thenReturn(Credentials.builder().email("").role(Role.DRIVER).build()); + when(securityContext.getAuthentication()).thenReturn(authentication); + SecurityContextHolder.setContext(securityContext); + + Page result = vehicleService.readAll(pageable); + + assertNotNull(result); + verify(vehicleRepository, times(1)).findAllByDriverId(any(), any()); + } + + @Test + void readById_carExists_shouldReturnVehicle() { + when(vehicleRepository.findById(anyLong())).thenReturn(Optional.of(vehicle)); + when(entityToDtoMapper.vehicleToVehicleDto(vehicle)).thenReturn(vehicleDto); + + org.fleetassistant.backend.dto.Vehicle result = vehicleService.readById(1L); + + assertNotNull(result); + assertEquals(vehicleDto.vin(), result.vin()); + verify(vehicleRepository, times(1)).findById(1L); + } + + @Test + void readById_carDoesNotExist_shouldThrowNoSuchObjectException() { + when(vehicleRepository.findById(anyLong())).thenReturn(Optional.empty()); + + Exception exception = assertThrows(NoSuchObjectException.class, () -> vehicleService.readById(1L)); + + assertEquals(String.format(VehicleService.VEHICLE_WITH_ID_NOT_FOUND, 1L), exception.getMessage()); + } + + @Test + void isCarExists_shouldReturnTrueIfCarExists() { + when(vehicleRepository.exists(any())).thenReturn(true); + + boolean exists = vehicleService.isVehicleExists(vehicle); + + assertTrue(exists); + } + + @Test + void isCarExists_shouldReturnFalseIfCarDoesNotExist() { + when(vehicleRepository.exists(any())).thenReturn(false); + + boolean exists = vehicleService.isVehicleExists(vehicle); + + assertFalse(exists); + } + + @Test + void calculateNextInspectionDate_YoungerThanFourYears_ShouldReturnFourYearsFromProduction() { + LocalDate productionDate = LocalDate.now().minusYears(3); + LocalDate lastInspectionDate = LocalDate.of(2023, 12, 10); + + LocalDate result = vehicleService.calculateNextInspectionDate(productionDate, lastInspectionDate); + + assertEquals(productionDate.plusYears(4), result); + } + + @Test + void calculateNextInspectionDate_AgedBetweenFourAndTenYears_ShouldReturnTwoYearsFromLastInspection() { + LocalDate productionDate = LocalDate.now().minusYears(5); + LocalDate lastInspectionDate = LocalDate.of(2023, 12, 10); + + LocalDate result = VehicleService.calculateNextInspectionDate(productionDate, lastInspectionDate); + + assertEquals(lastInspectionDate.plusYears(2), result); + } + + @Test + void calculateNextInspectionDate_AgedBetweenFourAndTenYears_NoLastInspection_ShouldReturnTwoYearsFromProduction() { + LocalDate productionDate = LocalDate.now().minusYears(6); + + LocalDate result = VehicleService.calculateNextInspectionDate(productionDate, null); + + assertEquals(productionDate.plusYears(2), result); + } + + @Test + void calculateNextInspectionDate_OlderThanTenYears_ShouldReturnOneYearFromLastInspection() { + LocalDate productionDate = LocalDate.now().minusYears(11); + LocalDate lastInspectionDate = LocalDate.of(2023, 12, 10); + + LocalDate result = VehicleService.calculateNextInspectionDate(productionDate, lastInspectionDate); + + assertEquals(lastInspectionDate.plusYears(1), result); + } + + @Test + void calculateNextInspectionDate_OlderThanTenYears_NoLastInspection_ShouldReturnFourYearsFromProduction() { + LocalDate productionDate = LocalDate.now().minusYears(12); + + LocalDate result = VehicleService.calculateNextInspectionDate(productionDate, null); + + assertEquals(productionDate.plusYears(4), result); + } + + @Test + void calculateNextInspectionDate_NullProductionDate_ShouldReturnNull() { + LocalDate result = VehicleService.calculateNextInspectionDate(null, LocalDate.of(2023, 12, 10)); + + assertNull(result); + } + + @Test + void updateLocation_NoSuchObjectException() { + when(vehicleRepository.findById(anyLong())).thenReturn(Optional.empty()); + + Exception exception = assertThrows(NoSuchObjectException.class, () -> vehicleService.updateLocation(locationDto, 1L)); + + assertEquals(String.format(VehicleService.VEHICLE_WITH_ID_NOT_FOUND, 1L), exception.getMessage()); + } + + @Test + void updateLocation_success() { + when(vehicleRepository.findById(anyLong())).thenReturn(Optional.of(vehicle)); + when(entityToDtoMapper.locationDtoToLocation(locationDto)).thenReturn(location); + when(locationService.create(location)).thenReturn(locationDto); + vehicleService.updateLocation(locationDto, 1L); + assertNotNull(location.getVehicle()); + } + + @Test + void deleteLocations_NoSuchObjectException() { + when(vehicleRepository.findById(anyLong())).thenReturn(Optional.empty()); + + Exception exception = assertThrows(NoSuchObjectException.class, () -> vehicleService.deleteLocations(1L)); + + assertEquals(String.format(VehicleService.VEHICLE_WITH_ID_NOT_FOUND, 1L), exception.getMessage()); + } + + @Test + void deleteLocations_success() { + when(vehicleRepository.findById(anyLong())).thenReturn(Optional.of(vehicle)); + vehicleService.deleteLocations(1L); + verify(locationService).deleteAllByVehicleId(1L); + } + + @Test + void assignDriver_NoSuchObjectException() { + when(vehicleRepository.findById(anyLong())).thenReturn(Optional.empty()); + + Exception exception = assertThrows(NoSuchObjectException.class, () -> vehicleService.assignDriver(1L, 1L)); + + assertEquals(String.format(VehicleService.VEHICLE_WITH_ID_NOT_FOUND, 1L), exception.getMessage()); + } + + @Test + void assignDriver_success() { + when(vehicleRepository.findById(anyLong())).thenReturn(Optional.of(vehicle)); + when(driverService.getDriverById(anyLong())).thenReturn(Driver.builder().id(1L).build()); + when(vehicleRepository.save(any())).thenReturn(vehicle); + when(entityToDtoMapper.vehicleToVehicleDto(vehicle)).thenReturn(vehicleDto); + org.fleetassistant.backend.dto.Vehicle result = vehicleService.assignDriver(1L, 1L); + assertEquals( 1L, result.driverId()); + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml new file mode 100644 index 0000000..39a32b3 --- /dev/null +++ b/backend/src/test/resources/application.yml @@ -0,0 +1,12 @@ +spring: + liquibase: + change-log: classpath:db/changelog/db.changelog-master.yml + enabled: true + security: + oauth2: + resourceserver: + jwt: + issuer-uri: https://accounts.google.com + jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs + jwt: + secret: MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMGBoG93o/faIHuvPr0eryGKSX3FsQUkdeWJo62tTU8xD8hFqDlj7iMrZFz/I7e01bpqx8sGr4sVM8gmoIUj1zUCAwEAAQ== \ No newline at end of file diff --git a/client-example/requirements.txt b/client-example/requirements.txt new file mode 100644 index 0000000..9ee2289 --- /dev/null +++ b/client-example/requirements.txt @@ -0,0 +1,5 @@ +certifi==2024.8.30 +charset-normalizer==3.4.0 +idna==3.10 +requests==2.32.3 +urllib3==2.2.3 diff --git a/client-example/src/client.py b/client-example/src/client.py new file mode 100644 index 0000000..5f33498 --- /dev/null +++ b/client-example/src/client.py @@ -0,0 +1,68 @@ +import time + +import requests + + +def delete_vehicle_locations(vehicle_id): + url = 'http://localhost:8080/api/v1/vehicle/{}/location'.format(vehicle_id) + headers = {'Content-Type': 'application/json'} + + response = requests.delete(url, headers=headers) + if response.status_code == 204: + print('Locations deleted successfully for ID:', vehicle_id) + else: + print('Response:', response.text) + raise Exception('Failed to delete locations for ID:', vehicle_id) + + +def send_vehicle_location(vehicle_id, latitude, longitude): + url = 'http://localhost:8080/api/v1/vehicle/{}/location'.format(vehicle_id) + headers = {'Content-Type': 'application/json'} + + location_data = { + 'latitude': latitude, + 'longitude': longitude + } + + response = requests.post(url, headers=headers, json=location_data) + if response.status_code == 200: + print('Location sent successfully for ID:', vehicle_id) + else: + print('Response:', response.text) + raise Exception('Failed to send location for ID:', vehicle_id) + + +vehicle_id = input("Enter ID: ") +delete_vehicle_locations(vehicle_id) +locations = [ + (51.104287, 17.085214), + (51.104475, 17.081910), + (51.104852, 17.079635), + (51.105634, 17.077146), + (51.106496, 17.074657), + (51.107574, 17.072039), + (51.108113, 17.069936), + (51.108894, 17.069421), + (51.109945, 17.069593), + (51.111131, 17.069851), + (51.112532, 17.070022), + (51.113717, 17.071181), + (51.114795, 17.072940), + (51.116115, 17.079549), + (51.117031, 17.082553), + (51.117111, 17.085300), + (51.117273, 17.087017), + (51.117677, 17.089291), + (51.116680, 17.090150), + (51.115495, 17.090278), + (51.114202, 17.090493), + (51.111750, 17.090536), + (51.109891, 17.090450), + (51.105796, 17.090192), + (51.104394, 17.089420), + (51.104179, 17.087231), + (51.104287, 17.085214), +] +for latitude, longitude in locations: + send_vehicle_location(vehicle_id, latitude, longitude) + time.sleep(5) \ No newline at end of file diff --git a/deployment/cd_backend/ec2.tf b/deployment/cd_backend/ec2.tf new file mode 100644 index 0000000..2ace5fa --- /dev/null +++ b/deployment/cd_backend/ec2.tf @@ -0,0 +1,45 @@ +resource "aws_instance" "ec2_backend" { + ami = "ami-03ca36368dbc9cfa1" + instance_type = "t2.micro" + + vpc_security_group_ids = [var.rds_connection_sg_id] + iam_instance_profile = aws_iam_instance_profile.ec2_instance_profile.name + + depends_on = [ aws_s3_object.backend_source_code ] + + lifecycle { + create_before_destroy = true + } + + user_data = <<-EOF + #!/bin/bash + export SQL_USERNAME="${var.rds_instance_username}" + export SQL_PASSWORD="${var.rds_instance_password}" + export SQL_URI="${var.rds_instance_endpoint}" + + sudo yum install -y unzip aws-cli java-21 + + aws s3 cp s3://${var.private_s3_name}/backend/source_code.zip /tmp/source_code.zip + + unzip /tmp/source_code.zip + + java -jar backend-0.1.jar + EOF +} + +resource "aws_s3_object" "backend_source_code" { + bucket = var.private_s3_name + key = "/backend/source_code.zip" + source = "../../backend/build/libs/backend.zip" + + source_hash = filemd5("../../backend/build/libs/backend.zip") +} + +resource "aws_iam_instance_profile" "ec2_instance_profile" { + name = "ec2_backend_instance_profile" + role = var.ec2_backend_role_name +} + +resource "aws_eip" "ec2_eip" { + instance = aws_instance.ec2_backend.id +} diff --git a/deployment/cd_backend/main.tf b/deployment/cd_backend/main.tf new file mode 100644 index 0000000..6ec88dd --- /dev/null +++ b/deployment/cd_backend/main.tf @@ -0,0 +1,15 @@ +provider "aws" { + region = "eu-west-1" +} + +terraform { + backend "s3" { + bucket = "fleet-assistant-backend-bucket" + key = "terraform/cd_backend_state" + region = "eu-west-1" + } +} + +output "instance_public_ip" { + value = aws_eip.ec2_eip.public_ip +} \ No newline at end of file diff --git a/deployment/cd_backend/variables.tf b/deployment/cd_backend/variables.tf new file mode 100644 index 0000000..9a56bd6 --- /dev/null +++ b/deployment/cd_backend/variables.tf @@ -0,0 +1,23 @@ +variable "rds_connection_sg_id" { + type = string +} + +variable "ec2_backend_role_name" { + type = string +} + +variable "private_s3_name" { + type = string +} + +variable "rds_instance_username" { + type = string +} + +variable "rds_instance_password" { + type = string +} + +variable "rds_instance_endpoint" { + type = string +} \ No newline at end of file diff --git a/deployment/infrastructure_init/ec2_role.tf b/deployment/infrastructure_init/ec2_role.tf new file mode 100644 index 0000000..7dca2f2 --- /dev/null +++ b/deployment/infrastructure_init/ec2_role.tf @@ -0,0 +1,24 @@ +resource "aws_iam_role" "ec2_role" { + name = var.ec2_backend_role_name + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Action = "sts:AssumeRole", + Effect = "Allow", + Principal = { Service = "ec2.amazonaws.com" } + }] + }) +} + +resource "aws_iam_role_policy" "s3_access" { + name = "s3_access_policy" + role = aws_iam_role.ec2_role.id + policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Action = ["s3:GetObject"], + Effect = "Allow", + Resource = "arn:aws:s3:::${var.private_s3_name}/backend/source_code.zip" + }] + }) +} \ No newline at end of file diff --git a/deployment/infrastructure_init/main.tf b/deployment/infrastructure_init/main.tf new file mode 100644 index 0000000..039c72a --- /dev/null +++ b/deployment/infrastructure_init/main.tf @@ -0,0 +1,19 @@ +provider "aws" { + region = "eu-west-1" +} + +terraform { + backend "s3" { + bucket = "fleet-assistant-backend-bucket" + key = "terraform/init_state" + region = "eu-west-1" + } +} + +output "rds_endpoint" { + value = aws_db_instance.postgres.endpoint +} + +output "rds_connection_sg" { + value = aws_security_group.rds_connection.id +} \ No newline at end of file diff --git a/deployment/infrastructure_init/rds.tf b/deployment/infrastructure_init/rds.tf new file mode 100644 index 0000000..87a6c52 --- /dev/null +++ b/deployment/infrastructure_init/rds.tf @@ -0,0 +1,58 @@ +resource "aws_db_instance" "postgres" { + identifier = var.rds_instance_name + allocated_storage = 20 + storage_type = "gp2" + engine = "postgres" + engine_version = "16.3" + multi_az = false + + db_name = "fleet_assistant" + + instance_class = "db.t3.micro" + + username = var.rds_instance_username + password = var.rds_instance_password + + vpc_security_group_ids = [aws_security_group.rds_sg.id] + skip_final_snapshot = true + publicly_accessible = false + backup_retention_period = 0 +} + +resource "aws_security_group" "rds_sg" { + name = var.rds_secutiry_group_name + revoke_rules_on_delete = true + + ingress { + security_groups = [aws_security_group.rds_connection.id] + from_port = 5432 + to_port = 5432 + protocol = "tcp" + } + + egress { + security_groups = [aws_security_group.rds_connection.id] + from_port = 0 + to_port = 0 + protocol = "-1" + } +} + +resource "aws_security_group" "rds_connection" { + name = var.rds_connection_secutiry_group_name + revoke_rules_on_delete = true + + ingress { + cidr_blocks = ["0.0.0.0/0"] + from_port = 0 + to_port = 0 + protocol = "-1" + } + + egress { + cidr_blocks = ["0.0.0.0/0"] + from_port = 0 + to_port = 0 + protocol = "-1" + } +} \ No newline at end of file diff --git a/deployment/infrastructure_init/s3.tf b/deployment/infrastructure_init/s3.tf new file mode 100644 index 0000000..e513e73 --- /dev/null +++ b/deployment/infrastructure_init/s3.tf @@ -0,0 +1,77 @@ +resource "aws_s3_bucket" "public_s3" { + bucket = var.public_s3_name +} + +resource "aws_s3_bucket_versioning" "versioning_example" { + bucket = aws_s3_bucket.public_s3.id + versioning_configuration { + status = "Disabled" + } +} + +resource "aws_s3_bucket_acl" "bucket-acl" { + bucket = aws_s3_bucket.public_s3.id + acl = "public-read" + depends_on = [aws_s3_bucket_ownership_controls.s3_bucket_acl_ownership] +} + +resource "aws_s3_bucket_ownership_controls" "s3_bucket_acl_ownership" { + bucket = aws_s3_bucket.public_s3.id + rule { + object_ownership = "BucketOwnerPreferred" + } + depends_on = [aws_s3_bucket_public_access_block.example] +} + +resource "aws_s3_bucket_public_access_block" "example" { + bucket = aws_s3_bucket.public_s3.id + + block_public_acls = false + block_public_policy = false + ignore_public_acls = false + restrict_public_buckets = false +} + +resource "aws_s3_bucket_policy" "bucket-policy" { + bucket = aws_s3_bucket.public_s3.id + policy = data.aws_iam_policy_document.iam-policy-1.json +} + +data "aws_iam_policy_document" "iam-policy-1" { + statement { + sid = "AllowPublicRead" + effect = "Allow" + resources = [ + "arn:aws:s3:::${var.public_s3_name}", + "arn:aws:s3:::${var.public_s3_name}/*", + ] + actions = ["S3:GetObject"] + principals { + type = "*" + identifiers = ["*"] + } + } + + depends_on = [aws_s3_bucket_public_access_block.example] +} + +resource "aws_s3_bucket_website_configuration" "website-config" { + bucket = aws_s3_bucket.public_s3.bucket + + index_document { + suffix = "index.html" + } + + error_document { + key = "index.html" + } + + routing_rule { + condition { + key_prefix_equals = "*" + } + redirect { + replace_key_prefix_with = "index.html" + } + } +} \ No newline at end of file diff --git a/deployment/infrastructure_init/variables.tf b/deployment/infrastructure_init/variables.tf new file mode 100644 index 0000000..8144f96 --- /dev/null +++ b/deployment/infrastructure_init/variables.tf @@ -0,0 +1,31 @@ +variable "private_s3_name" { + type = string +} + +variable "public_s3_name" { + type = string +} + +variable "rds_instance_username" { + type = string +} + +variable "rds_instance_password" { + type = string +} + +variable "rds_instance_name" { + type = string +} + +variable "rds_secutiry_group_name" { + type = string +} + +variable "rds_connection_secutiry_group_name" { + type = string +} + +variable "ec2_backend_role_name" { + type = string +} \ No newline at end of file diff --git a/deployment/s3_backend_init/main.tf b/deployment/s3_backend_init/main.tf new file mode 100644 index 0000000..c8748a1 --- /dev/null +++ b/deployment/s3_backend_init/main.tf @@ -0,0 +1,7 @@ +provider "aws" { + region = "eu-west-1" +} + +resource "aws_s3_bucket" "private_s3" { + bucket = var.private_s3_name +} \ No newline at end of file diff --git a/deployment/s3_backend_init/variables.tf b/deployment/s3_backend_init/variables.tf new file mode 100644 index 0000000..4e03701 --- /dev/null +++ b/deployment/s3_backend_init/variables.tf @@ -0,0 +1,3 @@ +variable "private_s3_name" { + type = string +} \ No newline at end of file diff --git a/deployment/variables.tfvars b/deployment/variables.tfvars new file mode 100644 index 0000000..94abe60 --- /dev/null +++ b/deployment/variables.tfvars @@ -0,0 +1,8 @@ +public_s3_name="fleet-assistant-hosting-bucket" +private_s3_name="fleet-assistant-backend-bucket" + +rds_instance_name="fleet-assistant-rds-postgres" +rds_secutiry_group_name="rds-security-group" +rds_connection_secutiry_group_name="rds-connection-security-group" + +ec2_backend_role_name="backend-ec2-private-s3-access-role" \ No newline at end of file diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..c4fa2ff --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "printWidth": 80, + "tabWidth": 4, + "trailingComma": "es5" +} diff --git a/frontend/angular.json b/frontend/angular.json new file mode 100644 index 0000000..c1d3493 --- /dev/null +++ b/frontend/angular.json @@ -0,0 +1,129 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "fleet-assistant": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/fleet-assistant", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "stylePreprocessorOptions": { + "includePaths": [ + "src/utilities" + ] + }, + "styles": [ + "@angular/material/prebuilt-themes/azure-blue.css", + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "1MB", + "maximumError": "10MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "10kB", + "maximumError": "20kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "fleet-assistant:build:production" + }, + "development": { + "buildTarget": "fleet-assistant:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "stylePreprocessorOptions": { + "includePaths": [ + "src/utilities" + ] + }, + "styles": [ + "@angular/material/prebuilt-themes/azure-blue.css", + "src/styles.scss" + ], + "scripts": [], + "karmaConfig": "./karma.conf.js" + } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "src/**/*.ts", + "src/**/*.html" + ] + } + } + } + } + }, + "cli": { + "analytics": false, + "schematicCollections": [ + "@angular-eslint/schematics" + ] + } +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5791e4e --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,47 @@ +// @ts-check +const eslint = require("@eslint/js"); +const tseslint = require("typescript-eslint"); +const angular = require("angular-eslint"); + +module.exports = tseslint.config( + { + files: ["**/*.ts"], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.stylistic, + ...angular.configs.tsRecommended, + ], + processor: angular.processInlineTemplates, + rules: { + "@angular-eslint/directive-selector": [ + "error", + { + type: "attribute", + prefix: "app", + style: "camelCase", + }, + ], + "@angular-eslint/component-selector": [ + "error", + { + type: "element", + prefix: "app", + style: "kebab-case", + }, + ], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/explicit-function-return-type": "warn", + }, + }, + { + files: ["**/*.html"], + extends: [ + ...angular.configs.templateRecommended, + ...angular.configs.templateAccessibility, + ], + rules: { + "@angular-eslint/template/alt-text": "warn", + }, + } +); diff --git a/frontend/generate-environment.sh b/frontend/generate-environment.sh new file mode 100644 index 0000000..b00cf3c --- /dev/null +++ b/frontend/generate-environment.sh @@ -0,0 +1,9 @@ +# generate-environment.sh +mkdir -p src/environments +cat < src/environments/environment.ts +export const environment = { + oidcClientId: '$OIDC_CLIENT_ID', + apiUrl: '$API_URL', + googleApiKey: '$GOOGLE_API_KEY' +}; +EOF diff --git a/frontend/karma.conf.js b/frontend/karma.conf.js new file mode 100644 index 0000000..43a256c --- /dev/null +++ b/frontend/karma.conf.js @@ -0,0 +1,24 @@ +module.exports = function (config) { + config.set({ + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('@angular-devkit/build-angular/plugins/karma'), + require('karma-coverage-istanbul-reporter') + ], + client: { + clearContext: false + }, + coverageIstanbulReporter: { + dir: require('path').join(__dirname, './coverage'), + reports: ['html', 'text-summary'], + fixWebpackSourcePaths: true + }, + reporters: ['progress', 'kjhtml', 'coverage-istanbul'], + browsers: ['ChromeHeadless'], + singleRun: true, + restartOnFileChange: false + }); +}; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c724c91 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,57 @@ +{ + "name": "fleet-assistant", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test --no-watch --no-progress --browsers=ChromeHeadless", + "lint": "ng lint", + "prettier": "prettier --write \\\"src/**/*.{ts,html,css,scss}\\\"" + }, + "private": true, + "dependencies": { + "@angular/animations": "^18.0.0", + "@angular/cdk": "^18.1.3", + "@angular/common": "^18.0.0", + "@angular/compiler": "^18.0.0", + "@angular/core": "^18.0.0", + "@angular/forms": "^18.0.0", + "@angular/google-maps": "^18.2.8", + "@angular/material": "^18.1.3", + "@angular/material-moment-adapter": "^18.2.8", + "@angular/platform-browser": "^18.0.0", + "@angular/platform-browser-dynamic": "^18.0.0", + "@angular/router": "^18.0.0", + "angular": "^1.8.3", + "angular-oauth2-oidc": "^17.0.2", + "autoprefixer": "^10.4.20", + "moment": "^2.30.1", + "postcss": "^8.4.41", + "prettier": "^3.3.3", + "rxjs": "~7.8.0", + "tailwindcss": "^3.4.7", + "tslib": "^2.3.0", + "zone.js": "~0.14.3" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^18.0.6", + "@angular/cli": "^18.0.6", + "@angular/compiler-cli": "^18.0.0", + "@types/jasmine": "~5.1.0", + "angular-eslint": "18.3.1", + "eslint": "^9.9.1", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "jasmine-core": "~5.1.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-coverage-istanbul-reporter": "^3.0.3", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.4.2", + "typescript-eslint": "8.2.0" + } +} diff --git a/frontend/public/car-icon-clicked.png b/frontend/public/car-icon-clicked.png new file mode 100644 index 0000000..dce5bee Binary files /dev/null and b/frontend/public/car-icon-clicked.png differ diff --git a/frontend/public/car-icon.png b/frontend/public/car-icon.png new file mode 100644 index 0000000..fc5e3e7 Binary files /dev/null and b/frontend/public/car-icon.png differ diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..e79fcfd Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/public/maintenance.jpg b/frontend/public/maintenance.jpg new file mode 100644 index 0000000..ec152d3 Binary files /dev/null and b/frontend/public/maintenance.jpg differ diff --git a/frontend/public/maintenance_2.jpg b/frontend/public/maintenance_2.jpg new file mode 100644 index 0000000..0f2ed35 Binary files /dev/null and b/frontend/public/maintenance_2.jpg differ diff --git a/frontend/public/maintenance_3.jpg b/frontend/public/maintenance_3.jpg new file mode 100644 index 0000000..4df5130 Binary files /dev/null and b/frontend/public/maintenance_3.jpg differ diff --git a/frontend/public/maintenance_4.jpg b/frontend/public/maintenance_4.jpg new file mode 100644 index 0000000..b86491a Binary files /dev/null and b/frontend/public/maintenance_4.jpg differ diff --git a/frontend/public/management.jpg b/frontend/public/management.jpg new file mode 100644 index 0000000..12a9529 Binary files /dev/null and b/frontend/public/management.jpg differ diff --git a/frontend/public/management_2.jpg b/frontend/public/management_2.jpg new file mode 100644 index 0000000..f3c246b Binary files /dev/null and b/frontend/public/management_2.jpg differ diff --git a/frontend/public/management_3.jpg b/frontend/public/management_3.jpg new file mode 100644 index 0000000..a08bd44 Binary files /dev/null and b/frontend/public/management_3.jpg differ diff --git a/frontend/public/management_4.jpg b/frontend/public/management_4.jpg new file mode 100644 index 0000000..cba18b8 Binary files /dev/null and b/frontend/public/management_4.jpg differ diff --git a/frontend/public/tracking.jpg b/frontend/public/tracking.jpg new file mode 100644 index 0000000..0a7ba4e Binary files /dev/null and b/frontend/public/tracking.jpg differ diff --git a/frontend/public/tracking_2.jpg b/frontend/public/tracking_2.jpg new file mode 100644 index 0000000..39567c6 Binary files /dev/null and b/frontend/public/tracking_2.jpg differ diff --git a/frontend/public/tracking_3.jpg b/frontend/public/tracking_3.jpg new file mode 100644 index 0000000..1b1c8b7 Binary files /dev/null and b/frontend/public/tracking_3.jpg differ diff --git a/frontend/public/tracking_4.jpg b/frontend/public/tracking_4.jpg new file mode 100644 index 0000000..2d8111b Binary files /dev/null and b/frontend/public/tracking_4.jpg differ diff --git a/frontend/public/ultimate_solution.jpg b/frontend/public/ultimate_solution.jpg new file mode 100644 index 0000000..1bc502f Binary files /dev/null and b/frontend/public/ultimate_solution.jpg differ diff --git a/frontend/public/ultimate_solution_2.jpg b/frontend/public/ultimate_solution_2.jpg new file mode 100644 index 0000000..868deca Binary files /dev/null and b/frontend/public/ultimate_solution_2.jpg differ diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html new file mode 100644 index 0000000..0197f3a --- /dev/null +++ b/frontend/src/app/app.component.html @@ -0,0 +1,8 @@ + +
+ + + + +
+ diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss new file mode 100644 index 0000000..09e4f84 --- /dev/null +++ b/frontend/src/app/app.component.scss @@ -0,0 +1,3 @@ +main { + min-height: calc(100vh - 20vh); +} diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts new file mode 100644 index 0000000..a83a77c --- /dev/null +++ b/frontend/src/app/app.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +import { NavbarService } from '../utilities/services/navbar.service'; +import { HeaderComponent } from './common/components/header/header.component'; +import { FooterComponent } from './common/components/footer/footer.component'; +import { SidenavComponent } from './common/components/sidenav/sidenav.component'; +import { BottomNavComponent } from './common/components/bottom-nav/bottom-nav.component'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; +import { Renderer2 } from '@angular/core'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { MockOAuthService } from '../utilities/tests/_mocks'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +describe('AppComponent', () => { + let component: AppComponent; + let fixture: ComponentFixture; + let navbarServiceMock: jasmine.SpyObj; + let rendererMock: jasmine.SpyObj; + + beforeEach(async () => { + navbarServiceMock = jasmine.createSpyObj('NavbarService', [ + 'showNavbar$', + ]); + rendererMock = jasmine.createSpyObj('Renderer2', [ + 'createElement', + 'appendChild', + ]); + + navbarServiceMock.showNavbar$ = of(true); + + await TestBed.configureTestingModule({ + imports: [ + AppComponent, + HeaderComponent, + FooterComponent, + SidenavComponent, + BottomNavComponent, + RouterTestingModule, + HttpClientTestingModule, + BrowserAnimationsModule, + ], + providers: [ + { provide: NavbarService, useValue: navbarServiceMock }, + { provide: Renderer2, useValue: rendererMock }, + { provide: OAuthService, useClass: MockOAuthService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AppComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts new file mode 100644 index 0000000..1ce73f2 --- /dev/null +++ b/frontend/src/app/app.component.ts @@ -0,0 +1,77 @@ +import { Component, DestroyRef, OnInit, Renderer2 } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { HeaderComponent } from './common/components/header/header.component'; +import { WelcomePageComponent } from './welcome-page/welcome-page.component'; +import { FooterComponent } from './common/components/footer/footer.component'; +import { NgIf } from '@angular/common'; +import { NavbarService } from '../utilities/services/navbar.service'; +import { SidenavComponent } from './common/components/sidenav/sidenav.component'; +import { BottomNavComponent } from './common/components/bottom-nav/bottom-nav.component'; +import { environment } from '../environments/environment'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [ + RouterOutlet, + HeaderComponent, + WelcomePageComponent, + FooterComponent, + NgIf, + SidenavComponent, + BottomNavComponent, + ], + templateUrl: './app.component.html', + styleUrl: './app.component.scss', +}) +export class AppComponent implements OnInit { + showNavbar = true; + + constructor( + private destroyRef: DestroyRef, + private navbarService: NavbarService, + private renderer: Renderer2 + ) { + this.navbarService.showNavbar$ + .pipe(takeUntilDestroyed(destroyRef)) + .subscribe((show) => { + this.showNavbar = show; + }); + } + + ngOnInit(): void { + this.addGoogleMapsScript(); + } + + addGoogleMapsScript(): void { + const apiKey = environment.googleApiKey; + + const scriptContent = `(g => { + var h, a, k, p = "The Google Maps JavaScript API", c = "google", l = "importLibrary", q = "__ib__", m = document, b = window; + b = b[c] || (b[c] = {}); + var d = b.maps || (b.maps = {}), + r = new Set, e = new URLSearchParams, + u = () => h || (h = new Promise(async(f, n) => { + await (a = m.createElement("script")); + e.set("libraries", [...r] + ""); + for (k in g) + e.set(k.replace(/[A-Z]/g, t => "_" + t[0].toLowerCase()), g[k]); + e.set("callback", c + ".maps." + q); + a.src = \`https://maps.\${c}apis.com/maps/api/js?\` + e; + d[q] = f; + a.onerror = () => h = n(Error(p + " could not load.")); + a.nonce = m.querySelector("script[nonce]")?.nonce || ""; + m.head.append(a) + })); + d[l] ? console.warn(p + " only loads once. Ignoring:", g) : d[l] = (f, ...n) => r.add(f) && u().then(() => d[l](f, ...n)) + })({ + key: '${apiKey}', + });`; + + const scriptElement = this.renderer.createElement('script'); + scriptElement.type = 'text/javascript'; + scriptElement.text = scriptContent; + this.renderer.appendChild(document.body, scriptElement); + } +} diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts new file mode 100644 index 0000000..970c93d --- /dev/null +++ b/frontend/src/app/app.config.ts @@ -0,0 +1,20 @@ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { provideOAuthClient } from 'angular-oauth2-oidc'; +import { authInterceptor } from '../utilities/auth.interceptor'; +import { provideNativeDateAdapter } from '@angular/material/core'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideAnimationsAsync(), + provideHttpClient(withInterceptors([authInterceptor])), + provideOAuthClient(), + provideNativeDateAdapter(), + ], +}; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts new file mode 100644 index 0000000..933335e --- /dev/null +++ b/frontend/src/app/app.routes.ts @@ -0,0 +1,72 @@ +import { Routes } from '@angular/router'; +import { WelcomePageComponent } from './welcome-page/welcome-page.component'; +import { WelcomeManagementComponent } from './welcome-page/welcome-management/welcome-management.component'; +import { WelcomeMaintenanceComponent } from './welcome-page/welcome-maintenance/welcome-maintenance.component'; +import { WelcomeTrackingComponent } from './welcome-page/welcome-tracking/welcome-tracking.component'; +import { RegisterComponent } from './auth/components/register/register.component'; +import { LoginComponent } from './auth/components/login/login.component'; +import { PasswordResetComponent } from './auth/components/password-reset/password-reset.component'; +import { loggedUserRestrictGuard } from '../utilities/guards/logged-user-restrict.guard'; +import { VehiclesAllComponent } from './vehicles/components/vehicles-all/vehicles-all.component'; +import { authGuard } from '../utilities/guards/auth.guard'; +import { VehicleCreateComponent } from './vehicles/components/vehicle-create/vehicle-create.component'; +import { VehicleDetailsComponent } from './vehicles/components/vehicle-details/vehicle-details.component'; +import { vehicleDetailsResolver } from '../utilities/resolvers/vehicle-details.resolver'; +import { AllLocationsComponent } from './locations/components/all-locations/all-locations.component'; +import { managerRoleGuard } from '../utilities/guards/manager-role.guard'; +import { DriversAllComponent } from './drivers/components/drivers-all/drivers-all.component'; +import { DriverCreateComponent } from './drivers/components/driver-create/driver-create.component'; + +export const routes: Routes = [ + { path: 'management-info', component: WelcomeManagementComponent }, + { path: 'maintenance-info', component: WelcomeMaintenanceComponent }, + { path: 'tracking-info', component: WelcomeTrackingComponent }, + { + path: 'login', + component: LoginComponent, + canActivate: [loggedUserRestrictGuard], + }, + { + path: 'register', + component: RegisterComponent, + canActivate: [loggedUserRestrictGuard], + }, + { + path: 'password-reset', + component: PasswordResetComponent, + canActivate: [loggedUserRestrictGuard], + }, + { + path: 'vehicles', + component: VehiclesAllComponent, + canActivate: [authGuard], + }, + { + path: 'new-vehicle', + component: VehicleCreateComponent, + canActivate: [authGuard, managerRoleGuard], + }, + { + path: 'locations', + component: AllLocationsComponent, + canActivate: [authGuard, managerRoleGuard], + }, + { + path: 'vehicles/details/:id', + component: VehicleDetailsComponent, + canActivate: [authGuard, managerRoleGuard], + resolve: { vehicle: vehicleDetailsResolver }, + }, + { + path: 'drivers', + component: DriversAllComponent, + canActivate: [authGuard, managerRoleGuard], + }, + { + path: 'create-driver', + component: DriverCreateComponent, + canActivate: [authGuard, managerRoleGuard], + }, + { path: '', component: WelcomePageComponent }, + { path: '**', redirectTo: '' }, +]; diff --git a/frontend/src/app/auth/_helpers.ts b/frontend/src/app/auth/_helpers.ts new file mode 100644 index 0000000..603b455 --- /dev/null +++ b/frontend/src/app/auth/_helpers.ts @@ -0,0 +1,20 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +export const PASSWORD_PATTERN = + '^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[A-Z])(?=.*[\\_\\^\\@\\!\\-\\#\\$\\.\\%\\&\\*])(?=.*[a-zA-Z]).{8,16}$'; + +export const PHONE_NUMBER_PATTERN = '^\\+?\\d{10,15}$'; + +export const passwordMatchValidator: ValidatorFn = ( + control: AbstractControl +): ValidationErrors | null => { + const password = control.get('password')?.value; + const repeatPassword = control.get('repeatPassword')?.value; + const mismatch = { mismatch: true }; + if (password !== repeatPassword) { + control.get('repeatPassword')?.setErrors(mismatch); + return mismatch; + } + control.get('repeatPassword')?.setErrors(null); + return null; +}; diff --git a/frontend/src/app/auth/components/login/login.component.html b/frontend/src/app/auth/components/login/login.component.html new file mode 100644 index 0000000..ca7bb85 --- /dev/null +++ b/frontend/src/app/auth/components/login/login.component.html @@ -0,0 +1,85 @@ + +

Welcome Back

+

Please enter your details.

+
+ + Email + + + + + Password + + + +
+ Remember me + Forgot password? +
+ + + +
+ + +
+
+
diff --git a/frontend/src/app/auth/components/login/login.component.spec.ts b/frontend/src/app/auth/components/login/login.component.spec.ts new file mode 100644 index 0000000..9731d31 --- /dev/null +++ b/frontend/src/app/auth/components/login/login.component.spec.ts @@ -0,0 +1,120 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { AuthService } from '../../service/auth.service'; +import { SnackbarService } from '../../../../utilities/services/snackbar.service'; +import { LoginComponent } from './login.component'; +import { By } from '@angular/platform-browser'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { RouterTestingModule } from '@angular/router/testing'; +import { INVALID_FORM_MESSAGE } from '../../../../utilities/_constants'; +import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + let authService: jasmine.SpyObj; + let snackbarService: jasmine.SpyObj; + + beforeEach(async () => { + const authServiceSpy = jasmine.createSpyObj('AuthService', ['login', 'loginViaGoogle']); + const snackbarServiceSpy = jasmine.createSpyObj('SnackbarService', ['openSnackBar']); + + await TestBed.configureTestingModule({ + imports: [ + LoginComponent, + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatCheckboxModule, + MatButtonModule, + RouterTestingModule, + BrowserAnimationsModule + ], + declarations: [], + providers: [ + { provide: AuthService, useValue: authServiceSpy }, + { provide: SnackbarService, useValue: snackbarServiceSpy } + ] + }).compileComponents(); + + authService = TestBed.inject(AuthService) as jasmine.SpyObj; + snackbarService = TestBed.inject(SnackbarService) as jasmine.SpyObj; + + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize loginForm with email, password, and rememberMe controls', () => { + expect(component.loginForm.contains('email')).toBeTruthy(); + expect(component.loginForm.contains('password')).toBeTruthy(); + expect(component.loginForm.contains('rememberMe')).toBeTruthy(); + }); + + it('should make email and password controls required', () => { + const emailControl = component.loginForm.get('email'); + const passwordControl = component.loginForm.get('password'); + + emailControl?.setValue(''); + passwordControl?.setValue(''); + + expect(emailControl?.valid).toBeFalsy(); + expect(passwordControl?.valid).toBeFalsy(); + expect(emailControl?.hasError('required')).toBeTruthy(); + expect(passwordControl?.hasError('required')).toBeTruthy(); + }); + + it('should call authService.login with form values when form is valid', () => { + const formValues = { + email: 'test@example.com', + password: 'password123', + rememberMe: true + }; + component.loginForm.setValue(formValues); + + component.onSubmit(); + + expect(authService.login).toHaveBeenCalledWith({ + email: formValues.email, + password: formValues.password + }); + expect(snackbarService.openSnackBar).not.toHaveBeenCalled(); + }); + + it('should not call authService.login and show snackbar when form is invalid', () => { + component.loginForm.setValue({ + email: '', + password: '', + rememberMe: false + }); + + component.onSubmit(); + + expect(authService.login).not.toHaveBeenCalled(); + expect(snackbarService.openSnackBar).toHaveBeenCalledWith(INVALID_FORM_MESSAGE); + }); + + it('should call authService.loginViaGoogle when onGoogleLogin is called', () => { + component.onGoogleLogin(); + expect(authService.loginViaGoogle).toHaveBeenCalled(); + }); + + it('should enable the submit button if the form is valid', () => { + component.loginForm.setValue({ + email: 'test@example.com', + password: 'password123', + rememberMe: false + }); + fixture.detectChanges(); + + const submitButton = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement; + expect(submitButton.disabled).toBeFalsy(); + }); +}); diff --git a/frontend/src/app/auth/components/login/login.component.ts b/frontend/src/app/auth/components/login/login.component.ts new file mode 100644 index 0000000..192d1d9 --- /dev/null +++ b/frontend/src/app/auth/components/login/login.component.ts @@ -0,0 +1,63 @@ +import { Component } from '@angular/core'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatButton } from '@angular/material/button'; +import { MatInput } from '@angular/material/input'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { SignComponent } from '../sign/sign.component'; +import { SnackbarService } from '../../../../utilities/services/snackbar.service'; +import { INVALID_FORM_MESSAGE } from '../../../../utilities/_constants'; +import { AuthService } from '../../service/auth.service'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [ + MatLabel, + MatFormField, + ReactiveFormsModule, + MatInput, + MatButton, + MatCheckbox, + RouterLink, + SignComponent, + ], + templateUrl: './login.component.html', + styleUrl: '../sign/sign.component.scss', +}) +export class LoginComponent { + loginForm: FormGroup; + + constructor( + private authService: AuthService, + public snackbarService: SnackbarService + ) { + this.loginForm = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + password: new FormControl('', [Validators.required]), + rememberMe: new FormControl(false), + }); + } + + onSubmit(): void { + if (this.loginForm.valid) { + const formValues = this.loginForm.value; + this.authService.login({ + email: formValues.email, + password: formValues.password, + }); + } else { + this.snackbarService.openSnackBar(INVALID_FORM_MESSAGE); + } + } + + onGoogleLogin(): void { + this.authService.loginViaGoogle(); + } +} diff --git a/frontend/src/app/auth/components/password-reset/password-reset.component.html b/frontend/src/app/auth/components/password-reset/password-reset.component.html new file mode 100644 index 0000000..7f3730a --- /dev/null +++ b/frontend/src/app/auth/components/password-reset/password-reset.component.html @@ -0,0 +1,18 @@ + +

Password Reset

+

Please enter your email address.

+
+ + Email + + +
+ + +
+
+
diff --git a/frontend/src/app/auth/components/password-reset/password-reset.component.ts b/frontend/src/app/auth/components/password-reset/password-reset.component.ts new file mode 100644 index 0000000..1214aa3 --- /dev/null +++ b/frontend/src/app/auth/components/password-reset/password-reset.component.ts @@ -0,0 +1,49 @@ +import { Component } from '@angular/core'; +import { SignComponent } from '../sign/sign.component'; +import { MatButton } from '@angular/material/button'; +import { RouterLink } from '@angular/router'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { SnackbarService } from '../../../../utilities/services/snackbar.service'; +import { INVALID_FORM_MESSAGE } from '../../../../utilities/_constants'; + +@Component({ + selector: 'app-password-reset', + standalone: true, + imports: [ + MatLabel, + MatFormField, + ReactiveFormsModule, + MatInput, + MatButton, + RouterLink, + SignComponent, + ], + templateUrl: './password-reset.component.html', + styleUrl: '../sign/sign.component.scss', +}) +export class PasswordResetComponent { + passwordResetForm: FormGroup; + + constructor(public snackbarService: SnackbarService) { + this.passwordResetForm = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + }); + } + + onSubmit(): void { + if (this.passwordResetForm.valid) { + const formValues = this.passwordResetForm.value; + alert('Form Submitted' + formValues.email); + this.passwordResetForm.reset(); + } else { + this.snackbarService.openSnackBar(INVALID_FORM_MESSAGE); + } + } +} diff --git a/frontend/src/app/auth/components/register/register.component.html b/frontend/src/app/auth/components/register/register.component.html new file mode 100644 index 0000000..dacfd1e --- /dev/null +++ b/frontend/src/app/auth/components/register/register.component.html @@ -0,0 +1,125 @@ + +

Welcome

+ +
+
+ + Name + + + + Surname + + +
+ + Email + + + Please enter a valid email address + + + + + Phone number + + + Please enter a valid phone number + + + + + Password + + + 8-16 characters, one uppercase letter, one lowercase letter, one + digit, one special character. + + + + Repeat Password + + + Passwords do not match + + + + +
+ + +
+
+
diff --git a/frontend/src/app/auth/components/register/register.component.spec.ts b/frontend/src/app/auth/components/register/register.component.spec.ts new file mode 100644 index 0000000..c995f3e --- /dev/null +++ b/frontend/src/app/auth/components/register/register.component.spec.ts @@ -0,0 +1,85 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import {MatError, MatFormFieldModule} from '@angular/material/form-field'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { RouterTestingModule } from '@angular/router/testing'; +import { RegisterComponent } from './register.component'; +import { AuthService } from '../../service/auth.service'; +import { SnackbarService } from '../../../../utilities/services/snackbar.service'; +import {BrowserAnimationsModule} from "@angular/platform-browser/animations"; + +describe('RegisterComponent', () => { + let component: RegisterComponent; + let fixture: ComponentFixture; + let authService: jasmine.SpyObj; + let snackBarService: jasmine.SpyObj; + + beforeEach(async () => { + authService = jasmine.createSpyObj('AuthService', ['register', 'loginViaGoogle']); + snackBarService = jasmine.createSpyObj('SnackbarService', ['openSnackBar']); + + await TestBed.configureTestingModule({ + declarations: [], + imports: [ + RegisterComponent, + ReactiveFormsModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatError, + MatCheckboxModule, + RouterTestingModule, + BrowserAnimationsModule + ], + providers: [ + { provide: AuthService, useValue: authService }, + { provide: SnackbarService, useValue: snackBarService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RegisterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the register component', () => { + expect(component).toBeTruthy(); + }); + + it('should call register method of AuthService when form is valid and submitted', () => { + component.registerForm.controls['name'].setValue('John'); + component.registerForm.controls['surname'].setValue('Doe'); + component.registerForm.controls['email'].setValue('test@example.com'); + component.registerForm.controls['password'].setValue('Password1!'); + component.registerForm.controls['repeatPassword'].setValue('Password1!'); + component.registerForm.controls['phone'].setValue('1234567890'); + + component.onSubmit(); + + expect(authService.register).toHaveBeenCalledWith({ + name: 'John', + surname: 'Doe', + email: 'test@example.com', + password: 'Password1!', + number: '1234567890', + }); + expect(snackBarService.openSnackBar).not.toHaveBeenCalled(); + }); + + it('should show an error message if the form is invalid and submitted', () => { + component.registerForm.controls['name'].setValue(''); + component.registerForm.controls['surname'].setValue(''); + component.registerForm.controls['email'].setValue('invalid-email'); + component.registerForm.controls['password'].setValue(''); + component.registerForm.controls['repeatPassword'].setValue(''); + + component.onSubmit(); + + expect(snackBarService.openSnackBar).toHaveBeenCalledWith('Please fill in the form correctly'); + expect(authService.register).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/auth/components/register/register.component.ts b/frontend/src/app/auth/components/register/register.component.ts new file mode 100644 index 0000000..60b43ea --- /dev/null +++ b/frontend/src/app/auth/components/register/register.component.ts @@ -0,0 +1,91 @@ +import { Component } from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatError, MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { SignComponent } from '../sign/sign.component'; +import { RouterLink } from '@angular/router'; +import { NgClass, NgIf } from '@angular/common'; +import { SnackbarService } from '../../../../utilities/services/snackbar.service'; +import { + PASSWORD_PATTERN, + passwordMatchValidator, + PHONE_NUMBER_PATTERN, +} from '../../_helpers'; +import { INVALID_FORM_MESSAGE } from '../../../../utilities/_constants'; +import { AuthService } from '../../service/auth.service'; + +@Component({ + selector: 'app-register', + standalone: true, + imports: [ + MatLabel, + MatFormField, + ReactiveFormsModule, + MatInput, + MatButton, + MatCheckbox, + RouterLink, + SignComponent, + MatError, + NgIf, + NgClass, + ], + templateUrl: './register.component.html', + styleUrl: '../sign/sign.component.scss', +}) +export class RegisterComponent { + registerForm: FormGroup; + + constructor( + private authService: AuthService, + public snackBarService: SnackbarService + ) { + this.registerForm = new FormGroup( + { + name: new FormControl('', Validators.required), + surname: new FormControl('', Validators.required), + email: new FormControl('', [ + Validators.required, + Validators.email, + ]), + phone: new FormControl( + '', + Validators.pattern(PHONE_NUMBER_PATTERN) + ), + password: new FormControl('', [ + Validators.required, + Validators.pattern(PASSWORD_PATTERN), + ]), + repeatPassword: new FormControl('', [Validators.required]), + }, + { validators: passwordMatchValidator } + ); + } + + onSubmit(): void { + if (this.registerForm.valid) { + const formValues = this.registerForm.value; + this.authService.register({ + name: formValues.name, + surname: formValues.surname, + email: formValues.email, + password: formValues.password, + number: formValues.phone || null, + }); + this.registerForm.reset(); + } else { + this.snackBarService.openSnackBar(INVALID_FORM_MESSAGE); + } + } + + onGoogleLogin(): void { + this.authService.loginViaGoogle(); + } +} diff --git a/frontend/src/app/auth/components/sign/sign.component.html b/frontend/src/app/auth/components/sign/sign.component.html new file mode 100644 index 0000000..2d87ad4 --- /dev/null +++ b/frontend/src/app/auth/components/sign/sign.component.html @@ -0,0 +1,18 @@ + diff --git a/frontend/src/app/auth/components/sign/sign.component.scss b/frontend/src/app/auth/components/sign/sign.component.scss new file mode 100644 index 0000000..b4c4dfc --- /dev/null +++ b/frontend/src/app/auth/components/sign/sign.component.scss @@ -0,0 +1,26 @@ +@import 'variables'; + +:host { + .accent { + background-color: $basic_color; + color: $hover-color; + } + + * { + color: $basic-color; + } + + svg { + width: 20px; + } + + form { + @apply w-3/4; + } +} + +.floating-button { + position: absolute; + top: 1rem; + left: 1rem; +} diff --git a/frontend/src/app/auth/components/sign/sign.component.spec.ts b/frontend/src/app/auth/components/sign/sign.component.spec.ts new file mode 100644 index 0000000..15f69b2 --- /dev/null +++ b/frontend/src/app/auth/components/sign/sign.component.spec.ts @@ -0,0 +1,34 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { RouterTestingModule } from '@angular/router/testing'; +import { SignComponent } from './sign.component'; + +describe('SignComponent', () => { + let component: SignComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [], + imports: [SignComponent, MatButtonModule, MatIconModule, RouterTestingModule], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SignComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the sign component', () => { + expect(component).toBeTruthy(); + }); + + it('should display the image on large screens', () => { + const image = fixture.debugElement.query(By.css('div.hidden.lg\\:block img')); + expect(image).toBeTruthy(); + expect(image.nativeElement.src).toContain('management.jpg'); + }); +}); diff --git a/frontend/src/app/auth/components/sign/sign.component.ts b/frontend/src/app/auth/components/sign/sign.component.ts new file mode 100644 index 0000000..450a8b5 --- /dev/null +++ b/frontend/src/app/auth/components/sign/sign.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { MatFabAnchor } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-sign', + standalone: true, + imports: [MatFabAnchor, MatIcon, RouterLink], + templateUrl: './sign.component.html', + styleUrl: './sign.component.scss', +}) +export class SignComponent {} diff --git a/frontend/src/app/auth/service/auth-google.service.spec.ts b/frontend/src/app/auth/service/auth-google.service.spec.ts new file mode 100644 index 0000000..b0b8ca7 --- /dev/null +++ b/frontend/src/app/auth/service/auth-google.service.spec.ts @@ -0,0 +1,81 @@ +import { TestBed } from '@angular/core/testing'; +import { AuthGoogleService } from './auth-google.service'; +import { OAuthService, AuthConfig, OAuthSuccessEvent } from 'angular-oauth2-oidc'; + +describe('AuthGoogleService', () => { + let service: AuthGoogleService; + let oauthServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + oauthServiceSpy = jasmine.createSpyObj('OAuthService', [ + 'configure', + 'setupAutomaticSilentRefresh', + 'loadDiscoveryDocument', + 'tryLoginImplicitFlow', + 'hasValidAccessToken', + 'initLoginFlow', + 'revokeTokenAndLogout', + 'logOut', + 'getIdToken' + ]); + + oauthServiceSpy.loadDiscoveryDocument.and.returnValue(Promise.resolve() as unknown as Promise); + oauthServiceSpy.tryLoginImplicitFlow.and.returnValue(Promise.resolve(true)); + + TestBed.configureTestingModule({ + providers: [ + AuthGoogleService, + { provide: OAuthService, useValue: oauthServiceSpy } + ] + }); + + service = TestBed.inject(AuthGoogleService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should configure OAuthService on initialization', () => { + service.initConfiguration(); + expect(oauthServiceSpy.configure).toHaveBeenCalledWith(jasmine.objectContaining({ + issuer: 'https://accounts.google.com', + clientId: jasmine.any(String), + redirectUri: window.location.origin, + scope: jasmine.any(String), + })); + expect(oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalled(); + expect(oauthServiceSpy.loadDiscoveryDocument).toHaveBeenCalled(); + }); + + it('should not call initLoginFlow if a valid access token already exists on login', async () => { + oauthServiceSpy.hasValidAccessToken.and.returnValue(true); + service.login(); + expect(oauthServiceSpy.initLoginFlow).not.toHaveBeenCalled(); + }); + + it('should update accessTokenSubject to empty string on logout', () => { + service.logout(); + expect(oauthServiceSpy.revokeTokenAndLogout).toHaveBeenCalled(); + expect(oauthServiceSpy.logOut).toHaveBeenCalled(); + service.accessToken$.subscribe(token => { + expect(token).toBe(''); + }); + }); + + it('should return the correct token from getToken', () => { + const mockToken = 'mock-id-token'; + oauthServiceSpy.getIdToken.and.returnValue(mockToken); + expect(service.getToken()).toBe(mockToken); + }); + + it('should return true when isLoggedIn is called and access token is valid', () => { + oauthServiceSpy.hasValidAccessToken.and.returnValue(true); + expect(service.isLoggedIn()).toBeTrue(); + }); + + it('should return false when isLoggedIn is called and access token is invalid', () => { + oauthServiceSpy.hasValidAccessToken.and.returnValue(false); + expect(service.isLoggedIn()).toBeFalse(); + }); +}); diff --git a/frontend/src/app/auth/service/auth-google.service.ts b/frontend/src/app/auth/service/auth-google.service.ts new file mode 100644 index 0000000..599b0d1 --- /dev/null +++ b/frontend/src/app/auth/service/auth-google.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; +import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthGoogleService { + private accessTokenSubject: BehaviorSubject = + new BehaviorSubject(undefined); + accessToken$: Observable = + this.accessTokenSubject.asObservable(); + + constructor(private oauthService: OAuthService) { + this.initConfiguration(); + } + + initConfiguration(): void { + const authConfig: AuthConfig = { + issuer: 'https://accounts.google.com', + strictDiscoveryDocumentValidation: false, + redirectUri: window.location.origin, + clientId: environment.oidcClientId, + scope: 'openid profile email https://www.googleapis.com/auth/gmail.readonly', + }; + + this.oauthService.configure(authConfig); + this.oauthService.setupAutomaticSilentRefresh(undefined, 'id_token'); + + this.oauthService.loadDiscoveryDocument().then(() => { + this.oauthService.tryLoginImplicitFlow().then(() => { + if (this.oauthService.hasValidAccessToken()) { + this.accessTokenSubject.next(this.getToken()); + } else { + this.accessTokenSubject.next(''); + } + }); + }); + } + + login(): void { + this.oauthService.loadDiscoveryDocument().then(() => { + this.oauthService.tryLoginImplicitFlow().then(() => { + if (!this.oauthService.hasValidAccessToken()) { + this.oauthService.initLoginFlow(); + } + }); + }); + } + + logout(): void { + this.oauthService.revokeTokenAndLogout(); + this.oauthService.logOut(); + this.accessTokenSubject.next(''); + } + + getToken(): string { + return this.oauthService.getIdToken(); + } + + isLoggedIn(): boolean { + return this.oauthService.hasValidAccessToken(); + } +} diff --git a/frontend/src/app/auth/service/auth-http.service.spec.ts b/frontend/src/app/auth/service/auth-http.service.spec.ts new file mode 100644 index 0000000..9ae53bf --- /dev/null +++ b/frontend/src/app/auth/service/auth-http.service.spec.ts @@ -0,0 +1,72 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { AuthHttpService } from './auth-http.service'; +import { AuthResponse, LoginForm, RegisterForm, UserInfo } from '../types/auth'; +import { LOGIN_URL, REGISTER_URL, USER_INFO_URL } from '../../../utilities/_urls'; + +describe('AuthHttpService', () => { + let service: AuthHttpService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [AuthHttpService] + }); + service = TestBed.inject(AuthHttpService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should call login API with correct URL and data', () => { + const loginForm: LoginForm = { email: 'test@example.com', password: 'password' }; + const mockResponse: AuthResponse = { + user: { role: 'USER', name: 'Test User', surname: 'surname', email: 'test@example.com' }, + token: { accessToken: 'mockAccessToken', refreshToken: 'mockRefreshToken' } + }; + + service.login(loginForm).subscribe(response => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(LOGIN_URL); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(loginForm); + + req.flush(mockResponse); + }); + + it('should call register API with correct URL and data', () => { + const registerForm: RegisterForm = { email: 'new@example.com', password: 'newpassword', name: 'New', surname: 'User' }; + const mockResponse: AuthResponse = { + user: { role: 'USER', name: 'New', surname: 'User', email: 'new@example.com' }, + token: { accessToken: 'newAccessToken', refreshToken: 'newRefreshToken' } + }; + + service.register(registerForm).subscribe(response => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(REGISTER_URL); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(registerForm); + + req.flush(mockResponse); + }); + + it('should call getUserData API with correct URL', () => { + const mockUserInfo: UserInfo = { role: 'USER', name: 'User Data', surname: 'surname', email: 'email@example.com' }; + + service.getUserData().subscribe(response => { + expect(response).toEqual(mockUserInfo); + }); + + const req = httpMock.expectOne(USER_INFO_URL); + expect(req.request.method).toBe('GET'); + + req.flush(mockUserInfo); + }); +}); diff --git a/frontend/src/app/auth/service/auth-http.service.ts b/frontend/src/app/auth/service/auth-http.service.ts new file mode 100644 index 0000000..cad0c82 --- /dev/null +++ b/frontend/src/app/auth/service/auth-http.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { AuthResponse, LoginForm, RegisterForm, UserInfo } from '../types/auth'; +import { Observable } from 'rxjs'; +import { + LOGIN_URL, + REGISTER_URL, + USER_INFO_URL, +} from '../../../utilities/_urls'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthHttpService { + constructor(private http: HttpClient) {} + + login(loginForm: LoginForm): Observable { + return this.http.post(LOGIN_URL, loginForm) as Observable; + } + + register(registerForm: RegisterForm): Observable { + return this.http.post( + REGISTER_URL, + registerForm + ) as Observable; + } + + getUserData(): Observable { + return this.http.get(USER_INFO_URL) as Observable; + } +} diff --git a/frontend/src/app/auth/service/auth.service.spec.ts b/frontend/src/app/auth/service/auth.service.spec.ts new file mode 100644 index 0000000..a721375 --- /dev/null +++ b/frontend/src/app/auth/service/auth.service.spec.ts @@ -0,0 +1,167 @@ +import { TestBed } from '@angular/core/testing'; +import { AuthService } from './auth.service'; +import { AuthGoogleService } from './auth-google.service'; +import { AuthHttpService } from './auth-http.service'; +import { SnackbarService } from '../../../utilities/services/snackbar.service'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { LoginForm, RegisterForm, UserInfo } from '../types/auth'; +import { INVALID_FORM_MESSAGE } from '../../../utilities/_constants'; + +describe('AuthService', () => { + let service: AuthService; + let authGoogleServiceSpy: jasmine.SpyObj; + let httpAuthServiceSpy: jasmine.SpyObj; + let snackbarServiceSpy: jasmine.SpyObj; + let routerSpy: jasmine.SpyObj; + + beforeEach(() => { + authGoogleServiceSpy = jasmine.createSpyObj('AuthGoogleService', [ + 'login', + 'logout', + 'isLoggedIn', + 'getToken', + 'accessToken$' + ]); + httpAuthServiceSpy = jasmine.createSpyObj('AuthHttpService', [ + 'login', + 'register', + 'getUserData' + ]); + snackbarServiceSpy = jasmine.createSpyObj('SnackbarService', [ + 'openSnackBar' + ]); + routerSpy = jasmine.createSpyObj('Router', ['navigate']); + + authGoogleServiceSpy.accessToken$ = of('mockToken'); + httpAuthServiceSpy.getUserData.and.returnValue(of({ role: 'MANAGER', name: 'New User', surname: 'surname', email: 'new@example.com' })); + + TestBed.configureTestingModule({ + providers: [ + AuthService, + { provide: AuthGoogleService, useValue: authGoogleServiceSpy }, + { provide: AuthHttpService, useValue: httpAuthServiceSpy }, + { provide: SnackbarService, useValue: snackbarServiceSpy }, + { provide: Router, useValue: routerSpy } + ] + }); + + service = TestBed.inject(AuthService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should log in and navigate to home on successful login', () => { + const loginForm: LoginForm = { email: 'test@example.com', password: 'password' }; + const mockResponse = { + user: { role: 'MANAGER', name: 'New User', surname: 'surname', email: 'new@example.com' }, + token: { accessToken: 'newAccessToken', refreshToken: 'refreshToken' } + }; + httpAuthServiceSpy.login.and.returnValue(of(mockResponse)); + + service.login(loginForm); + + expect(httpAuthServiceSpy.login).toHaveBeenCalledWith(loginForm); + expect(localStorage.getItem('userInfo')).toEqual(JSON.stringify(mockResponse.user)); + expect(localStorage.getItem('accessToken')).toBe(mockResponse.token.accessToken); + expect(routerSpy.navigate).toHaveBeenCalledWith(['/']); + }); + + it('should show error message on failed login', () => { + httpAuthServiceSpy.login.and.returnValue(throwError('error')); + const loginForm: LoginForm = { email: 'test@example.com', password: 'wrongpassword' }; + + service.login(loginForm); + + expect(snackbarServiceSpy.openSnackBar).toHaveBeenCalledWith('Check your Email and Password and try again'); + }); + + it('should register and navigate to home on successful registration', () => { + const registerForm: RegisterForm = { email: 'new@example.com', password: 'newpassword', name: 'New User', surname: 'surname' }; + const mockResponse = { + user: { role: 'MANAGER', name: 'New User', surname: 'surname', email: 'new@example.com' }, + token: { accessToken: 'newAccessToken', refreshToken: 'refreshToken' } + }; + httpAuthServiceSpy.register.and.returnValue(of(mockResponse)); + + service.register(registerForm); + + expect(httpAuthServiceSpy.register).toHaveBeenCalledWith(registerForm); + expect(localStorage.getItem('userInfo')).toEqual(JSON.stringify(mockResponse.user)); + expect(localStorage.getItem('accessToken')).toBe(mockResponse.token.accessToken); + expect(routerSpy.navigate).toHaveBeenCalledWith(['/']); + }); + + it('should show error message on failed registration', () => { + httpAuthServiceSpy.register.and.returnValue(throwError('error')); + const registerForm: RegisterForm = { email: 'new@example.com', password: 'newpassword', name: 'New User', surname: 'surname' }; + + service.register(registerForm); + + expect(snackbarServiceSpy.openSnackBar).toHaveBeenCalledWith(INVALID_FORM_MESSAGE); + }); + + it('should log out and remove user data from localStorage', () => { + spyOn(localStorage, 'removeItem').and.callThrough(); + authGoogleServiceSpy.isLoggedIn.and.returnValue(true); + + service.logout(); + + expect(authGoogleServiceSpy.logout).toHaveBeenCalled(); + expect(localStorage.removeItem).toHaveBeenCalledWith('userInfo'); + expect(localStorage.removeItem).toHaveBeenCalledWith('accessToken'); + }); + + it('should call AuthGoogleService login for loginViaGoogle', () => { + service.loginViaGoogle(); + expect(authGoogleServiceSpy.login).toHaveBeenCalled(); + }); + + it('should return true if accessToken is present in localStorage', () => { + localStorage.setItem('accessToken', 'mockToken'); + expect(service.isLoggedIn()).toBeTrue(); + }); + + it('should return false if accessToken is not present in localStorage', () => { + localStorage.removeItem('accessToken'); + expect(service.isLoggedIn()).toBeFalse(); + }); + + it('should return true if user role is MANAGER', () => { + const mockUser: UserInfo = { role: 'MANAGER', name: 'Manager', surname: 'surname', email: 'email' }; + localStorage.setItem('userInfo', JSON.stringify(mockUser)); + + expect(service.isManager()).toBeTrue(); + }); + + it('should return false if user role is not MANAGER', () => { + const mockUser: UserInfo = { role: 'USER', name: 'User', surname: 'surname', email: 'email' }; + localStorage.setItem('userInfo', JSON.stringify(mockUser)); + + expect(service.isManager()).toBeFalse(); + }); + + it('should return parsed userInfo if present in localStorage', () => { + const mockUser: UserInfo = { role: 'USER', name: 'Test User', surname: 'surname', email: 'email' }; + localStorage.setItem('userInfo', JSON.stringify(mockUser)); + + expect(service.getUserInfo()).toEqual(mockUser); + }); + + it('should return null if userInfo is not present in localStorage', () => { + localStorage.removeItem('userInfo'); + expect(service.getUserInfo()).toBeNull(); + }); + + it('should update localStorage with user data when getUserData is called', () => { + const mockUserData: UserInfo = { role: 'USER', name: 'User Data', surname: 'surname', email: 'email' }; + httpAuthServiceSpy.getUserData.and.returnValue(of(mockUserData)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).getUserData(); + + expect(localStorage.getItem('userInfo')).toEqual(JSON.stringify(mockUserData)); + }); +}); diff --git a/frontend/src/app/auth/service/auth.service.ts b/frontend/src/app/auth/service/auth.service.ts new file mode 100644 index 0000000..6d8c183 --- /dev/null +++ b/frontend/src/app/auth/service/auth.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from '@angular/core'; +import { AuthGoogleService } from './auth-google.service'; +import { LoginForm, RegisterForm, UserInfo } from '../types/auth'; +import { AuthHttpService } from './auth-http.service'; +import { INVALID_FORM_MESSAGE } from '../../../utilities/_constants'; +import { SnackbarService } from '../../../utilities/services/snackbar.service'; +import { Router } from '@angular/router'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthService { + constructor( + private authGoogleService: AuthGoogleService, + private httpAuthService: AuthHttpService, + private snackbarService: SnackbarService, + private router: Router + ) { + this.authGoogleService.accessToken$.subscribe((token) => { + if (token !== undefined && token !== '') { + if (!localStorage.getItem('accessToken')) { + localStorage.setItem( + 'accessToken', + authGoogleService.getToken() + ); + this.getUserData(); + } else if (!localStorage.getItem('userInfo')) { + this.getUserData(); + } + } + }); + } + + login(loginForm: LoginForm): void { + this.httpAuthService.login(loginForm).subscribe( + (response) => { + localStorage.setItem('userInfo', JSON.stringify(response.user)); + localStorage.setItem('accessToken', response.token.accessToken); + this.router.navigate(['/']); + }, + () => { + this.snackbarService.openSnackBar( + 'Check your Email and Password and try again' + ); + } + ); + } + + register(registerForm: RegisterForm): void { + this.httpAuthService.register(registerForm).subscribe( + (response) => { + localStorage.setItem('userInfo', JSON.stringify(response.user)); + localStorage.setItem('accessToken', response.token.accessToken); + this.router.navigate(['/']); + }, + () => { + this.snackbarService.openSnackBar(INVALID_FORM_MESSAGE); + } + ); + } + + logout(): void { + if (this.authGoogleService.isLoggedIn()) { + this.authGoogleService.logout(); + } + + localStorage.removeItem('userInfo'); + localStorage.removeItem('accessToken'); + } + + loginViaGoogle(): void { + this.authGoogleService.login(); + } + + isLoggedIn(): boolean { + return !!localStorage.getItem('accessToken'); + } + + isManager(): boolean { + const userInfo = localStorage.getItem('userInfo'); + if (!userInfo) return false; + const user: UserInfo = JSON.parse(userInfo); + return user.role === 'MANAGER'; + } + + getUserInfo(): UserInfo | null { + const userInfo = localStorage.getItem('userInfo'); + if (!userInfo) return null; + return JSON.parse(userInfo); + } + + private getUserData(): void { + this.httpAuthService.getUserData().subscribe((response) => { + localStorage.setItem('userInfo', JSON.stringify(response)); + }); + } +} diff --git a/frontend/src/app/auth/types/auth.ts b/frontend/src/app/auth/types/auth.ts new file mode 100644 index 0000000..2d947ad --- /dev/null +++ b/frontend/src/app/auth/types/auth.ts @@ -0,0 +1,29 @@ +export interface UserInfo { + name: string; + surname: string; + email: string; + role: string; +} + +export interface LoginForm { + email: string; + password: string; +} + +export interface RegisterForm { + name: string; + surname: string; + email: string; + password: string; + number?: string; +} + +export interface AuthResponse { + token: TokenDTO; + user: UserInfo; +} + +export interface TokenDTO { + accessToken: string; + refreshToken: string; +} diff --git a/frontend/src/app/common/components/add-card/add-card.component.html b/frontend/src/app/common/components/add-card/add-card.component.html new file mode 100644 index 0000000..024d8c9 --- /dev/null +++ b/frontend/src/app/common/components/add-card/add-card.component.html @@ -0,0 +1,21 @@ + + + + + + + diff --git a/frontend/src/app/common/components/add-card/add-card.component.scss b/frontend/src/app/common/components/add-card/add-card.component.scss new file mode 100644 index 0000000..afc0772 --- /dev/null +++ b/frontend/src/app/common/components/add-card/add-card.component.scss @@ -0,0 +1,25 @@ +.add-card { + background-color: #e4e4e4 !important; + cursor: pointer; + transition: + background-color 0.3s ease, + box-shadow 0.3s ease; + @apply flex + justify-center + items-center + w-[300px] + h-[300px] + hover:shadow-xl + active:shadow-md + transition-all + duration-300 + ease-in-out; +} + +.add-card:hover { + background-color: #cfcfcf !important; +} + +.add-card:active { + background-color: #b5b5b5 !important; +} diff --git a/frontend/src/app/common/components/add-card/add-card.component.ts b/frontend/src/app/common/components/add-card/add-card.component.ts new file mode 100644 index 0000000..942484f --- /dev/null +++ b/frontend/src/app/common/components/add-card/add-card.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { MatCard, MatCardContent } from '@angular/material/card'; + +@Component({ + selector: 'app-add-card', + standalone: true, + imports: [MatCard, MatCardContent], + templateUrl: './add-card.component.html', + styleUrl: './add-card.component.scss', +}) +export class AddCardComponent {} diff --git a/frontend/src/app/common/components/bottom-nav/bottom-nav.component.html b/frontend/src/app/common/components/bottom-nav/bottom-nav.component.html new file mode 100644 index 0000000..3eeada2 --- /dev/null +++ b/frontend/src/app/common/components/bottom-nav/bottom-nav.component.html @@ -0,0 +1,44 @@ +
+
+ + + + +
+
diff --git a/frontend/src/app/common/components/bottom-nav/bottom-nav.component.scss b/frontend/src/app/common/components/bottom-nav/bottom-nav.component.scss new file mode 100644 index 0000000..4cf4143 --- /dev/null +++ b/frontend/src/app/common/components/bottom-nav/bottom-nav.component.scss @@ -0,0 +1,3 @@ +.bottom-nav-button { + @apply inline-flex flex-col items-center justify-center px-5 active:bg-gray-300 transition-colors duration-300 ease-out; +} diff --git a/frontend/src/app/common/components/bottom-nav/bottom-nav.component.spec.ts b/frontend/src/app/common/components/bottom-nav/bottom-nav.component.spec.ts new file mode 100644 index 0000000..708d2ea --- /dev/null +++ b/frontend/src/app/common/components/bottom-nav/bottom-nav.component.spec.ts @@ -0,0 +1,136 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BottomNavComponent } from './bottom-nav.component'; +import { AuthService } from '../../../auth/service/auth.service'; +import { MatIcon } from '@angular/material/icon'; +import { RouterLink } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { By } from '@angular/platform-browser'; + +describe('BottomNavComponent', () => { + let fixture: ComponentFixture; + let authService: jasmine.SpyObj; + + const activatedRouteMock = { + snapshot: { + queryParamMap: of(new Map([['someParam', 'value']])), + }, + }; + + beforeEach(async () => { + const authServiceSpy = jasmine.createSpyObj('AuthService', [ + 'isLoggedIn', + 'isManager', + ]); + await TestBed.configureTestingModule({ + imports: [BottomNavComponent, MatIcon, RouterLink], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteMock }, + { provide: AuthService, useValue: authServiceSpy }, + ], + }).compileComponents(); + + authService = TestBed.inject( + AuthService + ) as jasmine.SpyObj; + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BottomNavComponent); + fixture.detectChanges(); + }); + + it('should not render the bottom nav when not logged in', () => { + authService.isLoggedIn.and.returnValue(false); + fixture.detectChanges(); + + const bottomNav = fixture.debugElement.query(By.css('div[ngIf]')); + expect(bottomNav).toBeFalsy(); + }); + + it('should render the bottom nav when logged in', async () => { + authService.isLoggedIn.and.returnValue(true); + fixture.detectChanges(); + await fixture.whenStable(); + + const bottomNav = fixture.debugElement.query(By.css('div.fixed')); + expect(bottomNav).toBeTruthy(); + }); + + it('should show the correct number of columns for managers', () => { + authService.isLoggedIn.and.returnValue(true); + authService.isManager.and.returnValue(true); + fixture.detectChanges(); + + const grid = fixture.debugElement.query(By.css('.grid')); + expect(grid.classes['grid-cols-4']).toBeTruthy(); + }); + + it('should show the correct number of columns for non-managers', () => { + authService.isLoggedIn.and.returnValue(true); + authService.isManager.and.returnValue(false); + fixture.detectChanges(); + + const grid = fixture.debugElement.query(By.css('.grid')); + expect(grid.classes['grid-cols-2']).toBeTruthy(); + }); + + it('should render the Locations button for managers', () => { + authService.isLoggedIn.and.returnValue(true); + authService.isManager.and.returnValue(true); + fixture.detectChanges(); + + const locationButton = fixture.debugElement.query( + By.css('button[routerLink="/locations"]') + ); + expect(locationButton).toBeTruthy(); + }); + + it('should not render the Locations button for non-managers', () => { + authService.isLoggedIn.and.returnValue(true); + authService.isManager.and.returnValue(false); + fixture.detectChanges(); + + const locationButton = fixture.debugElement.query( + By.css('button[routerLink="/locations"]') + ); + expect(locationButton).toBeFalsy(); + }); + + it('should render the Drivers button for managers', () => { + authService.isLoggedIn.and.returnValue(true); + authService.isManager.and.returnValue(true); + fixture.detectChanges(); + + const driversButton = fixture.debugElement.query( + By.css('button[routerLink="/drivers"]') + ); + expect(driversButton).toBeTruthy(); + }); + + it('should not render the Drivers button for non-managers', () => { + authService.isLoggedIn.and.returnValue(true); + authService.isManager.and.returnValue(false); + fixture.detectChanges(); + + const driversButton = fixture.debugElement.query( + By.css('button[routerLink="/drivers"]') + ); + expect(driversButton).toBeFalsy(); + }); + + it('should render Dashboard and Vehicles buttons for everyone', () => { + authService.isLoggedIn.and.returnValue(true); + fixture.detectChanges(); + + const dashboardButton = fixture.debugElement.query( + By.css('button[routerLink="/dashboard"]') + ); + const vehiclesButton = fixture.debugElement.query( + By.css('button[routerLink="/vehicles"]') + ); + + expect(dashboardButton).toBeTruthy(); + expect(vehiclesButton).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/common/components/bottom-nav/bottom-nav.component.ts b/frontend/src/app/common/components/bottom-nav/bottom-nav.component.ts new file mode 100644 index 0000000..26ae16a --- /dev/null +++ b/frontend/src/app/common/components/bottom-nav/bottom-nav.component.ts @@ -0,0 +1,24 @@ +import { Component } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { RouterLink } from '@angular/router'; +import { NgClass, NgIf } from '@angular/common'; +import { AuthService } from '../../../auth/service/auth.service'; + +@Component({ + selector: 'app-bottom-nav', + standalone: true, + imports: [MatIcon, RouterLink, NgIf, NgClass], + templateUrl: './bottom-nav.component.html', + styleUrl: './bottom-nav.component.scss', +}) +export class BottomNavComponent { + constructor(private authService: AuthService) {} + + isLoggedIn(): boolean { + return this.authService.isLoggedIn(); + } + + isManager(): boolean { + return this.authService.isManager(); + } +} diff --git a/frontend/src/app/common/components/card-field/card-field.component.html b/frontend/src/app/common/components/card-field/card-field.component.html new file mode 100644 index 0000000..d08651b --- /dev/null +++ b/frontend/src/app/common/components/card-field/card-field.component.html @@ -0,0 +1,7 @@ + + {{ fieldName + ': ' }} + + + {{ fieldValue }} + +
diff --git a/frontend/src/app/common/components/card-field/card-field.component.ts b/frontend/src/app/common/components/card-field/card-field.component.ts new file mode 100644 index 0000000..7a04d5c --- /dev/null +++ b/frontend/src/app/common/components/card-field/card-field.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-card-field', + standalone: true, + imports: [], + templateUrl: './card-field.component.html', +}) +export class CardFieldComponent { + @Input() fieldName!: string; + @Input() fieldValue!: string; +} diff --git a/frontend/src/app/common/components/contact-form/contact-form.component.html b/frontend/src/app/common/components/contact-form/contact-form.component.html new file mode 100644 index 0000000..31db9f3 --- /dev/null +++ b/frontend/src/app/common/components/contact-form/contact-form.component.html @@ -0,0 +1,29 @@ +
+ + Email + + @if ( + contactForm.get('email')?.hasError('email') && + !contactForm.get('email')?.hasError('required') + ) { + Please enter a valid email address + } + @if (contactForm.get('email')?.hasError('required')) { + Email is required + } + + + Leave a message + + + +
diff --git a/frontend/src/app/common/components/contact-form/contact-form.component.spec.ts b/frontend/src/app/common/components/contact-form/contact-form.component.spec.ts new file mode 100644 index 0000000..728bdd1 --- /dev/null +++ b/frontend/src/app/common/components/contact-form/contact-form.component.spec.ts @@ -0,0 +1,74 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ContactFormComponent } from './contact-form.component'; +import { ReactiveFormsModule } from '@angular/forms'; +import { AuthService } from '../../../auth/service/auth.service'; +import { SnackbarService } from '../../../../utilities/services/snackbar.service'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MatError, MatFormField } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatButton } from '@angular/material/button'; + +describe('ContactFormComponent', () => { + let component: ContactFormComponent; + let fixture: ComponentFixture; + let authServiceMock: jasmine.SpyObj; + let snackBarServiceMock: jasmine.SpyObj; + + beforeEach(() => { + authServiceMock = jasmine.createSpyObj('AuthService', ['isLoggedIn']); + snackBarServiceMock = jasmine.createSpyObj('SnackbarService', [ + 'openSnackBar', + ]); + + TestBed.configureTestingModule({ + declarations: [], + imports: [ + ReactiveFormsModule, + ContactFormComponent, + MatFormField, + MatInput, + MatError, + MatButton, + BrowserAnimationsModule, + ], + providers: [ + { provide: AuthService, useValue: authServiceMock }, + { provide: SnackbarService, useValue: snackBarServiceMock }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ContactFormComponent); + component = fixture.componentInstance; + }); + + it('should display email error when the email is invalid', () => { + component.contactForm.controls['email'].setValue('invalid-email'); + component.contactForm.controls['email'].markAsTouched(); + fixture.detectChanges(); + + const errorMessage = fixture.debugElement.query(By.css('mat-error')); + expect(errorMessage.nativeElement.textContent).toContain( + 'Please enter a valid email address' + ); + }); + + it('should display required error when email is not entered', () => { + component.contactForm.controls['email'].setValue(''); + component.contactForm.controls['email'].markAsTouched(); + fixture.detectChanges(); + + const errorMessage = fixture.debugElement.query(By.css('mat-error')); + expect(errorMessage.nativeElement.textContent).toContain( + 'Email is required' + ); + }); + + it('should display the email field when not logged in', () => { + authServiceMock.isLoggedIn.and.returnValue(false); + fixture.detectChanges(); + + const emailField = fixture.debugElement.query(By.css('mat-form-field')); + expect(emailField).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/common/components/contact-form/contact-form.component.ts b/frontend/src/app/common/components/contact-form/contact-form.component.ts new file mode 100644 index 0000000..8434e74 --- /dev/null +++ b/frontend/src/app/common/components/contact-form/contact-form.component.ts @@ -0,0 +1,69 @@ +import { Component, ViewEncapsulation } from '@angular/core'; +import { + MatError, + MatFormField, + MatHint, + MatLabel, +} from '@angular/material/form-field'; +import { + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatInput } from '@angular/material/input'; +import { MatButtonToggle } from '@angular/material/button-toggle'; +import { MatButton } from '@angular/material/button'; +import { SnackbarService } from '../../../../utilities/services/snackbar.service'; +import { INVALID_FORM_MESSAGE } from '../../../../utilities/_constants'; +import { AuthService } from '../../../auth/service/auth.service'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-contact-form', + standalone: true, + imports: [ + MatError, + MatHint, + MatLabel, + MatFormField, + ReactiveFormsModule, + MatInput, + MatButtonToggle, + FormsModule, + MatButton, + NgIf, + ], + templateUrl: './contact-form.component.html', + encapsulation: ViewEncapsulation.None, +}) +export class ContactFormComponent { + contactForm = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + message: new FormControl(''), + }); + + constructor( + public snackBarService: SnackbarService, + private authService: AuthService + ) {} + + onFormSubmit(): void { + if (this.contactForm.valid) { + alert( + 'Form Submitted' + + this.contactForm.value.email + + ' ' + + this.contactForm.value.message + ); + this.contactForm.reset(); + } else { + this.snackBarService.openSnackBar(INVALID_FORM_MESSAGE, 'end'); + } + } + + isLoggedIn(): boolean { + return this.authService.isLoggedIn(); + } +} diff --git a/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.html b/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.html new file mode 100644 index 0000000..c916cb7 --- /dev/null +++ b/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.html @@ -0,0 +1,12 @@ +
+ +
diff --git a/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.scss b/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.scss new file mode 100644 index 0000000..1b120e8 --- /dev/null +++ b/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.scss @@ -0,0 +1,11 @@ +@import 'variables'; +.double-column-paragraph { +} +.double-column-paragraph > * { + @apply flex flex-col text-center md:w-4/12; + padding: $padding; + margin: auto; + img { + width: 72vw; + } +} diff --git a/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.spec.ts b/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.spec.ts new file mode 100644 index 0000000..df2c2a3 --- /dev/null +++ b/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.spec.ts @@ -0,0 +1,56 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DoubleColumnParagraphComponent } from './double-column-paragraph.component'; +import { By } from '@angular/platform-browser'; + +describe('DoubleColumnParagraphComponent', () => { + let component: DoubleColumnParagraphComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DoubleColumnParagraphComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DoubleColumnParagraphComponent); + component = fixture.componentInstance; + }); + + it('should have the correct default classes', () => { + fixture.detectChanges(); + const divElement = fixture.debugElement.query( + By.css('div') + ).nativeElement; + + expect( + divElement.classList.contains('double-column-paragraph') + ).toBeTrue(); + expect(divElement.classList.contains('h-screen')).toBeTrue(); + expect(divElement.classList.contains('flex')).toBeTrue(); + expect(divElement.classList.contains('flex-row')).toBeTrue(); + expect(divElement.classList.contains('flex-row-reverse')).toBeFalse(); + }); + + it('should add flex-row-reverse class when reversed is true', () => { + component.reversed = true; + fixture.detectChanges(); + + const divElement = fixture.debugElement.query( + By.css('div') + ).nativeElement; + + expect(divElement.classList.contains('flex-row-reverse')).toBeTrue(); + expect(divElement.classList.contains('flex-row')).toBeFalse(); + }); + + it('should not add flex-row-reverse class when reversed is false', () => { + component.reversed = false; + fixture.detectChanges(); + + const divElement = fixture.debugElement.query( + By.css('div') + ).nativeElement; + + expect(divElement.classList.contains('flex-row-reverse')).toBeFalse(); + expect(divElement.classList.contains('flex-row')).toBeTrue(); + }); +}); diff --git a/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.ts b/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.ts new file mode 100644 index 0000000..22b94a4 --- /dev/null +++ b/frontend/src/app/common/components/double-column-paragraph/double-column-paragraph.component.ts @@ -0,0 +1,14 @@ +import { Component, Input, ViewEncapsulation } from '@angular/core'; +import { NgClass } from '@angular/common'; + +@Component({ + selector: 'app-double-column-paragraph', + standalone: true, + imports: [NgClass], + templateUrl: './double-column-paragraph.component.html', + styleUrl: './double-column-paragraph.component.scss', + encapsulation: ViewEncapsulation.None, +}) +export class DoubleColumnParagraphComponent { + @Input() reversed = false; +} diff --git a/frontend/src/app/common/components/footer/footer.component.html b/frontend/src/app/common/components/footer/footer.component.html new file mode 100644 index 0000000..2364f30 --- /dev/null +++ b/frontend/src/app/common/components/footer/footer.component.html @@ -0,0 +1,13 @@ +
+ diff --git a/frontend/src/app/common/components/footer/footer.component.scss b/frontend/src/app/common/components/footer/footer.component.scss new file mode 100644 index 0000000..d23bf68 --- /dev/null +++ b/frontend/src/app/common/components/footer/footer.component.scss @@ -0,0 +1,8 @@ +@import 'variables'; + +mat-toolbar { + background-color: $basic_color; + min-height: 10vh; + height: auto; + padding: $padding; +} diff --git a/frontend/src/app/common/components/footer/footer.component.spec.ts b/frontend/src/app/common/components/footer/footer.component.spec.ts new file mode 100644 index 0000000..825c74f --- /dev/null +++ b/frontend/src/app/common/components/footer/footer.component.spec.ts @@ -0,0 +1,43 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FooterComponent } from './footer.component'; +import { AuthService } from '../../../auth/service/auth.service'; +import { By } from '@angular/platform-browser'; +import { MatToolbar } from '@angular/material/toolbar'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +describe('FooterComponent', () => { + let fixture: ComponentFixture; + let authServiceSpy: jasmine.SpyObj; + + beforeEach(async () => { + authServiceSpy = jasmine.createSpyObj('AuthService', ['isLoggedIn']); + await TestBed.configureTestingModule({ + imports: [FooterComponent, MatToolbar], + providers: [{ provide: AuthService, useValue: authServiceSpy }], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(FooterComponent); + }); + + it('should display the footer correctly when logged out', () => { + authServiceSpy.isLoggedIn.and.returnValue(false); + fixture.detectChanges(); + + const footer = fixture.debugElement.query(By.css('footer')); + const div = fixture.debugElement.query(By.css('div')); + + expect(div).toBeNull(); + + expect(footer.styles['display']).toBe('flex'); + }); + + it('should display the footer correctly with the links when logged out', () => { + authServiceSpy.isLoggedIn.and.returnValue(false); + fixture.detectChanges(); + + const footerLinks = fixture.debugElement.queryAll(By.css('.link')); + + expect(footerLinks.length).toBe(3); + }); +}); diff --git a/frontend/src/app/common/components/footer/footer.component.ts b/frontend/src/app/common/components/footer/footer.component.ts new file mode 100644 index 0000000..b231e50 --- /dev/null +++ b/frontend/src/app/common/components/footer/footer.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; +import { MatToolbar } from '@angular/material/toolbar'; +import { NgIf, NgStyle } from '@angular/common'; +import { AuthService } from '../../../auth/service/auth.service'; + +@Component({ + selector: 'app-footer', + standalone: true, + imports: [MatToolbar, NgIf, NgStyle], + templateUrl: './footer.component.html', + styleUrl: './footer.component.scss', +}) +export class FooterComponent { + constructor(private authService: AuthService) {} + + isLoggedIn(): boolean { + return this.authService.isLoggedIn(); + } +} diff --git a/frontend/src/app/common/components/header/header.component.html b/frontend/src/app/common/components/header/header.component.html new file mode 100644 index 0000000..dd41a94 --- /dev/null +++ b/frontend/src/app/common/components/header/header.component.html @@ -0,0 +1,65 @@ +
+ + + +
+ account_circle + + + + + + + + + + +
+
+
diff --git a/frontend/src/app/common/components/header/header.component.scss b/frontend/src/app/common/components/header/header.component.scss new file mode 100644 index 0000000..4b94ad7 --- /dev/null +++ b/frontend/src/app/common/components/header/header.component.scss @@ -0,0 +1,36 @@ +@import 'variables'; + +mat-toolbar { + background-color: $basic_color; + min-height: 10vh; + + span { + color: $hover-color; + } + + mat-menu { + color: $basic_color; + } +} + +.user-info { + font-size: $font-size; +} + +.white-icon { + color: white; + cursor: pointer; + transition: + color 0.3s ease-out, + transform 0.3s ease-out; +} + +.white-icon:hover { + color: #b8b8b8; + transform: scale(1.1); +} + +.white-icon:active { + color: #8c8c8c; + transform: scale(0.9); +} diff --git a/frontend/src/app/common/components/header/header.component.spec.ts b/frontend/src/app/common/components/header/header.component.spec.ts new file mode 100644 index 0000000..e70d30f --- /dev/null +++ b/frontend/src/app/common/components/header/header.component.spec.ts @@ -0,0 +1,62 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HeaderComponent } from './header.component'; +import { AuthService } from '../../../auth/service/auth.service'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; + +describe('HeaderComponent', () => { + let fixture: ComponentFixture; + let authServiceSpy: jasmine.SpyObj; + + beforeEach(async () => { + authServiceSpy = jasmine.createSpyObj('AuthService', [ + 'isLoggedIn', + 'getUserInfo', + ]); + + await TestBed.configureTestingModule({ + imports: [RouterTestingModule, HeaderComponent], + providers: [{ provide: AuthService, useValue: authServiceSpy }], + }).compileComponents(); + + fixture = TestBed.createComponent(HeaderComponent); + }); + + it('should display Sign In and Sign Up links when not logged in', () => { + authServiceSpy.isLoggedIn.and.returnValue(false); + + fixture.detectChanges(); + + const signInLink = fixture.debugElement.query( + By.css('a[routerLink="/login"]') + ); + const signUpLink = fixture.debugElement.query( + By.css('a[routerLink="/register"]') + ); + + expect(signInLink).not.toBeNull(); + expect(signUpLink).not.toBeNull(); + }); + + it('should display user role and username when logged in', () => { + authServiceSpy.isLoggedIn.and.returnValue(true); + + authServiceSpy.getUserInfo.and.returnValue({ + name: 'John', + surname: 'Doe', + email: 'john.doe@example.com', + role: 'Admin', + }); + + fixture.detectChanges(); + + const userRole = fixture.debugElement.query( + By.css('.user-info') + ).nativeElement; + const userName = fixture.debugElement.queryAll(By.css('.user-info'))[1] + .nativeElement; + + expect(userRole.textContent).toBe('Admin'); + expect(userName.textContent).toBe('John Doe, john.doe@example.com'); + }); +}); diff --git a/frontend/src/app/common/components/header/header.component.ts b/frontend/src/app/common/components/header/header.component.ts new file mode 100644 index 0000000..8dbd590 --- /dev/null +++ b/frontend/src/app/common/components/header/header.component.ts @@ -0,0 +1,58 @@ +import { Component } from '@angular/core'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatIcon } from '@angular/material/icon'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { MatIconButton } from '@angular/material/button'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../../auth/service/auth.service'; +import { NgIf } from '@angular/common'; +import { MatTooltip } from '@angular/material/tooltip'; + +@Component({ + selector: 'app-header', + standalone: true, + imports: [ + MatToolbarModule, + MatIcon, + MatCheckbox, + MatMenuTrigger, + MatMenu, + MatIconButton, + MatMenuItem, + RouterLink, + NgIf, + MatTooltip, + ], + templateUrl: './header.component.html', + styleUrl: './header.component.scss', +}) +export class HeaderComponent { + constructor( + private authService: AuthService, + private router: Router + ) {} + + onLogOut(): void { + this.authService.logout(); + this.router.navigate(['']); + } + + isLoggedIn(): boolean { + return this.authService.isLoggedIn(); + } + + getUsername(): string { + return ( + this.authService.getUserInfo()?.name + + ' ' + + this.authService.getUserInfo()?.surname + + ', ' + + this.authService.getUserInfo()?.email + ); + } + + getUserRole(): string | undefined { + return this.authService.getUserInfo()?.role; + } +} diff --git a/frontend/src/app/common/components/nav-button/nav-button.component.html b/frontend/src/app/common/components/nav-button/nav-button.component.html new file mode 100644 index 0000000..080217b --- /dev/null +++ b/frontend/src/app/common/components/nav-button/nav-button.component.html @@ -0,0 +1,13 @@ + diff --git a/frontend/src/app/common/components/nav-button/nav-button.component.scss b/frontend/src/app/common/components/nav-button/nav-button.component.scss new file mode 100644 index 0000000..4dc9b27 --- /dev/null +++ b/frontend/src/app/common/components/nav-button/nav-button.component.scss @@ -0,0 +1,15 @@ +@import 'variables'; + +:host { + @apply w-3/4; +} +.mdc-button { + @apply w-full flex items-center; + .mat-icon, + span { + margin: 0; + padding: 0; + color: $basic-color; + transform: scale(1.5); + } +} diff --git a/frontend/src/app/common/components/nav-button/nav-button.component.spec.ts b/frontend/src/app/common/components/nav-button/nav-button.component.spec.ts new file mode 100644 index 0000000..e332990 --- /dev/null +++ b/frontend/src/app/common/components/nav-button/nav-button.component.spec.ts @@ -0,0 +1,41 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NavButtonComponent } from './nav-button.component'; +import { RouterTestingModule } from '@angular/router/testing'; +import { By } from '@angular/platform-browser'; + +describe('NavButtonComponent', () => { + let component: NavButtonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NavButtonComponent, RouterTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(NavButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should display the correct icon', () => { + component.iconName = 'home'; + fixture.detectChanges(); + + const iconElement = fixture.debugElement.query( + By.css('mat-icon') + ).nativeElement; + expect(iconElement.textContent).toBe('home'); + }); + + it('should apply the correct style when isTextVisible is true', () => { + component.isTextVisible = true; + fixture.detectChanges(); + + const buttonElement = fixture.debugElement.query( + By.css('button') + ).nativeElement; + const style = buttonElement.style.justifyContent; + expect(style).toBe('start'); + expect(buttonElement.style.gap).toBe('1rem'); + }); +}); diff --git a/frontend/src/app/common/components/nav-button/nav-button.component.ts b/frontend/src/app/common/components/nav-button/nav-button.component.ts new file mode 100644 index 0000000..1d8e4e4 --- /dev/null +++ b/frontend/src/app/common/components/nav-button/nav-button.component.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatButton } from '@angular/material/button'; +import { RouterLink } from '@angular/router'; +import { NgClass, NgIf, NgStyle } from '@angular/common'; + +@Component({ + selector: 'app-nav-button', + standalone: true, + imports: [MatIcon, MatButton, RouterLink, NgClass, NgStyle, NgIf], + templateUrl: './nav-button.component.html', + styleUrl: './nav-button.component.scss', +}) +export class NavButtonComponent { + @Input({ required: true }) iconName?: string; + @Input({ required: true }) link?: string; + @Input({ required: true }) text?: string; + @Input({ required: true }) isTextVisible?: boolean; +} diff --git a/frontend/src/app/common/components/sidenav/sidenav.component.html b/frontend/src/app/common/components/sidenav/sidenav.component.html new file mode 100644 index 0000000..e63cde9 --- /dev/null +++ b/frontend/src/app/common/components/sidenav/sidenav.component.html @@ -0,0 +1,49 @@ + + + + + + diff --git a/frontend/src/app/common/components/sidenav/sidenav.component.scss b/frontend/src/app/common/components/sidenav/sidenav.component.scss new file mode 100644 index 0000000..0e24764 --- /dev/null +++ b/frontend/src/app/common/components/sidenav/sidenav.component.scss @@ -0,0 +1,20 @@ +@import 'variables'; + +#sidenav { + transition: width 0.3s ease; + display: none !important; +} + +.menu-open { + width: 200px; +} + +.menu-close { + width: 80px; +} + +@media (min-width: 768px) { + #sidenav { + display: flex !important; + } +} diff --git a/frontend/src/app/common/components/sidenav/sidenav.component.spec.ts b/frontend/src/app/common/components/sidenav/sidenav.component.spec.ts new file mode 100644 index 0000000..afad6ed --- /dev/null +++ b/frontend/src/app/common/components/sidenav/sidenav.component.spec.ts @@ -0,0 +1,81 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SidenavComponent } from './sidenav.component'; +import { AuthService } from '../../../auth/service/auth.service'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatIconModule } from '@angular/material/icon'; +import { RouterTestingModule } from '@angular/router/testing'; +import { By } from '@angular/platform-browser'; +import { NavButtonComponent } from '../nav-button/nav-button.component'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +describe('SidenavComponent', () => { + let component: SidenavComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [], + imports: [ + MatSidenavModule, + MatIconModule, + RouterTestingModule, + SidenavComponent, + NavButtonComponent, + BrowserAnimationsModule, + ], + providers: [ + { + provide: AuthService, + useValue: { + isLoggedIn: jasmine.createSpy().and.returnValue(true), + isManager: jasmine.createSpy().and.returnValue(true), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SidenavComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should toggle menu state when toolbar menu icon is clicked', () => { + const toolbarMenu = fixture.debugElement.query(By.css('#toolbarMenu')); + + toolbarMenu.triggerEventHandler('click', null); + fixture.detectChanges(); + + expect(component.isMenuOpen).toBeTrue(); + expect(component.contentMargin).toBe(240); + + toolbarMenu.triggerEventHandler('click', null); + fixture.detectChanges(); + + expect(component.isMenuOpen).toBeFalse(); + expect(component.contentMargin).toBe(70); + }); + + it('should show Dashboard and Vehicles buttons when logged in', () => { + const dashboardButton = fixture.debugElement.query( + By.css('app-nav-button[link="/dashboard"]') + ); + const vehiclesButton = fixture.debugElement.query( + By.css('app-nav-button[link="/vehicles"]') + ); + + expect(dashboardButton).toBeTruthy(); + expect(vehiclesButton).toBeTruthy(); + }); + + it('should show Locations and Drivers buttons when logged in as a manager', () => { + const locationsButton = fixture.debugElement.query( + By.css('app-nav-button[link="/locations"]') + ); + const driversButton = fixture.debugElement.query( + By.css('app-nav-button[link="/drivers"]') + ); + + expect(locationsButton).toBeTruthy(); + expect(driversButton).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/common/components/sidenav/sidenav.component.ts b/frontend/src/app/common/components/sidenav/sidenav.component.ts new file mode 100644 index 0000000..ae6f59b --- /dev/null +++ b/frontend/src/app/common/components/sidenav/sidenav.component.ts @@ -0,0 +1,81 @@ +import { Component } from '@angular/core'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { FormsModule } from '@angular/forms'; +import { MatIcon } from '@angular/material/icon'; +import { Router, RouterLink } from '@angular/router'; +import { MatDivider } from '@angular/material/divider'; +import { + MatList, + MatListItem, + MatListOption, + MatSelectionList, +} from '@angular/material/list'; +import { + MatCard, + MatCardHeader, + MatCardSubtitle, + MatCardTitle, +} from '@angular/material/card'; +import { NgIf, NgStyle } from '@angular/common'; +import { MatToolbar } from '@angular/material/toolbar'; +import { MatBadge } from '@angular/material/badge'; +import { NavButtonComponent } from '../nav-button/nav-button.component'; +import { AuthService } from '../../../auth/service/auth.service'; + +@Component({ + selector: 'app-sidenav', + standalone: true, + imports: [ + MatSidenavModule, + MatCheckboxModule, + FormsModule, + MatButtonModule, + MatIcon, + RouterLink, + MatDivider, + MatListItem, + MatCardTitle, + MatCardSubtitle, + MatCardHeader, + MatList, + MatCard, + NgStyle, + MatSelectionList, + MatListOption, + MatToolbar, + MatBadge, + NavButtonComponent, + NgIf, + ], + templateUrl: './sidenav.component.html', + styleUrl: './sidenav.component.scss', +}) +export class SidenavComponent { + isMenuOpen = false; + contentMargin = 240; + + constructor( + private authService: AuthService, + public router: Router + ) {} + + onToolbarMenuToggle(): void { + this.isMenuOpen = !this.isMenuOpen; + + if (!this.isMenuOpen) { + this.contentMargin = 70; + } else { + this.contentMargin = 240; + } + } + + isLoggedIn(): boolean { + return this.authService.isLoggedIn(); + } + + isManager(): boolean { + return this.authService.isManager(); + } +} diff --git a/frontend/src/app/drivers/_helpers.ts b/frontend/src/app/drivers/_helpers.ts new file mode 100644 index 0000000..b794398 --- /dev/null +++ b/frontend/src/app/drivers/_helpers.ts @@ -0,0 +1,12 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import moment from 'moment'; + +export function dateOfBirthValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + if (value && moment().diff(moment(value), 'years') >= 16) { + return null; + } + return { minSixteenYears: true }; + }; +} diff --git a/frontend/src/app/drivers/components/driver-card/driver-card.component.html b/frontend/src/app/drivers/components/driver-card/driver-card.component.html new file mode 100644 index 0000000..4ea6fcd --- /dev/null +++ b/frontend/src/app/drivers/components/driver-card/driver-card.component.html @@ -0,0 +1,36 @@ + + + + + {{ driver.name + ' ' + driver.surname }} + + + + + + In registration process + + + +
+ + + + + +
diff --git a/frontend/src/app/drivers/components/driver-card/driver-card.component.scss b/frontend/src/app/drivers/components/driver-card/driver-card.component.scss new file mode 100644 index 0000000..f4cc6f6 --- /dev/null +++ b/frontend/src/app/drivers/components/driver-card/driver-card.component.scss @@ -0,0 +1,13 @@ +.driver-card { + transition: + background-color 0.3s ease, + box-shadow 0.3s ease; +} + +.driver-card:hover { + background-color: #ededed !important; +} + +.driver-card:active { + background-color: #d3d3d3 !important; +} diff --git a/frontend/src/app/drivers/components/driver-card/driver-card.component.ts b/frontend/src/app/drivers/components/driver-card/driver-card.component.ts new file mode 100644 index 0000000..86ef073 --- /dev/null +++ b/frontend/src/app/drivers/components/driver-card/driver-card.component.ts @@ -0,0 +1,33 @@ +import { Component, Input } from '@angular/core'; +import { + MatCard, + MatCardContent, + MatCardHeader, + MatCardSubtitle, + MatCardTitle, +} from '@angular/material/card'; +import { Driver } from '../../types/drivers'; +import { CardFieldComponent } from '../../../common/components/card-field/card-field.component'; +import { toDisplayDate } from '../../../../utilities/date-utils'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-driver-card', + standalone: true, + imports: [ + MatCard, + MatCardHeader, + MatCardTitle, + CardFieldComponent, + MatCardContent, + MatCardSubtitle, + NgIf, + ], + templateUrl: './driver-card.component.html', + styleUrl: './driver-card.component.scss', +}) +export class DriverCardComponent { + @Input() driver!: Driver; + + protected readonly toDisplayDate = toDisplayDate; +} diff --git a/frontend/src/app/drivers/components/driver-create/driver-create.component.html b/frontend/src/app/drivers/components/driver-create/driver-create.component.html new file mode 100644 index 0000000..23cbbd6 --- /dev/null +++ b/frontend/src/app/drivers/components/driver-create/driver-create.component.html @@ -0,0 +1,86 @@ +
+

Provide driver details:

+
+
+ + Name + + + + Surname + + + + Email + + + Please enter a valid email address + + + + Date of Birth + + DD/MM/YYYY + + + Driver age must be 16 or higher + + + + Driver license country code + + + + Driver license number + + +
+ + +
+
+
+
diff --git a/frontend/src/app/drivers/components/driver-create/driver-create.component.scss b/frontend/src/app/drivers/components/driver-create/driver-create.component.scss new file mode 100644 index 0000000..bc0a3f6 --- /dev/null +++ b/frontend/src/app/drivers/components/driver-create/driver-create.component.scss @@ -0,0 +1,12 @@ +@import 'variables'; + +:host { + .accent { + background-color: $basic_color; + color: $hover-color; + } + + * { + color: $basic-color; + } +} diff --git a/frontend/src/app/drivers/components/driver-create/driver-create.component.ts b/frontend/src/app/drivers/components/driver-create/driver-create.component.ts new file mode 100644 index 0000000..44767fd --- /dev/null +++ b/frontend/src/app/drivers/components/driver-create/driver-create.component.ts @@ -0,0 +1,101 @@ +import { Component, DestroyRef } from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { SnackbarService } from '../../../../utilities/services/snackbar.service'; +import { dateOfBirthValidator } from '../../_helpers'; +import { + MatError, + MatFormField, + MatHint, + MatLabel, +} from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { NgIf } from '@angular/common'; +import { + MatDatepicker, + MatDatepickerInput, +} from '@angular/material/datepicker'; +import { MatButton } from '@angular/material/button'; +import moment from 'moment'; +import { DriverCreateRequest } from '../../types/drivers'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { INVALID_FORM_MESSAGE } from '../../../../utilities/_constants'; +import { DriversService } from '../../service/drivers.service'; + +@Component({ + selector: 'app-driver-create', + standalone: true, + imports: [ + ReactiveFormsModule, + MatFormField, + MatLabel, + MatInput, + MatError, + NgIf, + MatDatepickerInput, + MatHint, + MatDatepicker, + MatButton, + RouterLink, + ], + templateUrl: './driver-create.component.html', + styleUrl: './driver-create.component.scss', +}) +export class DriverCreateComponent { + createDriverForm!: FormGroup; + + constructor( + private router: Router, + private snackBarService: SnackbarService, + private destroyRef: DestroyRef, + private driverService: DriversService + ) { + this.createDriverForm = new FormGroup({ + name: new FormControl('', Validators.required), + surname: new FormControl('', Validators.required), + email: new FormControl('', [Validators.required, Validators.email]), + dateOfBirth: new FormControl('', [ + Validators.required, + dateOfBirthValidator(), + ]), + driverLicenseCountryCode: new FormControl('', Validators.required), + driverLicenseNumber: new FormControl('', Validators.required), + }); + } + + onCreateDriver(): void { + if (this.createDriverForm.valid) { + const driverFormValue = this.createDriverForm.value; + const driverCreateRequest: DriverCreateRequest = { + name: driverFormValue.name, + surname: driverFormValue.surname, + email: driverFormValue.email, + driverLicenseCountryCode: + driverFormValue.driverLicenseCountryCode, + drivingLicenseNumber: driverFormValue.driverLicenseNumber, + birthDate: moment(driverFormValue.birthDate).format( + 'YYYY-MM-DD' + ), + }; + + this.driverService + .createDriver(driverCreateRequest) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe( + () => { + this.router.navigate(['/drivers']); + }, + () => { + this.snackBarService.openSnackBar(INVALID_FORM_MESSAGE); + } + ); + } else { + this.snackBarService.openSnackBar(INVALID_FORM_MESSAGE); + } + } +} diff --git a/frontend/src/app/drivers/components/drivers-all/drivers-all.component.html b/frontend/src/app/drivers/components/drivers-all/drivers-all.component.html new file mode 100644 index 0000000..0aa93ac --- /dev/null +++ b/frontend/src/app/drivers/components/drivers-all/drivers-all.component.html @@ -0,0 +1,16 @@ +
+
+

Your drivers:

+
+ + +
+
+
+

You don't have any driver in your fleet!

+ +
+
diff --git a/frontend/src/app/drivers/components/drivers-all/drivers-all.component.ts b/frontend/src/app/drivers/components/drivers-all/drivers-all.component.ts new file mode 100644 index 0000000..107c857 --- /dev/null +++ b/frontend/src/app/drivers/components/drivers-all/drivers-all.component.ts @@ -0,0 +1,32 @@ +import { Component, DestroyRef, OnInit } from '@angular/core'; +import { NgForOf, NgIf } from '@angular/common'; +import { Driver } from '../../types/drivers'; +import { DriversService } from '../../service/drivers.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AddCardComponent } from '../../../common/components/add-card/add-card.component'; +import { RouterLink } from '@angular/router'; +import { DriverCardComponent } from '../driver-card/driver-card.component'; + +@Component({ + selector: 'app-drivers-all', + standalone: true, + imports: [NgIf, AddCardComponent, RouterLink, DriverCardComponent, NgForOf], + templateUrl: './drivers-all.component.html', +}) +export class DriversAllComponent implements OnInit { + drivers: Driver[] = []; + + constructor( + private driversService: DriversService, + private destroyRef: DestroyRef + ) {} + + ngOnInit(): void { + this.driversService + .getAllDrivers(false) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + this.drivers = result.content; + }); + } +} diff --git a/frontend/src/app/drivers/service/drivers-http.service.ts b/frontend/src/app/drivers/service/drivers-http.service.ts new file mode 100644 index 0000000..6b5558a --- /dev/null +++ b/frontend/src/app/drivers/service/drivers-http.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import {Observable} from 'rxjs'; +import {DRIVERS_URL, GET_DRIVERS_URL} from '../../../utilities/_urls'; +import { DriverCreateRequest, DriversPage } from '../types/drivers'; + +@Injectable({ + providedIn: 'root', +}) +export class DriversHttpService { + constructor(private http: HttpClient) {} + + getAllDrivers(registered: boolean): Observable { + return this.http.get(GET_DRIVERS_URL(registered)) as Observable; + } + + createDriver(driverCreateRequest: DriverCreateRequest): Observable { + return this.http.post( + DRIVERS_URL, + driverCreateRequest, + {responseType: 'text' as 'json'} + ) as Observable; + } +} diff --git a/frontend/src/app/drivers/service/drivers.service.ts b/frontend/src/app/drivers/service/drivers.service.ts new file mode 100644 index 0000000..47d4586 --- /dev/null +++ b/frontend/src/app/drivers/service/drivers.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { DriversHttpService } from './drivers-http.service'; +import {Observable} from 'rxjs'; +import { DriverCreateRequest, DriversPage } from '../types/drivers'; + +@Injectable({ + providedIn: 'root', +}) +export class DriversService { + constructor(private driversHttpService: DriversHttpService) {} + + getAllDrivers(registered: boolean): Observable { + return this.driversHttpService.getAllDrivers(registered); + } + + createDriver(driverCreateRequest: DriverCreateRequest): Observable { + return this.driversHttpService.createDriver(driverCreateRequest); + } +} diff --git a/frontend/src/app/drivers/types/drivers.ts b/frontend/src/app/drivers/types/drivers.ts new file mode 100644 index 0000000..f699408 --- /dev/null +++ b/frontend/src/app/drivers/types/drivers.ts @@ -0,0 +1,22 @@ +import { UserInfo } from '../../auth/types/auth'; + +export interface Driver extends UserInfo { + id: string; + drivingLicenseNumber: string; + drivingLicenseCountryCode: string; + birthDate: string[]; + isEnabled: boolean; +} + +export interface DriverCreateRequest { + name: string; + surname: string; + email: string; + drivingLicenseNumber: string; + driverLicenseCountryCode: string; + birthDate: string; +} + +export interface DriversPage { + content: Driver[]; +} diff --git a/frontend/src/app/locations/_helpers.ts b/frontend/src/app/locations/_helpers.ts new file mode 100644 index 0000000..08cdc62 --- /dev/null +++ b/frontend/src/app/locations/_helpers.ts @@ -0,0 +1,10 @@ +export const mapOptions: google.maps.MapOptions = { + mapId: 'DEMO_MAP_ID', + zoomControl: true, + scrollwheel: true, + disableDoubleClickZoom: true, + maxZoom: 18, + minZoom: 5, + streetViewControl: false, + mapTypeControl: false, +}; diff --git a/frontend/src/app/locations/components/all-locations/all-locations.component.html b/frontend/src/app/locations/components/all-locations/all-locations.component.html new file mode 100644 index 0000000..b061cd4 --- /dev/null +++ b/frontend/src/app/locations/components/all-locations/all-locations.component.html @@ -0,0 +1,37 @@ +
+

Browse your fleet on map:

+ +
+
+ + + +
+ +
+
diff --git a/frontend/src/app/locations/components/all-locations/all-locations.component.scss b/frontend/src/app/locations/components/all-locations/all-locations.component.scss new file mode 100644 index 0000000..2a5152a --- /dev/null +++ b/frontend/src/app/locations/components/all-locations/all-locations.component.scss @@ -0,0 +1,3 @@ +.map-container { + @apply w-full sm:w-full md:max-w-[80%] lg:max-w-[70%] h-[50vh] sm:h-screen md:h-[70vh] lg:h-[70vh]; +} diff --git a/frontend/src/app/locations/components/all-locations/all-locations.component.ts b/frontend/src/app/locations/components/all-locations/all-locations.component.ts new file mode 100644 index 0000000..42b3968 --- /dev/null +++ b/frontend/src/app/locations/components/all-locations/all-locations.component.ts @@ -0,0 +1,91 @@ +import { Component, DestroyRef, OnInit } from '@angular/core'; +import { GoogleMap, MapAdvancedMarker } from '@angular/google-maps'; +import { Vehicle } from '../../../vehicles/types/vehicles'; +import { VehiclesService } from '../../../vehicles/service/vehicles.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Location } from '../../types/locations'; +import { mapOptions } from '../../_helpers'; +import { NgForOf, NgIf } from '@angular/common'; +import { VehicleCardComponent } from '../../../vehicles/components/vehicle-card/vehicle-card.component'; + +export interface CustomMarker { + position: google.maps.LatLngLiteral; + vehicle: Vehicle; + content: HTMLImageElement; +} + +@Component({ + selector: 'app-all-locations', + standalone: true, + imports: [ + GoogleMap, + NgForOf, + MapAdvancedMarker, + VehicleCardComponent, + NgIf, + ], + templateUrl: './all-locations.component.html', + styleUrl: './all-locations.component.scss', +}) +export class AllLocationsComponent implements OnInit { + mapInitialZoom = 15; + mapInitialCenter!: google.maps.LatLngLiteral; + selectedVehicle: Vehicle | undefined; + selectedMarkerContent: Node; + + private vehicles: Vehicle[] | undefined; + + constructor( + private vehicleService: VehiclesService, + private destroyRef: DestroyRef + ) { + const imgTag = document.createElement('img'); + imgTag.src = 'car-icon-clicked.png'; + this.selectedMarkerContent = imgTag; + } + + ngOnInit(): void { + this.vehicleService + .getAllVehicles({ pageSize: 100, pageNumber: 0 }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((response) => { + this.vehicles = response.content; + this.mapInitialCenter = { + lat: this.vehicles[0].locations[ + this.vehicles[0].locations.length - 1 + ]?.latitude, + lng: this.vehicles[0].locations[ + this.vehicles[0].locations.length - 1 + ]?.longitude, + }; + }); + } + + getMarkers(): CustomMarker[] { + return this.vehicles!.map((vehicle) => { + const lastLocation: Location = + vehicle.locations[vehicle.locations.length - 1]; + const imgTag = document.createElement('img'); + imgTag.src = 'car-icon.png'; + + if (!lastLocation) { + return null; + } + + return { + position: { + lat: lastLocation.latitude, + lng: lastLocation.longitude, + }, + vehicle: vehicle, + content: imgTag, + }; + }).filter((marker): marker is CustomMarker => marker !== null); + } + + onMarkerClick(marker: CustomMarker): void { + this.selectedVehicle = marker.vehicle; + } + + protected readonly mapOptions = mapOptions; +} diff --git a/frontend/src/app/locations/types/locations.ts b/frontend/src/app/locations/types/locations.ts new file mode 100644 index 0000000..e30df69 --- /dev/null +++ b/frontend/src/app/locations/types/locations.ts @@ -0,0 +1,5 @@ +export interface Location { + id: number; + longitude: number; + latitude: number; +} diff --git a/frontend/src/app/vehicles/_helpers.ts b/frontend/src/app/vehicles/_helpers.ts new file mode 100644 index 0000000..cb66582 --- /dev/null +++ b/frontend/src/app/vehicles/_helpers.ts @@ -0,0 +1,34 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import moment from 'moment/moment'; + +export function futureDateValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + if (value && moment(value).isSameOrAfter(moment())) { + return null; + } + return { past: true }; + }; +} + +export function pastDateValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value; + if (value && moment(value).isBefore(moment())) { + return null; + } + return { future: true }; + }; +} + +export const CUSTOM_DATEFORMAT = { + parse: { + dateInput: 'DD/MM/YYYY', + }, + display: { + dateInput: 'DD/MM/YYYY', + monthYearLabel: 'MMMM YYYY', + dateA11yLabel: 'LL', + monthYearA11yLabel: 'MMMM YYYY', + }, +}; diff --git a/frontend/src/app/vehicles/components/vehicle-card/vehicle-card.component.html b/frontend/src/app/vehicles/components/vehicle-card/vehicle-card.component.html new file mode 100644 index 0000000..f2772a6 --- /dev/null +++ b/frontend/src/app/vehicles/components/vehicle-card/vehicle-card.component.html @@ -0,0 +1,49 @@ + + + + + {{ vehicle.name }} + + + + + + +
+ + + + + + + + + +
diff --git a/frontend/src/app/vehicles/components/vehicle-card/vehicle-card.component.scss b/frontend/src/app/vehicles/components/vehicle-card/vehicle-card.component.scss new file mode 100644 index 0000000..4cb2ab7 --- /dev/null +++ b/frontend/src/app/vehicles/components/vehicle-card/vehicle-card.component.scss @@ -0,0 +1,13 @@ +.vehicle-card { + transition: + background-color 0.3s ease, + box-shadow 0.3s ease; +} + +.vehicle-card:hover { + background-color: #ededed !important; +} + +.vehicle-card:active { + background-color: #d3d3d3 !important; +} diff --git a/frontend/src/app/vehicles/components/vehicle-card/vehicle-card.component.ts b/frontend/src/app/vehicles/components/vehicle-card/vehicle-card.component.ts new file mode 100644 index 0000000..364eac0 --- /dev/null +++ b/frontend/src/app/vehicles/components/vehicle-card/vehicle-card.component.ts @@ -0,0 +1,48 @@ +import { Component, Input } from '@angular/core'; +import { + MatCard, + MatCardContent, + MatCardHeader, + MatCardSubtitle, + MatCardTitle, +} from '@angular/material/card'; +import { Vehicle } from '../../types/vehicles'; +import { CardFieldComponent } from '../../../common/components/card-field/card-field.component'; +import { toDisplayDate } from '../../../../utilities/date-utils'; +import { RouterLink } from '@angular/router'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-vehicle-card', + standalone: true, + imports: [ + MatCard, + MatCardContent, + MatCardHeader, + MatCardSubtitle, + MatCardTitle, + CardFieldComponent, + RouterLink, + NgIf, + ], + templateUrl: './vehicle-card.component.html', + styleUrl: './vehicle-card.component.scss', +}) +export class VehicleCardComponent { + @Input() vehicle!: Vehicle; + @Input() isDriver!: boolean; + + getDriverName(): string { + if (this.vehicle.driver) { + return ( + ' ' + + this.vehicle.driver.name + + ' ' + + this.vehicle.driver.surname + ); + } + return ' N/A'; + } + + protected readonly toDisplayDate = toDisplayDate; +} diff --git a/frontend/src/app/vehicles/components/vehicle-create/vehicle-create.component.html b/frontend/src/app/vehicles/components/vehicle-create/vehicle-create.component.html new file mode 100644 index 0000000..5cb0736 --- /dev/null +++ b/frontend/src/app/vehicles/components/vehicle-create/vehicle-create.component.html @@ -0,0 +1,128 @@ +
+

Provide details of your vehicle:

+
+
+ + Name + + + + VIN + + + Please provide valid VIN number + + + + Plate number + + + + Country code + + + + Insurance date + + DD/MM/YYYY + + + Please select a future date + + + + Last inspection date + + DD/MM/YYYY + + + Please select a date that is today or in the past + + + + Production date + + DD/MM/YYYY + + + Please select a date that is today or in the past + + + + Assign driver + + +
+ + +
+
+
+
diff --git a/frontend/src/app/vehicles/components/vehicle-create/vehicle-create.component.scss b/frontend/src/app/vehicles/components/vehicle-create/vehicle-create.component.scss new file mode 100644 index 0000000..bc0a3f6 --- /dev/null +++ b/frontend/src/app/vehicles/components/vehicle-create/vehicle-create.component.scss @@ -0,0 +1,12 @@ +@import 'variables'; + +:host { + .accent { + background-color: $basic_color; + color: $hover-color; + } + + * { + color: $basic-color; + } +} diff --git a/frontend/src/app/vehicles/components/vehicle-create/vehicle-create.component.ts b/frontend/src/app/vehicles/components/vehicle-create/vehicle-create.component.ts new file mode 100644 index 0000000..7b386cf --- /dev/null +++ b/frontend/src/app/vehicles/components/vehicle-create/vehicle-create.component.ts @@ -0,0 +1,143 @@ +import { Component, DestroyRef } from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { + MatError, + MatFormField, + MatHint, + MatLabel, +} from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { VehicleCreateRequest } from '../../types/vehicles'; +import { + MatDatepicker, + MatDatepickerInput, + MatDatepickerToggle, +} from '@angular/material/datepicker'; +import { + DateAdapter, + MAT_DATE_FORMATS, + MAT_DATE_LOCALE, +} from '@angular/material/core'; +import { MomentDateAdapter } from '@angular/material-moment-adapter'; +import { SnackbarService } from '../../../../utilities/services/snackbar.service'; +import { INVALID_FORM_MESSAGE } from '../../../../utilities/_constants'; +import { NgIf } from '@angular/common'; +import { + CUSTOM_DATEFORMAT, + futureDateValidator, + pastDateValidator, +} from '../../_helpers'; +import { Router, RouterLink } from '@angular/router'; +import { VehiclesService } from '../../service/vehicles.service'; +import moment from 'moment/moment'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Driver } from '../../../drivers/types/drivers'; + +@Component({ + selector: 'app-vehicle-create', + standalone: true, + imports: [ + MatButton, + MatFormField, + MatInput, + MatLabel, + MatDatepickerInput, + MatDatepickerToggle, + MatDatepicker, + MatHint, + NgIf, + MatError, + RouterLink, + ReactiveFormsModule, + ], + providers: [ + { + provide: DateAdapter, + useClass: MomentDateAdapter, + deps: [MAT_DATE_LOCALE], + }, + { provide: MAT_DATE_FORMATS, useValue: CUSTOM_DATEFORMAT }, + ], + templateUrl: './vehicle-create.component.html', + styleUrl: './vehicle-create.component.scss', +}) +export class VehicleCreateComponent { + createVehicleForm!: FormGroup; + drivers: Driver[] = []; + + constructor( + private router: Router, + private snackBarService: SnackbarService, + private vehicleService: VehiclesService, + private destroyRef: DestroyRef + ) { + this.createVehicleForm = new FormGroup({ + name: new FormControl('', Validators.required), + vin: new FormControl('', [ + Validators.required, + Validators.maxLength(17), + Validators.minLength(17), + ]), + plateNumber: new FormControl('', Validators.required), + countryCode: new FormControl('', Validators.required), + productionDate: new FormControl('', [ + Validators.required, + pastDateValidator(), + ]), + insuranceDate: new FormControl('', [ + Validators.required, + futureDateValidator(), + ]), + lastVehicleInspectionDate: new FormControl('', [ + Validators.required, + pastDateValidator(), + ]), + driver: new FormControl(''), + }); + } + + onCreateVehicle(): void { + if (this.createVehicleForm.valid) { + const vehicleFormValue = this.createVehicleForm.value; + const vehicleCreateRequest: VehicleCreateRequest = { + name: vehicleFormValue.name, + vin: vehicleFormValue.vin, + plateNumber: vehicleFormValue.plateNumber, + countryCode: vehicleFormValue.countryCode, + insuranceDate: moment(vehicleFormValue.insuranceDate).format( + 'YYYY-MM-DD' + ), + lastInspectionDate: moment( + vehicleFormValue.lastVehicleInspectionDate + ).format('YYYY-MM-DD'), + productionDate: moment(vehicleFormValue.vehicleYear).format( + 'YYYY-MM-DD' + ), + }; + + this.vehicleService + .createVehicle(vehicleCreateRequest) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe( + () => { + this.router.navigate(['/vehicles']); + }, + () => { + this.snackBarService.openSnackBar(INVALID_FORM_MESSAGE); + } + ); + } else { + this.snackBarService.openSnackBar(INVALID_FORM_MESSAGE); + } + } + + getDriverText(): string { + return ''; + } +} diff --git a/frontend/src/app/vehicles/components/vehicle-details/vehicle-details.component.html b/frontend/src/app/vehicles/components/vehicle-details/vehicle-details.component.html new file mode 100644 index 0000000..482ff7a --- /dev/null +++ b/frontend/src/app/vehicles/components/vehicle-details/vehicle-details.component.html @@ -0,0 +1,72 @@ +
+

{{ vehicle.name + ' details' }}

+
+
+
+
{{ vehicle.vin }}
+
VIN
+
+
+
+ {{ vehicle.countryCode }} +
+
Country code
+
+
+
+ {{ vehicle.plateNumber }} +
+
Plate number
+
+
+
+ {{ toDisplayDate(vehicle.productionDate) }} +
+
Production date
+
+
+
+ {{ toDisplayDate(vehicle.insuranceDate) }} +
+
Insurance date
+
+
+
+ {{ toDisplayDate(vehicle.lastInspectionDate) }} +
+
+ Last inspection date +
+
+
+
+ {{ toDisplayDate(vehicle.nextInspectionDate) }} +
+
+ Next inspection date: +
+
+
+
+
+
+ + + +
+
+
diff --git a/frontend/src/app/vehicles/components/vehicle-details/vehicle-details.component.ts b/frontend/src/app/vehicles/components/vehicle-details/vehicle-details.component.ts new file mode 100644 index 0000000..3bfd266 --- /dev/null +++ b/frontend/src/app/vehicles/components/vehicle-details/vehicle-details.component.ts @@ -0,0 +1,56 @@ +import { Component, DestroyRef, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Vehicle } from '../../types/vehicles'; +import { toDisplayDate } from '../../../../utilities/date-utils'; +import { mapOptions } from '../../../locations/_helpers'; +import { GoogleMap, MapAdvancedMarker } from '@angular/google-maps'; +import { NgForOf } from '@angular/common'; +import { VehiclesService } from '../../service/vehicles.service'; +import { Location } from '../../../locations/types/locations'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +@Component({ + selector: 'app-vehicle-details', + standalone: true, + imports: [GoogleMap, MapAdvancedMarker, NgForOf], + templateUrl: './vehicle-details.component.html', +}) +export class VehicleDetailsComponent implements OnInit { + vehicle!: Vehicle; + mapInitialZoom = 15; + mapInitialCenter!: google.maps.LatLngLiteral; + icon: Node; + location!: Location; + + constructor( + private route: ActivatedRoute, + private vehiclesService: VehiclesService, + private destroyRef: DestroyRef + ) { + const imgTag = document.createElement('img'); + imgTag.src = 'car-icon.png'; + this.icon = imgTag; + } + + ngOnInit(): void { + this.vehicle = this.route.snapshot.data['vehicle']; + this.mapInitialCenter = { + lat: this.vehicle.locations[this.vehicle.locations.length - 1] + ?.latitude, + lng: this.vehicle.locations[this.vehicle.locations.length - 1] + ?.longitude, + }; + this.vehiclesService + .getVehicleLocation(this.vehicle.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((location: Location) => { + this.location = location; + }); + } + + getVehicleLastLocation(): google.maps.LatLngLiteral { + return { lat: this.location.latitude, lng: this.location.longitude }; + } + + protected readonly toDisplayDate = toDisplayDate; + protected readonly mapOptions = mapOptions; +} diff --git a/frontend/src/app/vehicles/components/vehicles-all/vehicles-all.component.html b/frontend/src/app/vehicles/components/vehicles-all/vehicles-all.component.html new file mode 100644 index 0000000..b245699 --- /dev/null +++ b/frontend/src/app/vehicles/components/vehicles-all/vehicles-all.component.html @@ -0,0 +1,39 @@ +
+
+

+ {{ isDriver() ? 'Assigned vehicles:' : 'Your fleet:' }} +

+
+ + +
+ + +
+
+

+ {{ + isDriver() + ? 'There are no vehicles currently assigned to you!' + : "You don't have any vehicle in your fleet!" + }} +

+ +
+
diff --git a/frontend/src/app/vehicles/components/vehicles-all/vehicles-all.component.scss b/frontend/src/app/vehicles/components/vehicles-all/vehicles-all.component.scss new file mode 100644 index 0000000..6f7b59f --- /dev/null +++ b/frontend/src/app/vehicles/components/vehicles-all/vehicles-all.component.scss @@ -0,0 +1,4 @@ +.mat-mdc-paginator { + display: flex; + justify-content: center; +} diff --git a/frontend/src/app/vehicles/components/vehicles-all/vehicles-all.component.ts b/frontend/src/app/vehicles/components/vehicles-all/vehicles-all.component.ts new file mode 100644 index 0000000..ab63f58 --- /dev/null +++ b/frontend/src/app/vehicles/components/vehicles-all/vehicles-all.component.ts @@ -0,0 +1,72 @@ +import { Component, OnInit } from '@angular/core'; +import { VehicleCardComponent } from '../vehicle-card/vehicle-card.component'; +import { NgForOf, NgIf } from '@angular/common'; +import { MatCard, MatCardContent } from '@angular/material/card'; +import { MatIcon } from '@angular/material/icon'; +import { Vehicle } from '../../types/vehicles'; +import { AddCardComponent } from '../../../common/components/add-card/add-card.component'; +import { RouterLink } from '@angular/router'; +import { VehiclesService } from '../../service/vehicles.service'; +import { AuthService } from '../../../auth/service/auth.service'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; + +@Component({ + selector: 'app-vehicles-all', + standalone: true, + templateUrl: './vehicles-all.component.html', + styleUrl: './vehicles-all.component.scss', + imports: [ + VehicleCardComponent, + NgForOf, + NgIf, + MatCard, + MatCardContent, + MatIcon, + AddCardComponent, + RouterLink, + MatPaginator, + ], +}) +export class VehiclesAllComponent implements OnInit { + vehicles: Vehicle[] | undefined; + totalElements = 0; + pageIndex = 0; + pageSize = 4; + pageSizeOptions = [2, 4, 9, 14]; + + constructor( + private vehiclesService: VehiclesService, + private authService: AuthService + ) {} + + ngOnInit(): void { + this.vehiclesService + .getAllVehicles({ + pageNumber: this.pageIndex, + pageSize: this.pageSize, + }) + .subscribe((page) => { + this.vehicles = page.content; + this.totalElements = page.totalElements; + }); + } + + handlePageEvent(e: PageEvent): void { + this.pageSize = e.pageSize; + this.pageIndex = e.pageIndex; + + this.vehiclesService + .getAllVehicles({ + pageNumber: this.pageIndex, + pageSize: this.pageSize, + }) + .subscribe((page) => { + this.vehicles = page.content; + this.totalElements = page.totalElements; + }); + } + + isDriver(): boolean { + return !this.authService.isManager(); + } +} diff --git a/frontend/src/app/vehicles/service/vehicles-http.service.ts b/frontend/src/app/vehicles/service/vehicles-http.service.ts new file mode 100644 index 0000000..d751a20 --- /dev/null +++ b/frontend/src/app/vehicles/service/vehicles-http.service.ts @@ -0,0 +1,69 @@ +import { Injectable, NgZone } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + Pageable, + Vehicle, + VehicleCreateRequest, + VehiclesPage, +} from '../types/vehicles'; +import { HttpClient } from '@angular/common/http'; +import { + GET_ALL_VEHICLES_URL, + GET_VEHICLE_BY_ID_URL, + GET_VEHICLE_LIVE_LOCATION_BY_ID_URL, + VEHICLES_URL, +} from '../../../utilities/_urls'; +import { Location } from '../../locations/types/locations'; + +@Injectable({ + providedIn: 'root', +}) +export class VehiclesHttpService { + constructor( + private http: HttpClient, + private ngZone: NgZone + ) {} + + getAllVehicles(pageable: Pageable): Observable { + return this.http.get( + GET_ALL_VEHICLES_URL(pageable) + ) as Observable; + } + + createVehicle( + vehicleCreateRequest: VehicleCreateRequest + ): Observable { + return this.http.post( + VEHICLES_URL, + vehicleCreateRequest + ) as Observable; + } + + getVehicle(id: string): Observable { + return this.http.get(GET_VEHICLE_BY_ID_URL(id)) as Observable; + } + + getVehicleLocation(id: number): Observable { + return new Observable((observer) => { + const url = GET_VEHICLE_LIVE_LOCATION_BY_ID_URL(id.toString()); + const eventSource = new EventSource(url); + + eventSource.onmessage = (event): void => { + this.ngZone.run(() => { + const location = JSON.parse(event.data); + observer.next(location); + }); + }; + + eventSource.onerror = (error): void => { + this.ngZone.run(() => { + observer.error(error); + }); + }; + + return () => { + eventSource.close(); + }; + }); + } +} diff --git a/frontend/src/app/vehicles/service/vehicles.service.ts b/frontend/src/app/vehicles/service/vehicles.service.ts new file mode 100644 index 0000000..99895fb --- /dev/null +++ b/frontend/src/app/vehicles/service/vehicles.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { VehiclesHttpService } from './vehicles-http.service'; +import { + Pageable, + Vehicle, + VehicleCreateRequest, + VehiclesPage, +} from '../types/vehicles'; +import { Observable } from 'rxjs'; +import { Location } from '../../locations/types/locations'; + +@Injectable({ + providedIn: 'root', +}) +export class VehiclesService { + constructor(private vehiclesHttpService: VehiclesHttpService) {} + + getAllVehicles(pageable: Pageable): Observable { + return this.vehiclesHttpService.getAllVehicles(pageable); + } + + createVehicle( + vehicleCreateRequest: VehicleCreateRequest + ): Observable { + return this.vehiclesHttpService.createVehicle(vehicleCreateRequest); + } + + getVehicle(id: string): Observable { + return this.vehiclesHttpService.getVehicle(id); + } + + getVehicleLocation(id: number): Observable { + return this.vehiclesHttpService.getVehicleLocation(id); + } +} diff --git a/frontend/src/app/vehicles/types/vehicles.ts b/frontend/src/app/vehicles/types/vehicles.ts new file mode 100644 index 0000000..25cbab6 --- /dev/null +++ b/frontend/src/app/vehicles/types/vehicles.ts @@ -0,0 +1,37 @@ +import { Location } from '../../locations/types/locations'; +import { Driver } from '../../drivers/types/drivers'; + +export interface Vehicle { + id: number; + name: string; + vin: string; + plateNumber: string; + countryCode: string; + insuranceDate: string[]; + lastInspectionDate: string[]; + nextInspectionDate: string[]; + productionDate: string[]; + driver?: Driver; + locations: Location[] | []; +} + +export interface VehiclesPage { + content: Vehicle[]; + totalElements: number; +} + +export interface Pageable { + pageSize: number; + pageNumber: number; +} + +export interface VehicleCreateRequest { + name: string; + vin: string; + plateNumber: string; + countryCode: string; + insuranceDate: string; + lastInspectionDate: string; + productionDate: string; + driver?: Driver; +} diff --git a/frontend/src/app/welcome-page/welcome-maintenance/welcome-maintenance.component.html b/frontend/src/app/welcome-page/welcome-maintenance/welcome-maintenance.component.html new file mode 100644 index 0000000..5da2790 --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-maintenance/welcome-maintenance.component.html @@ -0,0 +1,42 @@ + +
+

Comprehensive Maintenance Tracking

+

+ Our fleet management software offers comprehensive maintenance + tracking to ensure your vehicles are always in top condition. + Schedule regular maintenance, track service history, and receive + alerts for upcoming maintenance tasks. +

+
+
+ Maintenance Tracking +
+
+ +
+

Maintenance Reports

+

+ Generate detailed maintenance reports to analyze the performance and + costs associated with your fleet's maintenance activities. Use these + insights to optimize your maintenance processes and improve overall + efficiency. +

+
+
+ Maintenance Reports +
+
+ +
+

Preventive Maintenance

+

+ Implement preventive maintenance strategies with our software to + reduce downtime and extend the lifespan of your fleet. Monitor + vehicle health and performance to proactively address potential + issues before they become major problems. +

+
+
+ Preventive Maintenance +
+
diff --git a/frontend/src/app/welcome-page/welcome-maintenance/welcome-maintenance.component.spec.ts b/frontend/src/app/welcome-page/welcome-maintenance/welcome-maintenance.component.spec.ts new file mode 100644 index 0000000..6f12f5c --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-maintenance/welcome-maintenance.component.spec.ts @@ -0,0 +1,99 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { WelcomeMaintenanceComponent } from './welcome-maintenance.component'; +import { DoubleColumnParagraphComponent } from '../../common/components/double-column-paragraph/double-column-paragraph.component'; +import { By } from '@angular/platform-browser'; + +describe('WelcomeMaintenanceComponent', () => { + let component: WelcomeMaintenanceComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + WelcomeMaintenanceComponent, + DoubleColumnParagraphComponent, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(WelcomeMaintenanceComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should render the Comprehensive Maintenance Tracking section', () => { + const maintenanceTracking = fixture.debugElement.query( + By.css('app-double-column-paragraph:nth-of-type(1)') + ); + expect(maintenanceTracking).toBeTruthy(); + + const title = maintenanceTracking.query(By.css('h2')).nativeElement + .textContent; + expect(title).toBe('Comprehensive Maintenance Tracking'); + + const paragraph = maintenanceTracking.query(By.css('p')).nativeElement + .textContent; + expect(paragraph).toContain( + 'Our fleet management software offers comprehensive maintenance tracking' + ); + + const img = maintenanceTracking.query(By.css('img')); + expect(img).toBeTruthy(); + expect(img.nativeElement.getAttribute('alt')).toBe( + 'Maintenance Tracking' + ); + }); + + it('should render the Maintenance Reports section with reversed layout', () => { + const maintenanceReports = fixture.debugElement.query( + By.css('app-double-column-paragraph:nth-of-type(2)') + ); + expect(maintenanceReports).toBeTruthy(); + + const title = maintenanceReports.query(By.css('h2')).nativeElement + .textContent; + expect(title).toBe('Maintenance Reports'); + + const paragraph = maintenanceReports.query(By.css('p')).nativeElement + .textContent; + expect(paragraph).toContain('Generate detailed maintenance reports'); + + const img = maintenanceReports.query(By.css('img')); + expect(img).toBeTruthy(); + expect(img.nativeElement.getAttribute('alt')).toBe( + 'Maintenance Reports' + ); + + const reversedAttr = + maintenanceReports.attributes['ng-reflect-reversed']; + expect(reversedAttr).toBe('true'); + }); + + it('should render the Preventive Maintenance section', () => { + const preventiveMaintenance = fixture.debugElement.query( + By.css('app-double-column-paragraph:nth-of-type(3)') + ); + expect(preventiveMaintenance).toBeTruthy(); + + const title = preventiveMaintenance.query(By.css('h2')).nativeElement + .textContent; + expect(title).toBe('Preventive Maintenance'); + + const paragraph = preventiveMaintenance.query(By.css('p')).nativeElement + .textContent; + expect(paragraph).toContain( + 'Implement preventive maintenance strategies' + ); + + const img = preventiveMaintenance.query(By.css('img')); + expect(img).toBeTruthy(); + expect(img.nativeElement.getAttribute('alt')).toBe( + 'Preventive Maintenance' + ); + }); +}); diff --git a/frontend/src/app/welcome-page/welcome-maintenance/welcome-maintenance.component.ts b/frontend/src/app/welcome-page/welcome-maintenance/welcome-maintenance.component.ts new file mode 100644 index 0000000..ebbd086 --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-maintenance/welcome-maintenance.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { DoubleColumnParagraphComponent } from '../../common/components/double-column-paragraph/double-column-paragraph.component'; + +@Component({ + selector: 'app-welcome-maintenance', + standalone: true, + imports: [DoubleColumnParagraphComponent], + templateUrl: './welcome-maintenance.component.html', +}) +export class WelcomeMaintenanceComponent {} diff --git a/frontend/src/app/welcome-page/welcome-management/welcome-management.component.html b/frontend/src/app/welcome-page/welcome-management/welcome-management.component.html new file mode 100644 index 0000000..c5edd56 --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-management/welcome-management.component.html @@ -0,0 +1,39 @@ + +
+

Fleet Assistant Overview

+

+ Our fleet management software provides a comprehensive overview of + your fleet's operations. Monitor vehicle locations, driver behavior, + and overall fleet performance in real-time. +

+
+
+ Fleet Management Overview +
+
+ +
+

Driver Management

+

+ Effectively manage your drivers with our software. Track driving + hours, monitor compliance with regulations, and ensure driver safety + with real-time alerts and reporting. +

+
+
+ Driver Management +
+
+ +
+

Operational Efficiency

+

+ Optimize your fleet's operational efficiency with our advanced + analytics and reporting tools. Identify areas for improvement, + reduce costs, and enhance overall productivity. +

+
+
+ Operational Efficiency +
+
diff --git a/frontend/src/app/welcome-page/welcome-management/welcome-management.component.spec.ts b/frontend/src/app/welcome-page/welcome-management/welcome-management.component.spec.ts new file mode 100644 index 0000000..99a051f --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-management/welcome-management.component.spec.ts @@ -0,0 +1,98 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { WelcomeManagementComponent } from './welcome-management.component'; +import { DoubleColumnParagraphComponent } from '../../common/components/double-column-paragraph/double-column-paragraph.component'; +import { By } from '@angular/platform-browser'; + +describe('WelcomeManagementComponent', () => { + let component: WelcomeManagementComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + WelcomeManagementComponent, + DoubleColumnParagraphComponent, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(WelcomeManagementComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should render the Fleet Assistant Overview section', () => { + const fleetOverview = fixture.debugElement.query( + By.css('app-double-column-paragraph:nth-of-type(1)') + ); + expect(fleetOverview).toBeTruthy(); + + const title = fleetOverview.query(By.css('h2')).nativeElement + .textContent; + expect(title).toBe('Fleet Assistant Overview'); + + const paragraph = fleetOverview.query(By.css('p')).nativeElement + .textContent; + expect(paragraph).toContain( + "Our fleet management software provides a comprehensive overview of your fleet's operations" + ); + + const img = fleetOverview.query(By.css('img')); + expect(img).toBeTruthy(); + expect(img.nativeElement.getAttribute('alt')).toBe( + 'Fleet Management Overview' + ); + }); + + it('should render the Driver Management section with reversed layout', () => { + const driverManagement = fixture.debugElement.query( + By.css('app-double-column-paragraph:nth-of-type(2)') + ); + expect(driverManagement).toBeTruthy(); + + const title = driverManagement.query(By.css('h2')).nativeElement + .textContent; + expect(title).toBe('Driver Management'); + + const paragraph = driverManagement.query(By.css('p')).nativeElement + .textContent; + expect(paragraph).toContain( + 'Effectively manage your drivers with our software' + ); + + const img = driverManagement.query(By.css('img')); + expect(img).toBeTruthy(); + expect(img.nativeElement.getAttribute('alt')).toBe('Driver Management'); + + const reversedAttr = driverManagement.attributes['ng-reflect-reversed']; + expect(reversedAttr).toBe('true'); + }); + + it('should render the Operational Efficiency section', () => { + const operationalEfficiency = fixture.debugElement.query( + By.css('app-double-column-paragraph:nth-of-type(3)') + ); + expect(operationalEfficiency).toBeTruthy(); + + const title = operationalEfficiency.query(By.css('h2')).nativeElement + .textContent; + expect(title).toBe('Operational Efficiency'); + + const paragraph = operationalEfficiency.query(By.css('p')).nativeElement + .textContent; + expect(paragraph).toContain( + "Optimize your fleet's operational efficiency with our advanced analytics" + ); + + const img = operationalEfficiency.query(By.css('img')); + expect(img).toBeTruthy(); + expect(img.nativeElement.getAttribute('alt')).toBe( + 'Operational Efficiency' + ); + }); +}); diff --git a/frontend/src/app/welcome-page/welcome-management/welcome-management.component.ts b/frontend/src/app/welcome-page/welcome-management/welcome-management.component.ts new file mode 100644 index 0000000..e4a7759 --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-management/welcome-management.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { DoubleColumnParagraphComponent } from '../../common/components/double-column-paragraph/double-column-paragraph.component'; + +@Component({ + selector: 'app-welcome-management', + standalone: true, + imports: [DoubleColumnParagraphComponent], + templateUrl: './welcome-management.component.html', +}) +export class WelcomeManagementComponent {} diff --git a/frontend/src/app/welcome-page/welcome-page.component.html b/frontend/src/app/welcome-page/welcome-page.component.html new file mode 100644 index 0000000..5127ec1 --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-page.component.html @@ -0,0 +1,64 @@ +
+
+
+ Management + Management +
+
+ Maintenance + Maintenance +
+
+ Tracking + Tracking +
+
+
+
+ +
+

Ultimate Solution

+

+ Our fleet management software is the ultimate solution for + managing your fleet. With our software, you can easily track + your vehicles, monitor driver behavior, and analyze your fleet's + performance. Our software also provides you with real-time data + on your fleet, so you can make informed decisions about your + business. +

+
+
+ Ultimate Solution +
+
+
+
+ +
+

Easy and Intuitive

+

+ Our fleet management software is easy to use and intuitive. With + our software, you can easily manage your fleet, track your + vehicles, and monitor driver behavior. Our software is designed + to be user-friendly, so you can focus on running your business, + not managing your fleet. +

+
+
+ Easy and Intuitive +
+
+
+
+ +
+

Contact Us

+

+ If you have any questions about our fleet management software, + or if you would like to schedule a demo, please contact us. We + are here to help you manage your fleet and grow your business. +

+
+ +
+
diff --git a/frontend/src/app/welcome-page/welcome-page.component.scss b/frontend/src/app/welcome-page/welcome-page.component.scss new file mode 100644 index 0000000..9af6860 --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-page.component.scss @@ -0,0 +1,20 @@ +.image-item { + @apply relative overflow-hidden h-screen sm:w-full md:w-1/3; + img { + @apply w-full h-full object-cover; + filter: brightness(0.5); + transition: transform 0.3s ease; + } + + span { + @apply absolute inset-0 flex items-center justify-center text-white text-2xl font-bold; + text-shadow: 1px 1px 2px black; + } + a { + @apply absolute inset-0 flex items-center justify-center text-white text-2xl font-bold; + text-shadow: 1px 1px 2px black; + } + &:hover img { + transform: scale(1.1); + } +} diff --git a/frontend/src/app/welcome-page/welcome-page.component.spec.ts b/frontend/src/app/welcome-page/welcome-page.component.spec.ts new file mode 100644 index 0000000..797193d --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-page.component.spec.ts @@ -0,0 +1,125 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { WelcomePageComponent } from './welcome-page.component'; +import { DoubleColumnParagraphComponent } from '../common/components/double-column-paragraph/double-column-paragraph.component'; +import { ContactFormComponent } from '../common/components/contact-form/contact-form.component'; +import { RouterTestingModule } from '@angular/router/testing'; +import { By } from '@angular/platform-browser'; +import { NavbarService } from '../../utilities/services/navbar.service'; +import { Renderer2 } from '@angular/core'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { MockOAuthService } from '../../utilities/tests/_mocks'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +describe('WelcomePageComponent', () => { + let component: WelcomePageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const navbarServiceMock = jasmine.createSpyObj('NavbarService', [ + 'showNavbar$', + ]); + const rendererMock = jasmine.createSpyObj('Renderer2', [ + 'createElement', + 'appendChild', + ]); + + navbarServiceMock.showNavbar$ = of(true); + await TestBed.configureTestingModule({ + declarations: [], + imports: [ + RouterTestingModule, + WelcomePageComponent, + DoubleColumnParagraphComponent, + ContactFormComponent, + HttpClientTestingModule, + BrowserAnimationsModule, + ], + providers: [ + { provide: NavbarService, useValue: navbarServiceMock }, + { provide: Renderer2, useValue: rendererMock }, + { provide: OAuthService, useClass: MockOAuthService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(WelcomePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should render the top-section', () => { + const topSection = fixture.debugElement.query(By.css('#top-section')); + expect(topSection).toBeTruthy(); + }); + + it('should contain links in top-section', () => { + const links = fixture.debugElement.queryAll(By.css('#top-section a')); + expect(links.length).toBe(3); + expect(links[0].nativeElement.getAttribute('routerLink')).toBe( + '/management-info' + ); + expect(links[1].nativeElement.getAttribute('routerLink')).toBe( + '/maintenance-info' + ); + expect(links[2].nativeElement.getAttribute('routerLink')).toBe( + '/tracking-info' + ); + }); + + it('should display image in the top-section with alt text "Management"', () => { + const img = fixture.debugElement.query( + By.css('#top-section .image-item img[alt="Management"]') + ); + expect(img).toBeTruthy(); + }); + + it('should render the Ultimate Solution section with app-double-column-paragraph', () => { + const ultimateSolutionSection = fixture.debugElement.query( + By.css('#ultimate-solution app-double-column-paragraph') + ); + expect(ultimateSolutionSection).toBeTruthy(); + }); + + it('should render the Easy and Intuitive section with app-double-column-paragraph reversed', () => { + const intuitiveSection = fixture.debugElement.query( + By.css('#intuitive app-double-column-paragraph') + ); + expect(intuitiveSection).toBeTruthy(); + expect(intuitiveSection.attributes['ng-reflect-reversed']).toBe('true'); + }); + + it('should render the Contact Us section with app-contact-form', () => { + const contactSection = fixture.debugElement.query( + By.css('#contact app-contact-form') + ); + expect(contactSection).toBeTruthy(); + }); + + it('should have an img element with the correct alt text in the Ultimate Solution section', () => { + const img = fixture.debugElement.query( + By.css('#ultimate-solution img[alt="Ultimate Solution"]') + ); + expect(img).toBeTruthy(); + }); + + it('should have an img element with the correct alt text in the Easy and Intuitive section', () => { + const img = fixture.debugElement.query( + By.css('#intuitive img[alt="Easy and Intuitive"]') + ); + expect(img).toBeTruthy(); + }); + + it('should render the Contact Form component inside the Contact section', () => { + const contactForm = fixture.debugElement.query( + By.css('#contact app-contact-form') + ); + expect(contactForm).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/welcome-page/welcome-page.component.ts b/frontend/src/app/welcome-page/welcome-page.component.ts new file mode 100644 index 0000000..a56d84c --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-page.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { DoubleColumnParagraphComponent } from '../common/components/double-column-paragraph/double-column-paragraph.component'; +import { ContactFormComponent } from '../common/components/contact-form/contact-form.component'; + +@Component({ + selector: 'app-welcome-page', + standalone: true, + imports: [DoubleColumnParagraphComponent, ContactFormComponent, RouterLink], + templateUrl: './welcome-page.component.html', + styleUrl: './welcome-page.component.scss', +}) +export class WelcomePageComponent {} diff --git a/frontend/src/app/welcome-page/welcome-tracking/welcome-tracking.component.html b/frontend/src/app/welcome-page/welcome-tracking/welcome-tracking.component.html new file mode 100644 index 0000000..563b6be --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-tracking/welcome-tracking.component.html @@ -0,0 +1,39 @@ + +
+

Real-Time Vehicles Tracking

+

+ Our fleet management software provides real-time vehicle tracking to + ensure you always know the location of your vehicles. Monitor + routes, optimize travel times, and improve overall fleet efficiency. +

+
+
+ Real-Time Vehicles Tracking +
+
+ +
+

Geofencing

+

+ Set up geofences to receive alerts when vehicles enter or leave + designated areas. Enhance security and ensure compliance with route + plans using our geofencing capabilities. +

+
+
+ Geofencing +
+
+ +
+

Route Optimization

+

+ Optimize routes to reduce fuel consumption and travel time. Our + software analyzes traffic patterns and provides the most efficient + routes for your fleet. +

+
+
+ Route Optimization +
+
diff --git a/frontend/src/app/welcome-page/welcome-tracking/welcome-tracking.component.spec.ts b/frontend/src/app/welcome-page/welcome-tracking/welcome-tracking.component.spec.ts new file mode 100644 index 0000000..10241fb --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-tracking/welcome-tracking.component.spec.ts @@ -0,0 +1,94 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { WelcomeTrackingComponent } from './welcome-tracking.component'; +import { DoubleColumnParagraphComponent } from '../../common/components/double-column-paragraph/double-column-paragraph.component'; +import { By } from '@angular/platform-browser'; + +describe('WelcomeTrackingComponent', () => { + let component: WelcomeTrackingComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WelcomeTrackingComponent, DoubleColumnParagraphComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(WelcomeTrackingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should render the Real-Time Vehicles Tracking section', () => { + const realTimeTracking = fixture.debugElement.query( + By.css('app-double-column-paragraph:nth-of-type(1)') + ); + expect(realTimeTracking).toBeTruthy(); + + const title = realTimeTracking.query(By.css('h2')).nativeElement + .textContent; + expect(title).toBe('Real-Time Vehicles Tracking'); + + const paragraph = realTimeTracking.query(By.css('p')).nativeElement + .textContent; + expect(paragraph).toContain( + 'Our fleet management software provides real-time vehicle tracking' + ); + + const img = realTimeTracking.query(By.css('img')); + expect(img).toBeTruthy(); + expect(img.nativeElement.getAttribute('alt')).toBe( + 'Real-Time Vehicles Tracking' + ); + }); + + it('should render the Geofencing section with reversed layout', () => { + const geofencing = fixture.debugElement.query( + By.css('app-double-column-paragraph:nth-of-type(2)') + ); + expect(geofencing).toBeTruthy(); + + const title = geofencing.query(By.css('h2')).nativeElement.textContent; + expect(title).toBe('Geofencing'); + + const paragraph = geofencing.query(By.css('p')).nativeElement + .textContent; + expect(paragraph).toContain( + 'Set up geofences to receive alerts when vehicles enter or leave designated areas' + ); + + const img = geofencing.query(By.css('img')); + expect(img).toBeTruthy(); + expect(img.nativeElement.getAttribute('alt')).toBe('Geofencing'); + + const reversedAttr = geofencing.attributes['ng-reflect-reversed']; + expect(reversedAttr).toBe('true'); + }); + + it('should render the Route Optimization section', () => { + const routeOptimization = fixture.debugElement.query( + By.css('app-double-column-paragraph:nth-of-type(3)') + ); + expect(routeOptimization).toBeTruthy(); + + const title = routeOptimization.query(By.css('h2')).nativeElement + .textContent; + expect(title).toBe('Route Optimization'); + + const paragraph = routeOptimization.query(By.css('p')).nativeElement + .textContent; + expect(paragraph).toContain( + 'Optimize routes to reduce fuel consumption and travel time' + ); + + const img = routeOptimization.query(By.css('img')); + expect(img).toBeTruthy(); + expect(img.nativeElement.getAttribute('alt')).toBe( + 'Route Optimization' + ); + }); +}); diff --git a/frontend/src/app/welcome-page/welcome-tracking/welcome-tracking.component.ts b/frontend/src/app/welcome-page/welcome-tracking/welcome-tracking.component.ts new file mode 100644 index 0000000..a413b50 --- /dev/null +++ b/frontend/src/app/welcome-page/welcome-tracking/welcome-tracking.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; +import { DoubleColumnParagraphComponent } from '../../common/components/double-column-paragraph/double-column-paragraph.component'; + +@Component({ + selector: 'app-welcome-tracking', + standalone: true, + imports: [DoubleColumnParagraphComponent], + templateUrl: './welcome-tracking.component.html', +}) +export class WelcomeTrackingComponent {} diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 0000000..0aa9600 --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,21 @@ + + + + + Fleet Assistant + + + + + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..170598b --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err) +); diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss new file mode 100644 index 0000000..0a0787a --- /dev/null +++ b/frontend/src/styles.scss @@ -0,0 +1,36 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; +@import 'variables'; + +html, +body { + height: 100%; + font-family: Roboto, 'Helvetica Neue', sans-serif; +} + +.link { + color: $hover-color; + font-size: $font-size; + text-decoration: none; + padding: $padding; + border-radius: 4px; + display: inline-block; + transition: + background-color 0.3s ease, + color 0.3s ease; + + &:hover { + color: $basic_color; + background-color: $hover-color; + } + + &:active { + color: $basic_color; + } + + &:focus { + outline: 2px solid $primary-color; + outline-offset: 2px; + } +} diff --git a/frontend/src/utilities/_constants.ts b/frontend/src/utilities/_constants.ts new file mode 100644 index 0000000..3d81c4f --- /dev/null +++ b/frontend/src/utilities/_constants.ts @@ -0,0 +1,2 @@ +export const HIDE_NAVBAR_ROUTES = ['/password-reset', '/login', '/register']; +export const INVALID_FORM_MESSAGE = 'Please fill in the form correctly'; diff --git a/frontend/src/utilities/_urls.ts b/frontend/src/utilities/_urls.ts new file mode 100644 index 0000000..8dfde6a --- /dev/null +++ b/frontend/src/utilities/_urls.ts @@ -0,0 +1,17 @@ +import { environment } from '../environments/environment'; +import { Pageable } from '../app/vehicles/types/vehicles'; + +export const LOGIN_URL = environment.apiUrl + '/auth/authenticate'; +export const REGISTER_URL = environment.apiUrl + '/auth/register'; +export const USER_INFO_URL = environment.apiUrl + '/user/data'; +export const VEHICLES_URL = environment.apiUrl + '/vehicle'; +export const GET_ALL_VEHICLES_URL = (pageable: Pageable): string => + environment.apiUrl + + `/vehicle?page=${pageable.pageNumber}&size=${pageable.pageSize}`; +export const GET_VEHICLE_BY_ID_URL = (id: string): string => + VEHICLES_URL + `/${id}`; +export const GET_VEHICLE_LIVE_LOCATION_BY_ID_URL = (id: string): string => + VEHICLES_URL + `/${id}/location-stream`; +export const DRIVERS_URL = environment.apiUrl + '/driver'; +export const GET_DRIVERS_URL = (registered: boolean): string => environment.apiUrl + `/driver?registered=${registered}`; + diff --git a/frontend/src/utilities/_variables.scss b/frontend/src/utilities/_variables.scss new file mode 100644 index 0000000..ea995a5 --- /dev/null +++ b/frontend/src/utilities/_variables.scss @@ -0,0 +1,10 @@ +//colors +$basic_color: #002a50; +$primary-color: #3498db; +$hover-color: #e0f7fd; + +//fonts +$font-size: 16px; + +//margin-constants +$padding: 8px 16px; diff --git a/frontend/src/utilities/auth.interceptor.ts b/frontend/src/utilities/auth.interceptor.ts new file mode 100644 index 0000000..4a16fb9 --- /dev/null +++ b/frontend/src/utilities/auth.interceptor.ts @@ -0,0 +1,34 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { catchError, EMPTY } from 'rxjs'; +import { SnackbarService } from './services/snackbar.service'; + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const token = localStorage.getItem('accessToken'); + const router = inject(Router); + const snackbarService = inject(SnackbarService); + + let authReq = req.clone(); + if (!req.url.includes('google')) { + authReq = token + ? req.clone({ + setHeaders: { + Authorization: `Bearer ${token}`, + }, + }) + : req; + } + + return next(authReq).pipe( + catchError((error) => { + if (error.status === 401) { + localStorage.removeItem('userInfo'); + localStorage.removeItem('accessToken'); + router.navigate(['']); + snackbarService.openSnackBar('Your session was expired!'); + } + return EMPTY; + }) + ); +}; diff --git a/frontend/src/utilities/date-utils.ts b/frontend/src/utilities/date-utils.ts new file mode 100644 index 0000000..becec9c --- /dev/null +++ b/frontend/src/utilities/date-utils.ts @@ -0,0 +1,7 @@ +export function toDisplayDate(date: string[]): string { + const year = date[0]; + const month = date[1].toString().length === 1 ? '0' + date[1] : date[1]; + const day = date[2].toString().length === 1 ? '0' + date[2] : date[2]; + + return day + '.' + month + '.' + year; +} diff --git a/frontend/src/utilities/guards/auth.guard.ts b/frontend/src/utilities/guards/auth.guard.ts new file mode 100644 index 0000000..483eb35 --- /dev/null +++ b/frontend/src/utilities/guards/auth.guard.ts @@ -0,0 +1,15 @@ +import { CanActivateFn, Router } from '@angular/router'; +import { inject } from '@angular/core'; +import { AuthService } from '../../app/auth/service/auth.service'; + +export const authGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isLoggedIn()) { + return true; + } else { + router.navigate(['']); + return false; + } +}; diff --git a/frontend/src/utilities/guards/logged-user-restrict.guard.ts b/frontend/src/utilities/guards/logged-user-restrict.guard.ts new file mode 100644 index 0000000..5e759ee --- /dev/null +++ b/frontend/src/utilities/guards/logged-user-restrict.guard.ts @@ -0,0 +1,15 @@ +import { CanActivateFn, Router } from '@angular/router'; +import { inject } from '@angular/core'; +import { AuthService } from '../../app/auth/service/auth.service'; + +export const loggedUserRestrictGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isLoggedIn()) { + router.navigate(['']); + return false; + } else { + return true; + } +}; diff --git a/frontend/src/utilities/guards/manager-role.guard.ts b/frontend/src/utilities/guards/manager-role.guard.ts new file mode 100644 index 0000000..298046f --- /dev/null +++ b/frontend/src/utilities/guards/manager-role.guard.ts @@ -0,0 +1,15 @@ +import { CanActivateFn, Router } from '@angular/router'; +import { inject } from '@angular/core'; +import { AuthService } from '../../app/auth/service/auth.service'; + +export const managerRoleGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isManager()) { + return true; + } else { + router.navigate(['']); + return false; + } +}; diff --git a/frontend/src/utilities/resolvers/vehicle-details.resolver.ts b/frontend/src/utilities/resolvers/vehicle-details.resolver.ts new file mode 100644 index 0000000..b474a1f --- /dev/null +++ b/frontend/src/utilities/resolvers/vehicle-details.resolver.ts @@ -0,0 +1,13 @@ +import { ResolveFn } from '@angular/router'; +import { inject } from '@angular/core'; +import { VehiclesService } from '../../app/vehicles/service/vehicles.service'; +import { Vehicle } from '../../app/vehicles/types/vehicles'; +import { Observable } from 'rxjs'; + +export const vehicleDetailsResolver: ResolveFn> = ( + route +) => { + const vehicleService = inject(VehiclesService); + const id = route.paramMap.get('id'); + return vehicleService.getVehicle(id!); +}; diff --git a/frontend/src/utilities/services/navbar.service.spec.ts b/frontend/src/utilities/services/navbar.service.spec.ts new file mode 100644 index 0000000..ca97abc --- /dev/null +++ b/frontend/src/utilities/services/navbar.service.spec.ts @@ -0,0 +1,47 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { NavbarService } from './navbar.service'; +import { BehaviorSubject } from 'rxjs'; +import { NavigationEnd } from '@angular/router'; + +describe('NavbarService', () => { + let service: NavbarService; + let router: Router; + + beforeEach(() => { + const mockRouter = { + events: new BehaviorSubject(new NavigationEnd(0, '', '')), + url: '/home', + }; + + TestBed.configureTestingModule({ + providers: [ + NavbarService, + { provide: Router, useValue: mockRouter }, + ], + }); + + service = TestBed.inject(NavbarService); + router = TestBed.inject(Router); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should set showNavbar$ to false for routes that should hide the navbar', () => { + const hideRoute = '/some-hide-route'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (router as any).url = hideRoute; + service = TestBed.inject(NavbarService); + + service.showNavbar$.subscribe((showNavbar) => { + expect(showNavbar).toBeFalse(); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (router.events as BehaviorSubject).next( + new NavigationEnd(0, hideRoute, hideRoute) + ); + }); +}); diff --git a/frontend/src/utilities/services/navbar.service.ts b/frontend/src/utilities/services/navbar.service.ts new file mode 100644 index 0000000..9985ea1 --- /dev/null +++ b/frontend/src/utilities/services/navbar.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, filter } from 'rxjs'; +import { NavigationEnd, Router } from '@angular/router'; +import { HIDE_NAVBAR_ROUTES } from '../_constants'; + +@Injectable({ + providedIn: 'root', +}) +export class NavbarService { + private showNavbarSubject = new BehaviorSubject(true); + showNavbar$ = this.showNavbarSubject.asObservable(); + + constructor(private router: Router) { + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe(() => { + const shouldShowNavbar = HIDE_NAVBAR_ROUTES.some((route) => + this.router.url.includes(route) + ); + this.showNavbarSubject.next(shouldShowNavbar); + }); + } +} diff --git a/frontend/src/utilities/services/snackbar.service.spec.ts b/frontend/src/utilities/services/snackbar.service.spec.ts new file mode 100644 index 0000000..fdbae21 --- /dev/null +++ b/frontend/src/utilities/services/snackbar.service.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { SnackbarService } from './snackbar.service'; + +describe('SnackbarService', () => { + let snackbarService: SnackbarService; + let matSnackBarSpy: jasmine.SpyObj; + + beforeEach(() => { + matSnackBarSpy = jasmine.createSpyObj('MatSnackBar', ['open']); + + TestBed.configureTestingModule({ + providers: [ + SnackbarService, + { provide: MatSnackBar, useValue: matSnackBarSpy }, + ], + }); + + snackbarService = TestBed.inject(SnackbarService); + }); + + it('should be created', () => { + expect(snackbarService).toBeTruthy(); + }); + + it('should call MatSnackBar open method with correct parameters', () => { + const message = 'Test Message'; + const horizontalPosition: 'start' | 'end' = 'start'; + const duration = 3000; + + snackbarService.openSnackBar(message, horizontalPosition, duration); + + expect(matSnackBarSpy.open).toHaveBeenCalledWith(message, 'Close', { + duration, + horizontalPosition, + }); + }); + + it('should call MatSnackBar open with default values if no duration or position is provided', () => { + const message = 'Test Message'; + + snackbarService.openSnackBar(message); + + expect(matSnackBarSpy.open).toHaveBeenCalledWith(message, 'Close', { + duration: 2000, + horizontalPosition: 'start', + }); + }); +}); diff --git a/frontend/src/utilities/services/snackbar.service.ts b/frontend/src/utilities/services/snackbar.service.ts new file mode 100644 index 0000000..adcd1f8 --- /dev/null +++ b/frontend/src/utilities/services/snackbar.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +@Injectable({ + providedIn: 'root', +}) +export class SnackbarService { + constructor(private snackBar: MatSnackBar) {} + + openSnackBar( + message: string, + horizontalPosition: 'start' | 'end' = 'start', + duration = 2000 + ): void { + this.snackBar.open(message, 'Close', { + duration, + horizontalPosition: horizontalPosition, + }); + } +} diff --git a/frontend/src/utilities/tests/_mocks.ts b/frontend/src/utilities/tests/_mocks.ts new file mode 100644 index 0000000..0e919ed --- /dev/null +++ b/frontend/src/utilities/tests/_mocks.ts @@ -0,0 +1,45 @@ +import { AuthConfig } from 'angular-oauth2-oidc'; +import { Observable, of } from 'rxjs'; + +export class MockOAuthService { + // eslint-disable-next-line @typescript-eslint/no-empty-function + initCodeFlow(): void {} + + loadDiscoveryDocument(): Promise { + return Promise.resolve(true); + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + loadDiscoveryDocumentAndTryLogin(): void {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars,@typescript-eslint/no-empty-function + configure(config: AuthConfig): void {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + setupAutomaticSilentRefresh(): void {} + + get accessToken$(): Observable { + return of('mock-token'); + } + + hasValidAccessToken(): boolean { + return true; + } + + getIdToken(): string { + return 'mock-id-token'; + } + + tryLoginImplicitFlow(): Promise { + return Promise.resolve(); + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + initLoginFlow(): void {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + revokeTokenAndLogout(): void {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + logOut(): void {} +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..1dfb857 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,10 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{html,ts}", + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..3775b37 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d3dbc4a --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,33 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": [ + "ES2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json new file mode 100644 index 0000000..4fc2fc7 --- /dev/null +++ b/frontend/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine", "node" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/postman_collection/ZPI.postman_collection.json b/postman_collection/ZPI.postman_collection.json new file mode 100644 index 0000000..f7d0fb4 --- /dev/null +++ b/postman_collection/ZPI.postman_collection.json @@ -0,0 +1,488 @@ +{ + "info": { + "_postman_id": "8ae78e15-7d8f-4f2e-a2ab-f7125f68d544", + "name": "ZPI", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "25250646", + "_collection_link": "https://vburmusteam.postman.co/workspace/My-Workspace~8df07bf3-ba52-4105-881b-c919d1c7ec59/collection/25250646-8ae78e15-7d8f-4f2e-a2ab-f7125f68d544?action=share&source=collection_link&creator=25250646" + }, + "item": [ + { + "name": "User", + "item": [ + { + "name": "Register", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\":\"Joh43n\",\r\n \"surname\":\"Doe\",\r\n \"number\": \"8888hjhj88888\",\r\n \"email\":\"admi3huhbjkjn2@admin.com\",\r\n \"password\":\"admin\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/v1/auth/register", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "auth", + "register" + ] + } + }, + "response": [] + }, + { + "name": "User Data", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6Miwic3ViIjoibWFuYWdlcjJAbWFuYWdlci5jb20iLCJyb2xlIjoiTUFOQUdFUiIsImVtYWlsIjoibWFuYWdlcjJAbWFuYWdlci5jb20iLCJ0eXBlIjoiQUNDRVNTX1RPS0VOIiwiaWF0IjoxNzMxMjgyNjI0LCJleHAiOjE3MzEyODYyMjR9.S_0QSpwhnfkzPB5M6iZldLMpLXl9P19BZIF3rDgoWfBGrcgSrW9DMNw-h_LYqWe2fDt0xzg_LVeUExMu5NCp_Q", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/v1/user/data", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "user", + "data" + ] + } + }, + "response": [] + }, + { + "name": "Authenticate MANAGER", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"email\":\"manager2@manager.com\",\r\n \"password\":\"admin\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/v1/auth/authenticate", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "auth", + "authenticate" + ] + } + }, + "response": [] + }, + { + "name": "Authenticate DRIVER", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"email\":\"driver@driver.com\",\r\n \"password\":\"admin\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/v1/auth/authenticate", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "auth", + "authenticate" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ] + }, + { + "name": "Vehicles", + "item": [ + { + "name": "All Vehicles", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6Mywic3ViIjoiZHJpdmVyQGRyaXZlci5jb20iLCJyb2xlIjoiRFJJVkVSIiwiZW1haWwiOiJkcml2ZXJAZHJpdmVyLmNvbSIsInR5cGUiOiJBQ0NFU1NfVE9LRU4iLCJpYXQiOjE3MzEyODA2MzMsImV4cCI6MTczMTI4NDIzM30.1Yuznz9CO8-IXgXL37XbsFFyg-o_LoIaj09MYfOsBvT2r_MHLHBTkZTcTPv1YrabkgIZzLgKolXMt8uPHt2iIA", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/api/v1/vehicle", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "vehicle" + ] + } + }, + "response": [] + }, + { + "name": "Create Vehicle", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6Miwic3ViIjoibWFuYWdlcjJAbWFuYWdlci5jb20iLCJyb2xlIjoiTUFOQUdFUiIsImVtYWlsIjoibWFuYWdlcjJAbWFuYWdlci5jb20iLCJ0eXBlIjoiQUNDRVNTX1RPS0VOIiwiaWF0IjoxNzMxMjgyNjI0LCJleHAiOjE3MzEyODYyMjR9.S_0QSpwhnfkzPB5M6iZldLMpLXl9P19BZIF3rDgoWfBGrcgSrW9DMNw-h_LYqWe2fDt0xzg_LVeUExMu5NCp_Q", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\": \"Fords Exp4lorer\",\r\n \"vin\": \"1FMYU021X7KB1979\",\r\n \"plateNumber\": \"XYZ1234\",\r\n \"countryCode\": \"US\",\r\n \"insuranceDate\": \"2024-01-15\",\r\n \"lastInspectionDate\": \"2023-12-10\",\r\n \"productionDate\": \"2023-05-20\",\r\n \"driverId\":\"4\"\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/v1/vehicle", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "vehicle" + ] + } + }, + "response": [] + }, + { + "name": "VehicleById", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6Miwic3ViIjoibWFuYWdlcjJAbWFuYWdlci5jb20iLCJyb2xlIjoiTUFOQUdFUiIsImVtYWlsIjoibWFuYWdlcjJAbWFuYWdlci5jb20iLCJ0eXBlIjoiQUNDRVNTX1RPS0VOIiwiaWF0IjoxNzMxMjgyNjI0LCJleHAiOjE3MzEyODYyMjR9.S_0QSpwhnfkzPB5M6iZldLMpLXl9P19BZIF3rDgoWfBGrcgSrW9DMNw-h_LYqWe2fDt0xzg_LVeUExMu5NCp_Q", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/api/v1/vehicle/1", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "vehicle", + "1" + ] + } + }, + "response": [] + }, + { + "name": "New Request", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6Miwic3ViIjoibWFuYWdlcjJAbWFuYWdlci5jb20iLCJyb2xlIjoiTUFOQUdFUiIsImVtYWlsIjoibWFuYWdlcjJAbWFuYWdlci5jb20iLCJ0eXBlIjoiQUNDRVNTX1RPS0VOIiwiaWF0IjoxNzMxMjgyNjI0LCJleHAiOjE3MzEyODYyMjR9.S_0QSpwhnfkzPB5M6iZldLMpLXl9P19BZIF3rDgoWfBGrcgSrW9DMNw-h_LYqWe2fDt0xzg_LVeUExMu5NCp_Q", + "type": "string" + } + ] + }, + "method": "POST", + "header": [], + "url": { + "raw": "http://localhost:8080/api/v1/vehicle/1/assign-driver/3", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "vehicle", + "1", + "assign-driver", + "3" + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "noauth" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ] + }, + { + "name": "Location", + "item": [ + { + "name": "Stream", + "request": { + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6Miwic3ViIjoibWFuYWdlcjJAbWFuYWdlci5jb20iLCJyb2xlIjoiTUFOQUdFUiIsImVtYWlsIjoibWFuYWdlcjJAbWFuYWdlci5jb20iLCJ0eXBlIjoiQUNDRVNTX1RPS0VOIiwiaWF0IjoxNzMxMjgyNjI0LCJleHAiOjE3MzEyODYyMjR9.S_0QSpwhnfkzPB5M6iZldLMpLXl9P19BZIF3rDgoWfBGrcgSrW9DMNw-h_LYqWe2fDt0xzg_LVeUExMu5NCp_Q", + "type": "string" + } + ] + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/api/v1/vehicle/1/location-stream", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "vehicle", + "1", + "location-stream" + ] + } + }, + "response": [] + }, + { + "name": "Send Location", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"longitude\":33333,\r\n \"latitude\": 33333\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/v1/vehicle/1/location", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "vehicle", + "1", + "location" + ] + } + }, + "response": [] + }, + { + "name": "Delete Locations", + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "{\r\n \"longitude\":33333,\r\n \"latitude\": 33333\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/api/v1/vehicle/1/location", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "api", + "v1", + "vehicle", + "1", + "location" + ] + } + }, + "response": [] + } + ] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6MSwic3ViIjoibWFuYWdlckBtYW5hZ2VyLmNvbSIsInJvbGUiOiJNQU5BR0VSIiwiZW1haWwiOiJtYW5hZ2VyQG1hbmFnZXIuY29tIiwidHlwZSI6IkFDQ0VTU19UT0tFTiIsImlhdCI6MTczMDQ1Mjg3NywiZXhwIjoxNzMwNDU2NDc3fQ.Ku416hzYclacQwOx49o8wmFQ5PAT3rEwlSXNR4-seXzbEeshliT6TBb1vuDTeN3h3X6WFPj0U96mcfSE8a9mdA", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ] +} \ No newline at end of file