Compare commits

...

69 Commits
0.10.0 ... main

Author SHA1 Message Date
MDeLuise
26de7e2aa2 chore: add missing fdroid build info 2026-01-20 10:28:54 +01:00
Adrian Chen
33e991b07f
fix(lang): English Localization Mispelling Nitpick (#604) 2026-01-20 10:24:14 +01:00
MDeLuise
ecbcb55ed7 chore: remove unused github action 2026-01-07 14:28:28 +01:00
MDeLuise
6d53b9f2fd chore: add stable app version disclaimer 2026-01-07 14:24:54 +01:00
Louise Mendiburu
58851ea2dc
feat(lang): add spanish translation (#597) 2026-01-03 19:16:05 +01:00
Ben
5d0d7ba9eb
feat(lang): add French language (#596)
* Added French translation
* Fixed a small typo in the English text
2025-12-30 17:51:58 +01:00
vtourneur
a9e049af7f
fix(lang): typo in english ("porvided" -> "provided") (#590)
Co-authored-by: Vincent Tourneur <vincent.tourneur@loria.fr>
2025-10-11 05:35:56 +02:00
MDeLuise
f17e321467 fix: app package name 2025-09-30 15:12:06 +02:00
MDeLuise
cd596a769a
Update README.md 2025-09-29 15:49:49 +02:00
MDeLuise
231512311c chore: update app version 2025-09-29 14:04:13 +02:00
MDeLuise
ddcd8cedef
Fix release.yml 2025-09-29 12:22:14 +02:00
MDeLuise
fb56dd01a5 cleanup 2025-09-29 12:15:00 +02:00
MDeLuise
ed61b0c831
Update flutter version in create-app.yml 2025-09-29 12:12:36 +02:00
MDeLuise
c9414b3d07
Update flutter version in main.yml 2025-09-29 12:12:19 +02:00
MDeLuise
3a2ac6f103
feature: Architectural changes (#584) 2025-09-29 12:05:45 +02:00
Lajos Bálint Sonkoly
4d61128914
feat(lang): add Hungarian language (#558) 2025-07-29 14:34:15 +02:00
Angelos Aslanidis
9bffb690c7
feat(lang): Add Greek language (#539) 2025-07-06 17:42:52 +00:00
SimonTen
d0b82b553f
Update app_de.arb (#518)
change translation for transplanting from "umgetopft" to "umgepflanzt"
2025-06-06 14:39:46 +00:00
dependabot[bot]
05e603d9e9
chore(deps): bump com.puppycrawl.tools:checkstyle in /backend (#508)
Bumps [com.puppycrawl.tools:checkstyle](https://github.com/checkstyle/checkstyle) from 10.23.1 to 10.24.0.
- [Release notes](https://github.com/checkstyle/checkstyle/releases)
- [Commits](https://github.com/checkstyle/checkstyle/compare/checkstyle-10.23.1...checkstyle-10.24.0)

---
updated-dependencies:
- dependency-name: com.puppycrawl.tools:checkstyle
  dependency-version: 10.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-22 12:51:33 +00:00
dependabot[bot]
ba747463a5
chore(deps): bump com.mysql:mysql-connector-j in /backend (#502)
Bumps [com.mysql:mysql-connector-j](https://github.com/mysql/mysql-connector-j) from 9.2.0 to 9.3.0.
- [Changelog](https://github.com/mysql/mysql-connector-j/blob/release/9.x/CHANGES)
- [Commits](https://github.com/mysql/mysql-connector-j/compare/9.2.0...9.3.0)

---
updated-dependencies:
- dependency-name: com.mysql:mysql-connector-j
  dependency-version: 9.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-19 08:38:35 +00:00
dependabot[bot]
17112a323c
chore(deps): bump org.springframework.boot:spring-boot-starter-parent (#503)
Bumps [org.springframework.boot:spring-boot-starter-parent](https://github.com/spring-projects/spring-boot) from 3.4.3 to 3.4.5.
- [Release notes](https://github.com/spring-projects/spring-boot/releases)
- [Commits](https://github.com/spring-projects/spring-boot/compare/v3.4.3...v3.4.5)

---
updated-dependencies:
- dependency-name: org.springframework.boot:spring-boot-starter-parent
  dependency-version: 3.4.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-19 08:36:01 +00:00
dependabot[bot]
5dfd188ac3
chore(deps): bump org.modelmapper:modelmapper in /backend (#504)
Bumps [org.modelmapper:modelmapper](https://github.com/modelmapper/modelmapper) from 3.2.2 to 3.2.3.
- [Changelog](https://github.com/modelmapper/modelmapper/blob/master/CHANGES.md)
- [Commits](https://github.com/modelmapper/modelmapper/compare/modelmapper-parent-3.2.2...modelmapper-parent-3.2.3)

---
updated-dependencies:
- dependency-name: org.modelmapper:modelmapper
  dependency-version: 3.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-19 08:33:30 +00:00
dependabot[bot]
5ce0a185ac
chore(deps): bump org.springframework.boot:spring-boot-starter-data-redis (#500)
Bumps [org.springframework.boot:spring-boot-starter-data-redis](https://github.com/spring-projects/spring-boot) from 3.4.3 to 3.4.5.
- [Release notes](https://github.com/spring-projects/spring-boot/releases)
- [Commits](https://github.com/spring-projects/spring-boot/compare/v3.4.3...v3.4.5)

---
updated-dependencies:
- dependency-name: org.springframework.boot:spring-boot-starter-data-redis
  dependency-version: 3.4.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 14:42:50 +00:00
dependabot[bot]
5f390521a0
chore(deps): bump commons-io:commons-io in /backend (#501)
Bumps commons-io:commons-io from 2.18.0 to 2.19.0.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  dependency-version: 2.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-17 14:32:47 +00:00
dependabot[bot]
e730a6329d
chore(deps): bump com.puppycrawl.tools:checkstyle in /backend (#499)
Bumps [com.puppycrawl.tools:checkstyle](https://github.com/checkstyle/checkstyle) from 10.21.1 to 10.23.1.
- [Release notes](https://github.com/checkstyle/checkstyle/releases)
- [Commits](https://github.com/checkstyle/checkstyle/compare/checkstyle-10.21.1...checkstyle-10.23.1)

---
updated-dependencies:
- dependency-name: com.puppycrawl.tools:checkstyle
  dependency-version: 10.23.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-16 14:50:33 +00:00
dependabot[bot]
f772577885
chore(deps): bump org.springdoc:springdoc-openapi-starter-webmvc-ui (#495)
Bumps [org.springdoc:springdoc-openapi-starter-webmvc-ui](https://github.com/springdoc/springdoc-openapi) from 2.8.3 to 2.8.8.
- [Release notes](https://github.com/springdoc/springdoc-openapi/releases)
- [Changelog](https://github.com/springdoc/springdoc-openapi/blob/main/CHANGELOG.md)
- [Commits](https://github.com/springdoc/springdoc-openapi/compare/v2.8.3...v2.8.8)

---
updated-dependencies:
- dependency-name: org.springdoc:springdoc-openapi-starter-webmvc-ui
  dependency-version: 2.8.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-15 06:51:02 +00:00
dependabot[bot]
30c86aad5b
chore(deps): bump org.springframework.boot:spring-boot-starter-thymeleaf (#497)
Bumps [org.springframework.boot:spring-boot-starter-thymeleaf](https://github.com/spring-projects/spring-boot) from 3.4.1 to 3.4.5.
- [Release notes](https://github.com/spring-projects/spring-boot/releases)
- [Commits](https://github.com/spring-projects/spring-boot/compare/v3.4.1...v3.4.5)

---
updated-dependencies:
- dependency-name: org.springframework.boot:spring-boot-starter-thymeleaf
  dependency-version: 3.4.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-15 06:44:24 +00:00
dependabot[bot]
0728528325
chore(deps): bump org.springframework.boot:spring-boot-starter-mail (#496)
Bumps [org.springframework.boot:spring-boot-starter-mail](https://github.com/spring-projects/spring-boot) from 3.4.1 to 3.4.5.
- [Release notes](https://github.com/spring-projects/spring-boot/releases)
- [Commits](https://github.com/spring-projects/spring-boot/compare/v3.4.1...v3.4.5)

---
updated-dependencies:
- dependency-name: org.springframework.boot:spring-boot-starter-mail
  dependency-version: 3.4.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-15 06:41:05 +00:00
dependabot[bot]
35f614a2ea
chore(deps): bump org.liquibase:liquibase-core in /backend (#442)
Bumps [org.liquibase:liquibase-core](https://github.com/liquibase/liquibase) from 4.30.0 to 4.31.1.
- [Release notes](https://github.com/liquibase/liquibase/releases)
- [Changelog](https://github.com/liquibase/liquibase/blob/v4.31.1/changelog.txt)
- [Commits](https://github.com/liquibase/liquibase/compare/v4.30.0...v4.31.1)

---
updated-dependencies:
- dependency-name: org.liquibase:liquibase-core
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 09:00:16 +00:00
dependabot[bot]
1ffb74863b
chore(deps-dev): bump io.cucumber:cucumber-spring in /backend (#494)
Bumps [io.cucumber:cucumber-spring](https://github.com/cucumber/cucumber-jvm) from 7.20.1 to 7.22.2.
- [Release notes](https://github.com/cucumber/cucumber-jvm/releases)
- [Changelog](https://github.com/cucumber/cucumber-jvm/blob/main/CHANGELOG.md)
- [Commits](https://github.com/cucumber/cucumber-jvm/compare/v7.20.1...v7.22.2)

---
updated-dependencies:
- dependency-name: io.cucumber:cucumber-spring
  dependency-version: 7.22.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 08:56:28 +00:00
dependabot[bot]
32b5bd7915
chore(deps): bump org.jacoco:jacoco-maven-plugin in /backend (#493)
Bumps [org.jacoco:jacoco-maven-plugin](https://github.com/jacoco/jacoco) from 0.8.12 to 0.8.13.
- [Release notes](https://github.com/jacoco/jacoco/releases)
- [Commits](https://github.com/jacoco/jacoco/compare/v0.8.12...v0.8.13)

---
updated-dependencies:
- dependency-name: org.jacoco:jacoco-maven-plugin
  dependency-version: 0.8.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-14 06:37:20 +00:00
dependabot[bot]
304b9d6867
chore(deps): bump com.google.code.gson:gson in /backend (#475)
Bumps [com.google.code.gson:gson](https://github.com/google/gson) from 2.11.0 to 2.13.1.
- [Release notes](https://github.com/google/gson/releases)
- [Changelog](https://github.com/google/gson/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/gson/compare/gson-parent-2.11.0...gson-parent-2.13.1)

---
updated-dependencies:
- dependency-name: com.google.code.gson:gson
  dependency-version: 2.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-13 15:08:50 +00:00
MDeLuise
62ca830793 chore: bump setup-maven-action in coverage workflow 2025-05-13 16:59:14 +02:00
dependabot[bot]
a22eea10f5
chore(deps): bump com.auth0:java-jwt from 4.4.0 to 4.5.0 in /backend (#453)
Bumps [com.auth0:java-jwt](https://github.com/auth0/java-jwt) from 4.4.0 to 4.5.0.
- [Release notes](https://github.com/auth0/java-jwt/releases)
- [Changelog](https://github.com/auth0/java-jwt/blob/master/CHANGELOG.md)
- [Commits](https://github.com/auth0/java-jwt/compare/4.4.0...4.5.0)

---
updated-dependencies:
- dependency-name: com.auth0:java-jwt
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-13 14:54:50 +00:00
MDeLuise
18863ed05a chore: bump setup-maven-action 2025-05-13 16:49:09 +02:00
MDeLuise
45bce224df chore: temporary disable stale workflow 2025-05-08 16:43:17 +02:00
Rich
8c9d4f893c
feat(lang): add Czech language (#484) 2025-05-02 18:14:18 +00:00
dependabot[bot]
8520004154
chore(deps): bump org.springframework.boot:spring-boot-starter-parent (#445)
Bumps [org.springframework.boot:spring-boot-starter-parent](https://github.com/spring-projects/spring-boot) from 3.4.1 to 3.4.3.
- [Release notes](https://github.com/spring-projects/spring-boot/releases)
- [Commits](https://github.com/spring-projects/spring-boot/compare/v3.4.1...v3.4.3)

---
updated-dependencies:
- dependency-name: org.springframework.boot:spring-boot-starter-parent
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-25 16:04:02 +00:00
dependabot[bot]
2a2f7e3f76
chore(deps): bump org.springframework.boot:spring-boot-starter-data-redis (#446)
Bumps [org.springframework.boot:spring-boot-starter-data-redis](https://github.com/spring-projects/spring-boot) from 3.4.1 to 3.4.3.
- [Release notes](https://github.com/spring-projects/spring-boot/releases)
- [Commits](https://github.com/spring-projects/spring-boot/compare/v3.4.1...v3.4.3)

---
updated-dependencies:
- dependency-name: org.springframework.boot:spring-boot-starter-data-redis
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-25 15:57:27 +00:00
Diego Gomes
bd2b67b3de
feat(lang): Portuguese translation update (#447) 2025-02-25 08:59:47 +00:00
dependabot[bot]
292ae00912
chore(deps): bump com.mysql:mysql-connector-j in /backend (#427)
Bumps [com.mysql:mysql-connector-j](https://github.com/mysql/mysql-connector-j) from 9.1.0 to 9.2.0.
- [Changelog](https://github.com/mysql/mysql-connector-j/blob/release/9.x/CHANGES)
- [Commits](https://github.com/mysql/mysql-connector-j/compare/9.1.0...9.2.0)

---
updated-dependencies:
- dependency-name: com.mysql:mysql-connector-j
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-22 10:56:57 +00:00
dependabot[bot]
e2bb1559a2
chore(deps): bump shared_preferences from 2.3.4 to 2.3.5 in /frontend (#426)
Bumps [shared_preferences](https://github.com/flutter/packages/tree/main/packages/shared_preferences) from 2.3.4 to 2.3.5.
- [Release notes](https://github.com/flutter/packages/releases)
- [Commits](https://github.com/flutter/packages/commits/shared_preferences-v2.3.5/packages/shared_preferences)

---
updated-dependencies:
- dependency-name: shared_preferences
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-22 08:31:02 +00:00
dependabot[bot]
0a646c8863
chore(deps): bump http from 1.2.2 to 1.3.0 in /frontend (#425)
Bumps [http](https://github.com/dart-lang/http/tree/master/pkgs) from 1.2.2 to 1.3.0.
- [Release notes](https://github.com/dart-lang/http/releases)
- [Commits](https://github.com/dart-lang/http/commits/http-v1.3.0/pkgs)

---
updated-dependencies:
- dependency-name: http
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-21 08:13:34 +00:00
dependabot[bot]
9c14e653a0
chore(deps): bump flutter_launcher_icons in /frontend (#424)
Bumps [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) from 0.14.2 to 0.14.3.
- [Release notes](https://github.com/fluttercommunity/flutter_launcher_icons/releases)
- [Changelog](https://github.com/fluttercommunity/flutter_launcher_icons/blob/master/CHANGELOG.md)
- [Commits](https://github.com/fluttercommunity/flutter_launcher_icons/commits)

---
updated-dependencies:
- dependency-name: flutter_launcher_icons
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-20 14:38:40 +00:00
dependabot[bot]
08429b74a9
chore(deps): bump talker from 4.6.2 to 4.6.4 in /frontend (#423)
Bumps [talker](https://github.com/Frezyx/talker) from 4.6.2 to 4.6.4.
- [Release notes](https://github.com/Frezyx/talker/releases)
- [Commits](https://github.com/Frezyx/talker/compare/v4.6.2...v4.6.4)

---
updated-dependencies:
- dependency-name: talker
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-17 14:35:28 +00:00
dependabot[bot]
c521dfd358
chore(deps): bump flutter_svg from 2.0.16 to 2.0.17 in /frontend (#421)
Bumps [flutter_svg](https://github.com/flutter/packages/tree/main/third_party/packages) from 2.0.16 to 2.0.17.
- [Release notes](https://github.com/flutter/packages/releases)
- [Commits](https://github.com/flutter/packages/commits/flutter_svg-v2.0.17/third_party/packages)

---
updated-dependencies:
- dependency-name: flutter_svg
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-16 07:14:33 +00:00
MDeLuise
f0c150b414 fix: stale bot config 2025-01-15 10:04:30 +01:00
MDeLuise
48bb403f9a improve: hide password fields by default 2025-01-15 09:59:23 +01:00
dependabot[bot]
07225e9a25
chore(deps): bump package_info_plus from 8.1.2 to 8.1.3 in /frontend (#420)
Bumps [package_info_plus](https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus) from 8.1.2 to 8.1.3.
- [Release notes](https://github.com/fluttercommunity/plus_plugins/releases)
- [Commits](https://github.com/fluttercommunity/plus_plugins/commits/package_info_plus-v8.1.3/packages/package_info_plus)

---
updated-dependencies:
- dependency-name: package_info_plus
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-15 06:50:20 +00:00
MDeLuise
ba0288984b improve: add password manager support for fields 2025-01-14 12:16:18 +01:00
dependabot[bot]
76c7ccbd5a
chore(deps): bump calendar_view from 1.3.0 to 1.4.0 in /frontend (#419)
Bumps [calendar_view](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view) from 1.3.0 to 1.4.0.
- [Release notes](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/releases)
- [Changelog](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/SimformSolutionsPvtLtd/flutter_calendar_view/compare/1.3.0...1.4.0)

---
updated-dependencies:
- dependency-name: calendar_view
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-14 06:23:55 +00:00
dependabot[bot]
f050da117a
chore(deps): bump com.puppycrawl.tools:checkstyle in /backend (#409)
Bumps [com.puppycrawl.tools:checkstyle](https://github.com/checkstyle/checkstyle) from 10.21.0 to 10.21.1.
- [Release notes](https://github.com/checkstyle/checkstyle/releases)
- [Commits](https://github.com/checkstyle/checkstyle/compare/checkstyle-10.21.0...checkstyle-10.21.1)

---
updated-dependencies:
- dependency-name: com.puppycrawl.tools:checkstyle
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-13 08:04:49 +00:00
MDeLuise
34cc7adfdd doc: add Obtanium download links and banners 2025-01-13 08:45:58 +01:00
MDeLuise
9575cab33a chore: add exempt labels for stale issues and pull requests
Added 'Stale Excerpt' to exempt-issue-labels and exempt-pr-labels to prevent stale issues and pull requests from being considered by the bot.
2025-01-13 08:21:47 +01:00
dependabot[bot]
73d0c32f82
chore(deps): bump org.springdoc:springdoc-openapi-starter-webmvc-ui (#418)
Bumps [org.springdoc:springdoc-openapi-starter-webmvc-ui](https://github.com/springdoc/springdoc-openapi) from 2.7.0 to 2.8.3.
- [Release notes](https://github.com/springdoc/springdoc-openapi/releases)
- [Changelog](https://github.com/springdoc/springdoc-openapi/blob/main/CHANGELOG.md)
- [Commits](https://github.com/springdoc/springdoc-openapi/compare/v2.7.0...v2.8.3)

---
updated-dependencies:
- dependency-name: org.springdoc:springdoc-openapi-starter-webmvc-ui
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-13 06:45:28 +00:00
dependabot[bot]
f5cdadf13e
chore(deps): bump talker from 4.5.4 to 4.6.2 in /frontend (#417)
Bumps [talker](https://github.com/Frezyx/talker) from 4.5.4 to 4.6.2.
- [Release notes](https://github.com/Frezyx/talker/releases)
- [Commits](https://github.com/Frezyx/talker/compare/v4.5.4...v4.6.2)

---
updated-dependencies:
- dependency-name: talker
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-13 06:42:19 +00:00
dependabot[bot]
e9ed987ed3
chore(deps): bump jinja2 in /online-resources/documentation (#406)
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.4...3.1.5)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-25 07:11:35 +00:00
dependabot[bot]
70ae9ce565
chore(deps): bump talker_flutter from 4.5.3 to 4.5.4 in /frontend (#405)
Bumps [talker_flutter](https://github.com/Frezyx/talker) from 4.5.3 to 4.5.4.
- [Release notes](https://github.com/Frezyx/talker/releases)
- [Commits](https://github.com/Frezyx/talker/compare/v4.5.3...v4.5.4)

---
updated-dependencies:
- dependency-name: talker_flutter
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-25 07:11:00 +00:00
dependabot[bot]
60175b7e43
chore(deps): bump talker from 4.5.3 to 4.5.4 in /frontend (#404)
Bumps [talker](https://github.com/Frezyx/talker) from 4.5.3 to 4.5.4.
- [Release notes](https://github.com/Frezyx/talker/releases)
- [Commits](https://github.com/Frezyx/talker/compare/v4.5.3...v4.5.4)

---
updated-dependencies:
- dependency-name: talker
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-25 07:05:28 +00:00
dependabot[bot]
df083674c1
chore(deps): bump talker_flutter from 4.5.2 to 4.5.3 in /frontend (#402)
Bumps [talker_flutter](https://github.com/Frezyx/talker) from 4.5.2 to 4.5.3.
- [Release notes](https://github.com/Frezyx/talker/releases)
- [Commits](https://github.com/Frezyx/talker/compare/v4.5.2...v4.5.3)

---
updated-dependencies:
- dependency-name: talker_flutter
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-23 09:57:17 +00:00
dependabot[bot]
6863027033
chore(deps): bump talker from 4.5.2 to 4.5.3 in /frontend (#403)
Bumps [talker](https://github.com/Frezyx/talker) from 4.5.2 to 4.5.3.
- [Release notes](https://github.com/Frezyx/talker/releases)
- [Commits](https://github.com/Frezyx/talker/compare/v4.5.2...v4.5.3)

---
updated-dependencies:
- dependency-name: talker
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-23 09:46:30 +00:00
dependabot[bot]
98ebfc6c44
chore(deps): bump org.springframework.boot:spring-boot-starter-data-redis (#401)
Bumps [org.springframework.boot:spring-boot-starter-data-redis](https://github.com/spring-projects/spring-boot) from 3.4.0 to 3.4.1.
- [Release notes](https://github.com/spring-projects/spring-boot/releases)
- [Commits](https://github.com/spring-projects/spring-boot/compare/v3.4.0...v3.4.1)

---
updated-dependencies:
- dependency-name: org.springframework.boot:spring-boot-starter-data-redis
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-23 09:39:10 +00:00
dependabot[bot]
b6f03a461e
chore(deps): bump org.springframework.boot:spring-boot-starter-mail (#397)
Bumps [org.springframework.boot:spring-boot-starter-mail](https://github.com/spring-projects/spring-boot) from 3.4.0 to 3.4.1.
- [Release notes](https://github.com/spring-projects/spring-boot/releases)
- [Commits](https://github.com/spring-projects/spring-boot/compare/v3.4.0...v3.4.1)

---
updated-dependencies:
- dependency-name: org.springframework.boot:spring-boot-starter-mail
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-20 08:38:16 +00:00
dependabot[bot]
d59d3f354b
chore(deps): bump org.springframework.boot:spring-boot-starter-thymeleaf (#396)
Bumps [org.springframework.boot:spring-boot-starter-thymeleaf](https://github.com/spring-projects/spring-boot) from 3.4.0 to 3.4.1.
- [Release notes](https://github.com/spring-projects/spring-boot/releases)
- [Commits](https://github.com/spring-projects/spring-boot/compare/v3.4.0...v3.4.1)

---
updated-dependencies:
- dependency-name: org.springframework.boot:spring-boot-starter-thymeleaf
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-20 08:28:31 +00:00
dependabot[bot]
4276a1cd0a
chore(deps): bump org.springframework.boot:spring-boot-starter-parent (#395)
Bumps [org.springframework.boot:spring-boot-starter-parent](https://github.com/spring-projects/spring-boot) from 3.4.0 to 3.4.1.
- [Release notes](https://github.com/spring-projects/spring-boot/releases)
- [Commits](https://github.com/spring-projects/spring-boot/compare/v3.4.0...v3.4.1)

---
updated-dependencies:
- dependency-name: org.springframework.boot:spring-boot-starter-parent
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-20 06:48:53 +00:00
dependabot[bot]
73f1b43ad2
chore(deps): bump shared_preferences from 2.3.3 to 2.3.4 in /frontend (#393)
Bumps [shared_preferences](https://github.com/flutter/packages/tree/main/packages/shared_preferences) from 2.3.3 to 2.3.4.
- [Release notes](https://github.com/flutter/packages/releases)
- [Commits](https://github.com/flutter/packages/commits/shared_preferences-v2.3.4/packages/shared_preferences)

---
updated-dependencies:
- dependency-name: shared_preferences
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-19 07:32:56 +00:00
dependabot[bot]
9aa75e0e7b
chore(deps): bump package_info_plus from 8.1.1 to 8.1.2 in /frontend (#390)
Bumps [package_info_plus](https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus) from 8.1.1 to 8.1.2.
- [Release notes](https://github.com/fluttercommunity/plus_plugins/releases)
- [Commits](https://github.com/fluttercommunity/plus_plugins/commits/package_info_plus-v8.1.2/packages/package_info_plus)

---
updated-dependencies:
- dependency-name: package_info_plus
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-13 09:17:29 +00:00
dependabot[bot]
435b8c179b
chore(deps): bump com.puppycrawl.tools:checkstyle in /backend (#389)
Bumps [com.puppycrawl.tools:checkstyle](https://github.com/checkstyle/checkstyle) from 10.20.2 to 10.21.0.
- [Release notes](https://github.com/checkstyle/checkstyle/releases)
- [Commits](https://github.com/checkstyle/checkstyle/compare/checkstyle-10.20.2...checkstyle-10.21.0)

---
updated-dependencies:
- dependency-name: com.puppycrawl.tools:checkstyle
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-13 09:12:08 +00:00
dependabot[bot]
682ca65d72
chore(deps): bump org.modelmapper:modelmapper in /backend (#386)
Bumps [org.modelmapper:modelmapper](https://github.com/modelmapper/modelmapper) from 3.2.1 to 3.2.2.
- [Changelog](https://github.com/modelmapper/modelmapper/blob/master/CHANGES.md)
- [Commits](https://github.com/modelmapper/modelmapper/compare/modelmapper-parent-3.2.1...modelmapper-parent-3.2.2)

---
updated-dependencies:
- dependency-name: org.modelmapper:modelmapper
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-10 10:07:36 +00:00
966 changed files with 74746 additions and 43125 deletions

View File

@ -7,11 +7,9 @@ body:
required: true
- type: textarea
attributes:
label: (if applicable) Local environment
label: Local environment
description: If applicable, please provide additional information related to the environment you are using.
placeholder: |
1. backend version and app version
2. content of docker-compose.yml file (remember to hide possible sensitive info)
3. content of server.env file (remember to hide possible sensitive info)
4. app log
5. output of the docker compose log
1. app version
2. phone model
3. phone OS version

View File

@ -37,14 +37,6 @@ body:
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: If applicable, add complete logs from the system logs.
render: shell
validations:
required: false
- type: textarea
id: screenshots
attributes:
@ -53,7 +45,7 @@ body:
validations:
required: false
# Host details
# App details
- type: input
id: plant-it-version
attributes:
@ -61,39 +53,10 @@ body:
placeholder: v1.0.0
validations:
required: true
- type: dropdown
id: plant-it-setup-method
attributes:
label: Setup Method
description: How did you installed Plant-it?
options:
- Docker
- Non-docker
validations:
required: true
- type: dropdown
- type: input
id: plant-it-setup-os
attributes:
label: Operating System
description: In which operating system does the server run?
options:
- Linux (Ubuntu, CentOS,...)
- Windows
- macOS
- other (please specify in description)
label: Phone Model and Operating System version
description: In which operating system does the app run?
validations:
required: true
# Client details
- type: textarea
id: client-details
attributes:
label: Client details
description: Details about your browser and operating system used to access Plant-it.
placeholder: |
- OS: [e.g. Windows, macOS, iOS, Android]
- Browser [e.g. Chrome, Firefox, Safari]
- Browser Version [e.g. 101.4]
- Android APK
validations:
required: false

View File

@ -1,19 +1,5 @@
version: 2
updates:
# Maintain dependencies for server
- package-ecosystem: "maven"
directory: "/backend"
schedule:
interval: "daily"
time: "06:30"
timezone: "Europe/Rome"
open-pull-requests-limit: 5
labels:
- "Status: Review Needed"
- "Type: Maintenance"
- "Priority: Low"
# Maintain dependencies for app
- package-ecosystem: "pub"
directory: "/frontend"
schedule:

View File

@ -1,51 +0,0 @@
name: Measure coverage
on:
pull_request:
branches:
- main
jobs:
calculate_coverage:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
if: ${{ github.actor != 'dependabot[bot]' }}
uses: actions/checkout@v2
- name: Setup Maven Action
if: ${{ github.actor != 'dependabot[bot]' }}
uses: s4u/setup-maven-action@v1.2.1
with:
java-version: 21
- name: Run backend tests
if: ${{ github.actor != 'dependabot[bot]' }}
run: mvn clean install
working-directory: ./backend
- name: Run Coverage
if: ${{ github.actor != 'dependabot[bot]' }}
run: mvn jacoco:report
working-directory: ./backend
- name: JaCoCo Report to PR
if: ${{ github.actor != 'dependabot[bot]' }}
uses: Madrapps/jacoco-report@v1.6.1
with:
paths: ${{ github.workspace }}/backend/target/site/jacoco/jacoco.xml
token: ${{ secrets.GITHUB_TOKEN }}
min-coverage-overall: 40
min-coverage-changed-files: 60
title: 📝 Coverage Report For Server Service
pass-emoji: 🟢
fail-emoji: 🔴
- name: Get the Coverage info
if: ${{ github.actor != 'dependabot[bot]' }}
run: |
echo "Total coverage ${{ steps.jacoco.outputs.coverage-overall }}"
echo "Changed Files coverage ${{ steps.jacoco.outputs.coverage-changed-files }}"

View File

@ -8,7 +8,7 @@ on:
type: boolean
env:
FLUTTER_VERSION: 3.24.3
FLUTTER_VERSION: 3.32.8
jobs:
create_apk:
@ -26,14 +26,8 @@ jobs:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '21'
- name: Create apk File
run: flutter pub get && flutter build apk --release
run: flutter pub get && flutter build apk --release --dart-define=ENV=prod
working-directory: ./frontend
- name: Decode keystore

View File

@ -9,26 +9,6 @@ on:
- main
jobs:
backend:
runs-on: ubuntu-latest
steps:
- name: Setup Maven Action
uses: s4u/setup-maven-action@v1.2.1
with:
java-version: 21
- name: Build
run: mvn clean install -DskipTests -Dcheckstyle.skip
working-directory: ./backend
- name: Verify the checkstyle
run: mvn checkstyle:check
working-directory: ./backend
- name: Run the tests
run: mvn test
working-directory: ./backend
frontend:
runs-on: ubuntu-latest
steps:
@ -39,7 +19,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.24.3
flutter-version: 3.32.8
- name: Download Dependencies
run: flutter pub get

View File

@ -3,32 +3,13 @@ name: Automated Release
on:
push:
tags:
- '[0-9]*.[0-9]*.[0-9]*'
- '[0-9]*\.[0-9]*\.[0-9]*(-[a-zA-Z0-9]+)?'
env:
FLUTTER_VERSION: 3.24.3
FLUTTER_VERSION: 3.32.8
DOCKER_NAME: plant-it-server
jobs:
build_and_test_backend:
runs-on: ubuntu-latest
steps:
- name: Setup Maven Action
uses: s4u/setup-maven-action@v1.2.1
with:
java-version: 21
- name: Create JAR File
run: mvn package
working-directory: ./backend
- name: Save JAR File
uses: actions/upload-artifact@v4
with:
name: backend-jar
retention-days: 1
path: ./backend/target/*.jar
build_and_test_frontend:
runs-on: ubuntu-latest
steps:
@ -54,7 +35,7 @@ jobs:
working-directory: ./frontend
- name: Create Flutter Build Files
run: flutter build web --release
run: flutter build apk --release
working-directory: ./frontend
- name: Save Flutter Build Files
@ -64,59 +45,6 @@ jobs:
retention-days: 1
path: ./frontend/build
create_and_push_images:
needs: [build_and_test_backend, build_and_test_frontend]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Load JAR File
uses: actions/download-artifact@v4
with:
name: backend-jar
path: ./backend/target/
- name: Load Flutter Build Files
uses: actions/download-artifact@v4
with:
name: frontend-build
path: ./frontend/build
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to the GitHub Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set lowercase actor
run: echo "LOWERCASE_ACTOR=${GITHUB_REPOSITORY_OWNER@L}" >> "${GITHUB_ENV}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./deployment/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ vars.DOCKERHUB_USERNAME }}/${{ env.DOCKER_NAME }}:${{ github.ref_name }}
${{ vars.DOCKERHUB_USERNAME }}/${{ env.DOCKER_NAME }}:latest
ghcr.io/${{ env.LOWERCASE_ACTOR }}/${{ env.DOCKER_NAME }}:${{ github.ref_name }}
ghcr.io/${{ env.LOWERCASE_ACTOR }}/${{ env.DOCKER_NAME }}:latest
create_apk:
needs: build_and_test_frontend
runs-on: ubuntu-latest
@ -133,12 +61,6 @@ jobs:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Create apk File
run: flutter pub get && flutter build apk --release
working-directory: ./frontend
@ -167,7 +89,7 @@ jobs:
create_release:
runs-on: ubuntu-latest
needs: [build_and_test_backend, create_apk]
needs: create_apk
steps:
- name: Load apk File
uses: actions/download-artifact@v4
@ -175,24 +97,6 @@ jobs:
name: frontend-apk
path: .
- name: Load backend jar
uses: actions/download-artifact@v4
with:
name: backend-jar
path: .
- name: Rename backend jar to server.jar
run: mv *.jar ./server.jar
- name: Load frontend files
uses: actions/download-artifact@v4
with:
name: frontend-build
path: .
- name: Create client.tar.gz
run: tar -czf client.tar.gz -C ./web .
- name: Create Draft Release
uses: softprops/action-gh-release@v2
with:
@ -201,21 +105,20 @@ jobs:
body: |
Hello, Plant-it community!
# Services version
- server version: ``
- app version: ``
# Highlights
Welcome to the release v${{ github.ref_name }} of Plant-it.
# Support Plant-it
If you find the project helpful, you can support Plant-it by buying me a coffee at https://www.buymeacoffee.com/mdeluise
https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExaW1xYWsxcHd1Zm85dnk0aGdmMzAzaTh4ZG14ZXUwcGFjdTdwNDEydCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/FkD6uqVhmJMd2/giphy.gif
# What's Changed
## 📱 App
-
## 🗄️ Server
-
## 📓 Documentation
-
## 🚀 Features
## 🌟 Enhancements
## 🐛 Bug fixes
## 🌐 Translations
## 🚀 Features
# New Contributors
* X made their first contribution in Y
@ -226,7 +129,5 @@ jobs:
append_body: true
files: |
app-release.apk
server.jar
client.tar.gz
fail_on_unmatched_files: true
token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,29 +1,28 @@
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '30 6 * * *'
workflow_dispatch:
# name: 'Close stale issues and PRs'
# on:
# schedule:
# - cron: '30 6 * * *'
# workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
days-before-stale: 90
days-before-close: 14
exempt-issue-labels: 'Type: Feature Request'
stale-issue-message: 'This issue has been marked as stale due to no activity in the last 90 days. It will be closed in 14 days if no further activity occurs.'
stale-pr-message: 'This pull request has been marked as stale due to no activity in the last 90 days. It will be closed in 14 days if no further activity occurs.'
close-issue-message: 'Closing this issue as it has been stale for 14 days.'
close-pr-message: 'Closing this pull request as it has been stale for 14 days.'
stale-issue-label: 'stale'
stale-pr-label: 'stale'
operations-per-run: 30
remove-issue-stale-when-updated: true
remove-pr-stale-when-updated: true
exempt-issue-author: 'dependabot[bot]'
exempt-pr-author: 'dependabot[bot]'
# jobs:
# stale:
# runs-on: ubuntu-latest
# permissions:
# issues: write
# pull-requests: write
# steps:
# - uses: actions/stale@v9
# with:
# days-before-stale: 90
# days-before-close: 14
# stale-issue-message: 'This issue has been marked as stale due to no activity in the last 90 days. It will be closed in 14 days if no further activity occurs.'
# stale-pr-message: 'This pull request has been marked as stale due to no activity in the last 90 days. It will be closed in 14 days if no further activity occurs.'
# close-issue-message: 'Closing this issue as it has been stale for 14 days.'
# close-pr-message: 'Closing this pull request as it has been stale for 14 days.'
# operations-per-run: 30
# remove-issue-stale-when-updated: true
# remove-pr-stale-when-updated: true
# exempt-issue-author: 'dependabot[bot]'
# exempt-pr-author: 'dependabot[bot]'
# exempt-issue-labels: 'Type: Feature Request,Stale Excerpt'
# exempt-pr-labels: 'Stale Excerpt'

9
.gitignore vendored
View File

@ -1,10 +1 @@
*.DS_Store
online-resources/**/public
online-resources/**/node_modules
online-resources/**/package-lock.json
online-resources/**/resources
online-resources/documentation/venv
frontend/untranslated.txt

26
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,26 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Flutter (Development)",
"program": "frontend/lib/main.dart",
"request": "launch",
"type": "dart",
"args": [
"--dart-define=ENV=dev"
]
},
{
"name": "Flutter (Production)",
"program": "frontend/lib/main.dart",
"request": "launch",
"type": "dart",
"args": [
"--dart-define=ENV=prod"
]
}
]
}

142
README.md
View File

@ -1,114 +1,34 @@
<p align="center">
<img width="150px" src="images/plant-it-logo.png" title="Plant-it">
<img src="images/logo-square.png" alt="Plant-it" style="width: 130px; height: 130px; border-radius: 15px"/>
</p>
<p align="center">Plant-it is a <b>gardening companion app.</b><br>Useful for keeping track of plant care, receiving notifications about when to water plants, uploading plant images, and more.</p>
<h1 align="center">Plant-it</h1>
<p align="center"><i><b>[Project under "active" development, some features may be unstable or change in the future. A first release version is planned to be packed soon].</b></i></p>
<p align="center">Plant-it is a <b>self-hosted gardening companion app.</b><br>Useful for keeping track of plant care, receiving notifications about when to water plants, uploading plant images, and more.</p>
<p align="center"><a href="https://docs.plant-it.org/latest/">Explore the documentation</a></p>
<p align="center"><a href="https://github.com/MDeLuise/plant-it/#why">Why?</a><a href="https://github.com/MDeLuise/plant-it/#features-highlight">Features highlights</a><a href="https://github.com/MDeLuise/plant-it/#quickstart">Quickstart</a><a href="https://github.com/MDeLuise/plant-it/#support-the-project">Support</a><a href="https://github.com/MDeLuise/plant-it/#contribute">Contribute</a></p>
<p align="center"><a href="https://github.com/MDeLuise/plant-it/#why">Why?</a><a href="https://github.com/MDeLuise/plant-it/#features-highlight">Features highlights</a><a href="https://github.com/MDeLuise/plant-it/#download">Download</a><a href="https://github.com/MDeLuise/plant-it/#support-the-project">Support</a><a href="https://github.com/MDeLuise/plant-it/#contribute">Contribute</a></p>
<p align="center">
<img src="/images/banner.png" width="100%" />
<img src="images/banner.png" width="100%" />
</p>
> [!NOTE]
> This project has reached a stable level of maturity, and as such, active development has slowed down. While pull requests and small bug fixes are still welcome, new feature requests will be considered with difficulty.
## Why?
Plant-it is a gardening companion app that helps you take care of your plants.
It does not recommend you about which action to take, instead it is designed to log the activity you are doing.
This is on purpose, I strongly believe that the only one in charge of knowing when to water your plants, when to fertilize them, etc. is you (with the help of multiple online sources).
It does not recommend you about which action to take; instead, it is designed to log the activity you are doing. This is on purpose, as I strongly believe that the only one in charge of knowing when to water your plants, when to fertilize them, etc., is you (with the help of multiple online sources).
Plant-it helps you remember the last time you did a treatment of your plants, which plants you have, collects photos of your plants, and notifies you about the time passed since the last action on them.
## Features highlight
* Add existing plants or user created plants to your collection
* Add existing plants or user-created plants to your collection
* Log events like watering, fertilizing, biostimulating, etc. for your plants
* View all the logged events, filtering by plant and event type
* Upload photos of your plants
* Set reminders for some actions on your plants (e.g. notify if not watered every 4 days)
* Set reminders for some actions on your plants (e.g., notify if not watered every 4 days)
## Quickstart
### Server
Installing Plant-it is pretty straight forward, in order to do so follow these steps:
## Download
You can download the Plant-it Android app from the following sources:
* Create a folder where you want to place all Plant-it related files.
* Inside that folder, create a file named `docker-compose.yml` with this content:
```yaml
name: plant-it
services:
server:
image: msdeluise/plant-it-server:latest
env_file: server.env
depends_on:
- db
- cache
restart: unless-stopped
volumes:
- "./upload-dir:/upload-dir"
ports:
- "8080:8080"
- "3000:3000"
db:
image: mysql:8.0
restart: always
env_file: server.env
volumes:
- "./db:/var/lib/mysql"
cache:
image: redis:7.2.1
restart: always
```
* Inside that folder, create a file named `server.env` with this content:
```properties
#
# DB
#
MYSQL_HOST=db
MYSQL_PORT=3306
MYSQL_USERNAME=root
MYSQL_PSW=root
MYSQL_DATABASE=bootdb
MYSQL_ROOT_PASSWORD=root
#
# JWT
#
JWT_SECRET=putTheSecretHere
JWT_EXP=1
#
# Server config
#
USERS_LIMIT=-1
UPLOAD_DIR=/upload-dir
API_PORT=8080
FLORACODEX_KEY=
LOG_LEVEL=DEBUG
ALLOWED_ORIGINS=*
#
# Cache
#
CACHE_TTL=86400
CACHE_HOST=cache
CACHE_PORT=6379
```
* Run the docker compose file (`docker compose -f docker-compose.yml up -d`), then the service will be available at `localhost:3000`, while the REST API will be available at `localhost:8080/api` (`localhost:8080/api/swagger-ui/index.html` for the documentation of them).
<a href="https://docs.plant-it.org/latest/server-installation/#configuration">Take a look at the documentation</a> in order to understand the available configurations.
## App
You can access the Plant-it service using the web app at `http://<server_ip>:3000`.
For Android users, the app is also available as an APK, which can be downloaded either from the GitHub releases assets or from F-Droid.
### Download
- **GitHub Releases**: You can download the latest APK from the [GitHub releases page](https://github.com/MDeLuise/plant-it/releases/latest).
<p align="center">
<a href="https://github.com/MDeLuise/plant-it/releases/latest"><img src="https://raw.githubusercontent.com/Kunzisoft/Github-badge/main/get-it-on-github.png" alt="Get it on GitHub" height="60" style="max-width: 200px"></a>
@ -119,9 +39,10 @@ For Android users, the app is also available as an APK, which can be downloaded
<a href="https://f-droid.org/packages/com.github.mdeluise.plantit" rel="nofollow"><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Get_it_on_F-Droid_%28material_design%29.svg/2880px-Get_it_on_F-Droid_%28material_design%29.svg.png" alt="Get it on F-Droid" height=40 ></a>
</p>
### Installation
For detailed instructions on how to install and configure the app, please refer to the [installation documentation](https://docs.plant-it.org/latest/app-installation/).
- **Obtanium**: You can also download the app from [Obtanium](http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/MDeLuise/plant-it).
<p align="center">
<a href="http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/MDeLuise/plant-it" rel="nofollow"><img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtanium" height=40 ></a>
</p>
## Support the project
If you find this project helpful and would like to support it, consider [buying me a coffee](https://www.buymeacoffee.com/mdeluise). Your generosity helps keep this project alive and ensures its continued development and improvement.
@ -130,30 +51,11 @@ If you find this project helpful and would like to support it, consider [buying
</p>
## Contribute
Feel free to contribute and help improve the repo.
### Contributing Translations to the Project
We welcome contributions to help improve the Plant-it project! Whether you want to fix bugs, add new features, or enhance documentation, your input is valuable.
Please refer to the [Contributing Guidelines](contributing.md) for detailed instructions on how to contribute effectively. Thank you for your support!
### Translations
If you're interested in contributing transactions to enhance the app, you can get started by following the guide provided [here](https://github.com/MDeLuise/plant-it/discussions/148). Your support and contributions are greatly appreciated.
| Language | Filename | Translation |
|----------|----------|-------------|
| English | app_en.arb | 100% |
| Italian | app_it.arb | 100% |
| German | app_de.arb | 100% |
| Polish | app_pl.arb | 100% |
| Chinese | app_zh.arb | 100% |
| Russian | app_ru.arb | 91% |
| Dutch Flemish | app_nl.arb | 91% |
| French | app_fr.arb | 90% |
| Danish | app_da.arb | 90% |
| Portuguese | app_pt.arb | 89% |
| Ukrainian | app_uk.arb | 87% |
| Spanish Castilian | app_es.arb | 87% |
### Bug Report, Feature Request and Question
You can submit any of this in the [issues](https://github.com/MDeLuise/plant-it/issues/new/choose) section of the repository. Choose the right template and then fill in the required info.
### Feature development
Let's discuss first possible solutions for the development before start working on that, please open a [feature request issue](https://github.com/MDeLuise/plant-it/issues/new?assignees=&labels=Status:+Created,Type:+Feature+Request&projects=&template=feature_request.yml).
### How to contribute
If you want to make some changes and test them locally <a href="https://docs.plant-it.org/latest/support/#contributing">take a look at the documentation</a>.

10
backend/.gitignore vendored
View File

@ -1,10 +0,0 @@
# target
target/
# IDE specific files
.idea/
.vscode/
# Other
.DS_Store
*.iml

View File

@ -1,96 +0,0 @@
# Testing Guideline
### Tools in use
### JUnit 5 as the testing framework
[JUnit 5](https://junit.org/junit5/) is the most advanced testing library for Java developers.
It provides advanced features for testing any layer.
Here its used to create unit tests.
### AssertJ as the assertions library
[AssertJ](https://assertj.github.io/doc/) extends the assertions of the testing framework, providing a rich set of assertions and improving the test code readability.
#### Prefer using AssertJ assertions methods over JUnit 5 ones
The assertion methods on both sides will achieve the main goal: validate the actual to the expected result.
The main difference is that AssertJ provides more assertions than `assertEquals` or `assertTrue`, it has a variety of methods to use for different objects.
### Naming
#### Test class name
The test classes must match the subject of the test, plus the suffix
`Test`.
Example: if the subject of the test is the `Limit` class, the test name will be `LimitTest`.
#### Test method name
All the test methods should describe the test intent in the following structure:
* fixed prefix `should`
* action name
* expected result optional for happy paths and mandatory for negative scenarios (corner cases)
Example for happy paths (positive scenario):
* `shouldCreatePageable`
* `shouldCreateLimitWithEqualsRange`
Example for negative scenario (or corner-case):
* `shouldReturnErrorWhenMaxResultsIsNegative`
#### Test description
This project uses the `@DisplayName` annotation from JUnit 5
#### Structure
```
class CalculatorTest {
@Test
@DisplayName("Should sum up correctly two numbers")
void shouldSumUpCorrectly() {
// ...
}
}
```
### Creation
#### Prefer soft assertions to hard assertions
Hard Assertions are the normal assertion method that will halt the test execution once the actual result is not matching the expected one.
Soft Assertion is a mechanism to run a group of assertions that wont halt the execution until all the assertions have been executed, showing an error summary when it happens.
Its more beneficial when we assert multiple attributes from an object.
This project uses the `SoftAssertions.assertSoftly()` method from the AssertJ library, and you can see a lot of already implemented examples in the available tests.
The current usage is done by importing statically the `SoftAssertions` class, using directly the
`assertSoftly()` method.
The assertions are done by using the consumer name, followed by the `assertThat()` method, as the consumer name in the below example is `softly`:
```
SoftAssertions softly = new SoftAssertions();
softly.assertThat(pageable.size()).as("page size is correct").isEqualTo(20);
softly.assertThat(pageable.mode()).as("default page mode is correct")
.isEqualTo(Pageable.Mode.CURSOR_NEXT);
softly.assertThat(pageable.cursor().getKeysetElement(2)).as("first keySetElement is correct")
.isEqualTo("First");
});
```
Don't forget to use the `as()` method!
It will be useful to fast identity which assertion is failing.
#### Use the AssertJ method for expected exceptions
Use one of the existing AssertJ methods to test any exception.
Try to write the `assertThat` followed by the exception class name.
Example:
`assertThatIllegalArgumentException()`.
When you can not find a method with a specific exception, use the method
`assertThatThrownBy()`.

View File

@ -1,244 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.0</version>
<relativePath/>
</parent>
<groupId>com.github.mdeluise</groupId>
<artifactId>plant-it</artifactId>
<version>0.11.0</version>
<name>Plant it</name>
<description>Gardening companion app</description>
<properties>
<java.version>21</java.version>
<start-class>com.github.mdeluise.plantit.Application</start-class>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>3.2.1</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.18.0</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.1.0</version>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.30.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>3.4.0</version>
</dependency>
<!--
https://github.com/kstyrc/embedded-redis/issues/135#issuecomment-1825620600
Previous dependency it.ozimov:embedded-redis:0.7.3 does not work on m1 mac
-->
<dependency>
<groupId>com.github.codemonstur</groupId>
<artifactId>embedded-redis</artifactId>
<version>1.4.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>7.9.0</version>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-spring</artifactId>
<version>7.20.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit</artifactId>
<version>7.10.1</version>
<scope>test</scope>
</dependency>
<!-- https://stackoverflow.com/a/65190070/20088695 -->
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.7.0</version>
</dependency>
</dependencies>
<profiles>
<profile>
<id>copyFiles</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<phase>initialize</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<tasks>
<echo>Copying files...</echo>
<copy file="${project.basedir}/src/main/resources/upload-dir/dummy-image.jpg"
tofile="/tmp/plant-it/3531aae7-486f-4650-a4e3-047d3755a759.jpg"/>
</tasks>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<configLocation>${project.basedir}/src/main/resources/checkstyle.xml
</configLocation>
<consoleOutput>true</consoleOutput>
</configuration>
<executions>
<execution>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>10.20.2</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -1,12 +0,0 @@
package com.github.mdeluise.plantit;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

View File

@ -1,135 +0,0 @@
package com.github.mdeluise.plantit;
import java.io.IOException;
import java.net.http.HttpClient;
import com.github.mdeluise.plantit.notification.otp.OtpService;
import com.github.mdeluise.plantit.notification.password.TemporaryPasswordService;
import com.github.mdeluise.plantit.reminder.ReminderDispatcher;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Contact;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.info.License;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.servers.Server;
import io.swagger.v3.oas.annotations.servers.ServerVariable;
import jakarta.annotation.PreDestroy;
import org.modelmapper.ModelMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import redis.embedded.RedisServer;
@Configuration
@OpenAPIDefinition(
info = @Info(
title = "Plant-it REST API", version = "1.0",
description = "<h1>Introduction</h1>" + "<p>Plant-it is a self-hosted, open " +
"source, gardening companion app.</p>",
license = @License(name = "GPL 3.0", url = "https://www.gnu.org/licenses/gpl-3.0.en.html"),
contact = @Contact(name = "GitHub page", url = "https://github.com/MDeLuise/plant-it")
), security = {@SecurityRequirement(name = "bearerAuth")}, servers = {
@Server(description = "Localhost", url = "/api"),
@Server(
description = "Custom",
url = "{protocol}://{host}:{port}/{basePath}",
variables = {
@ServerVariable(name = "protocol", defaultValue = "http", allowableValues = {"http", "https"}),
@ServerVariable(name = "host", defaultValue = "localhost"),
@ServerVariable(name = "port", defaultValue = "8085"),
@ServerVariable(name = "basePath", defaultValue = "api")
})
}
)
@SecurityScheme(
name = "bearerAuth", type = SecuritySchemeType.HTTP, bearerFormat = "JWT", scheme = "bearer",
in = SecuritySchemeIn.HEADER
)
@EnableScheduling
@EnableMethodSecurity
@EnableCaching
public class ApplicationConfig {
private final OtpService otpService;
private final TemporaryPasswordService temporaryPasswordService;
private final ReminderDispatcher reminderDispatcher;
private RedisServer redisServer;
private final Logger logger = LoggerFactory.getLogger(ApplicationConfig.class);
@Autowired
public ApplicationConfig(OtpService otpService, TemporaryPasswordService temporaryPasswordService,
ReminderDispatcher reminderDispatcher) {
this.otpService = otpService;
this.temporaryPasswordService = temporaryPasswordService;
this.reminderDispatcher = reminderDispatcher;
}
@Bean
public ModelMapper modelMapper() {
return new ModelMapper();
}
@Bean
@Profile(value = "dev")
public CommandLineRunner initEmbeddedCache(@Value("${spring.data.redis.port}") int port) {
return args -> {
logger.debug("Starting embedded redis...");
redisServer = new RedisServer(port);
redisServer.start();
};
}
@PreDestroy
public void stopRedisServer() throws IOException {
if (redisServer != null) {
logger.debug("Stopping embedded redis...");
try {
redisServer.stop();
} catch (IOException e) {
logger.error("Error while stopping embedded redis instance", e);
throw e;
}
}
}
@Bean
public HttpClient httpClient() {
return HttpClient.newHttpClient();
}
@CacheEvict(allEntries = true, cacheNames = {"latest-version"})
@Scheduled(fixedDelay = 86400000) // 1 day
public void cacheEvict() {
}
@Scheduled(fixedDelay = 28800000) // 8 hours
public void removeExpired() {
otpService.removeExpired();
temporaryPasswordService.removeExpired();
}
@Scheduled(cron = "${reminders.notify_check}")
public void dispatchReminders() {
reminderDispatcher.dispatch();
}
}

View File

@ -1,173 +0,0 @@
package com.github.mdeluise.plantit.authentication;
import java.util.Date;
import com.github.mdeluise.plantit.authentication.payload.request.LoginRequest;
import com.github.mdeluise.plantit.authentication.payload.request.SignupRequest;
import com.github.mdeluise.plantit.authentication.payload.response.UserInfoResponse;
import com.github.mdeluise.plantit.common.MessageResponse;
import com.github.mdeluise.plantit.exception.MaximumNumberOfUsersReachedExceptions;
import com.github.mdeluise.plantit.notification.email.EmailException;
import com.github.mdeluise.plantit.notification.email.EmailService;
import com.github.mdeluise.plantit.notification.otp.OtpService;
import com.github.mdeluise.plantit.notification.password.TemporaryPasswordService;
import com.github.mdeluise.plantit.security.jwt.JwtTokenInfo;
import com.github.mdeluise.plantit.security.jwt.JwtWebUtil;
import com.github.mdeluise.plantit.security.services.UserDetailsImpl;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PathVariable;
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("/authentication")
@Tag(name = "Authentication", description = "Endpoints for authentication")
public class AuthController {
private final AuthenticationManager authManager;
private final JwtWebUtil jwtWebUtil;
private final UserService userService;
private final int maxAllowedUsers;
private final EmailService emailService;
private final OtpService otpService;
private final TemporaryPasswordService temporaryPasswordService;
private final Logger logger = LoggerFactory.getLogger(AuthController.class);
@Autowired
@SuppressWarnings("ParameterNumber")
public AuthController(AuthenticationManager authManager, JwtWebUtil jwtWebUtil, UserService userService,
@Value("${users.max}") int maxAllowedUsers, EmailService emailService, OtpService otpService,
TemporaryPasswordService temporaryPasswordService) {
this.authManager = authManager;
this.jwtWebUtil = jwtWebUtil;
this.userService = userService;
this.maxAllowedUsers = maxAllowedUsers;
this.emailService = emailService;
this.otpService = otpService;
this.temporaryPasswordService = temporaryPasswordService;
}
@PostMapping("/login")
@Operation(
summary = "Username and password login", description = "Login using username and password."
)
@SecurityRequirements()
public ResponseEntity<UserInfoResponse> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
final Authentication authentication = authManager.authenticate(
new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password()));
SecurityContextHolder.getContext().setAuthentication(authentication);
final User user = userService.get(loginRequest.username());
final UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
final JwtTokenInfo jwtCookie = jwtWebUtil.generateJwt(userDetails);
final UserInfoResponse response =
new UserInfoResponse(userDetails.getId(), userDetails.getUsername(), user.getEmail(), user.getLastLogin(),
jwtCookie
);
user.setLastLogin(new Date());
userService.updateInternal(user.getId(), user);
return ResponseEntity.ok().body(response);
}
@PostMapping("/signup")
@Operation(
summary = "Signup", description = "Create a new account."
)
@SecurityRequirements()
@SuppressWarnings("ReturnCount") // FIXME
public ResponseEntity<MessageResponse> registerUser(@Valid @RequestBody SignupRequest signUpRequest)
throws EmailException {
if (maxAllowedUsers > 0 && userService.count() >= maxAllowedUsers) {
throw new MaximumNumberOfUsersReachedExceptions();
}
if (userService.existsByUsername(signUpRequest.username())) {
return ResponseEntity.badRequest().body(new MessageResponse("Error: Username is already taken!"));
}
if (userService.existsByEmail(signUpRequest.email())) {
return ResponseEntity.badRequest().body(new MessageResponse("Error: Email is already used!"));
}
if (emailService.isEnabled()) {
emailService.sendOtpMessage(signUpRequest.username(), signUpRequest.email());
return ResponseEntity.accepted().body(new MessageResponse("Signup request pending verification"));
}
userService.save(signUpRequest.username(), signUpRequest.password(), signUpRequest.email());
return ResponseEntity.ok(new MessageResponse("User registered successfully."));
}
@PostMapping("/signup/otp/{otpCode}")
@Operation(
summary = "Signup with OTP code", description = "Create a new account using a provided OTP code."
)
@SecurityRequirements()
public ResponseEntity<MessageResponse> registerUserWithOTP(@Valid @RequestBody SignupRequest signUpRequest,
@PathVariable String otpCode) {
if (maxAllowedUsers > 0 && userService.count() >= maxAllowedUsers) {
throw new MaximumNumberOfUsersReachedExceptions();
}
if (userService.existsByUsername(signUpRequest.username())) {
return ResponseEntity.badRequest().body(new MessageResponse("Error: Username is already taken!"));
}
if (otpService.checkExistenceAndExpirationThenRemove(otpCode)) {
userService.save(signUpRequest.username(), signUpRequest.password(), signUpRequest.email());
return ResponseEntity.ok(new MessageResponse("User registered successfully."));
}
final String errorMessage =
"Your OTP code is either invalid or has expired. Please ensure that you have entered the correct code and" +
" try again. If you believe this is an error, you can request a new OTP code by initiating the signup" +
" process again";
return ResponseEntity.badRequest().body(new MessageResponse(errorMessage));
}
@PostMapping("/refresh")
@Operation(
summary = "Refresh the authentication token", description = "Refresh the given authentication token."
)
public ResponseEntity<JwtTokenInfo> refreshAuthToken(HttpServletRequest httpServletRequest) {
final String expiredToken = jwtWebUtil.getJwtTokenFromRequest(httpServletRequest);
return ResponseEntity.ok().body(jwtWebUtil.refreshToken(expiredToken));
}
@PostMapping("/password/reset/{username}")
@Operation(
summary = "Initiates the password reset process",
description = "Start the process of resetting the user password"
)
public ResponseEntity<String> resetPassword(@PathVariable String username) throws EmailException {
if (!emailService.isEnabled()) {
final String temporaryPassword = temporaryPasswordService.generateNew(username);
logger.info("Temporary password for user {} is {}. Login with this temporary password.", username,
temporaryPassword
);
return ResponseEntity.ok(
"Password reset instructions have been printed into the server log. Contact the administrator in " +
"order to get it if needed.");
}
final User user = userService.get(username);
emailService.sendTemporaryPasswordMessage(username, user.getEmail());
return ResponseEntity.ok("Password reset instructions have been sent to your email address.");
}
}

View File

@ -1,232 +0,0 @@
package com.github.mdeluise.plantit.authentication;
import java.io.Serializable;
import java.util.Date;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.mdeluise.plantit.botanicalinfo.BotanicalInfo;
import com.github.mdeluise.plantit.diary.Diary;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
import com.github.mdeluise.plantit.notification.dispatcher.config.AbstractNotificationDispatcherConfig;
import com.github.mdeluise.plantit.plant.Plant;
import com.github.mdeluise.plantit.security.apikey.ApiKey;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotEmpty;
import org.hibernate.validator.constraints.Length;
@Entity(name = "user")
@Table(name = "application_users")
public class User implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(unique = true)
@NotEmpty
@Length(min = 3, max = 20)
private String username;
@NotEmpty
@Length(min = 8, max = 120)
@JsonProperty
private String password;
@Column(unique = true)
@NotEmpty
@Length(min = 5, max = 70)
private String email;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private Set<ApiKey> apiKeys = new HashSet<>();
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL)
private Set<Diary> diaries = new HashSet<>();
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL)
private Set<Plant> plants = new HashSet<>();
@OneToMany(mappedBy = "creator")
private Set<BotanicalInfo> botanicalInfos = new HashSet<>();
@ElementCollection(fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
@CollectionTable(
name = "notification_dispatchers",
joinColumns = @JoinColumn(name = "user_id")
)
@Column(name = "dispatcher_name")
private Set<NotificationDispatcherName> notificationDispatchers = new HashSet<>();
private Date lastLogin;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private Set<AbstractNotificationDispatcherConfig> configs = new HashSet<>();
public User(Long id, String username, String password) {
this.id = id;
this.username = username;
this.password = password;
}
public User() {
}
@JsonIgnore
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Set<ApiKey> getApiKeys() {
return apiKeys;
}
public void setApiKeys(Set<ApiKey> apiKeys) {
this.apiKeys = apiKeys;
}
public void addApiKey(ApiKey apiKey) {
apiKeys.add(apiKey);
}
public void removeApiKey(ApiKey apiKey) {
apiKeys.remove(apiKey);
}
public Set<Diary> getLogs() {
return diaries;
}
public void setLogs(Set<Diary> logs) {
this.diaries = logs;
}
public Set<Plant> getPlants() {
return plants;
}
public void setPlants(Set<Plant> plants) {
this.plants = plants;
}
public Set<BotanicalInfo> getBotanicalInfos() {
return botanicalInfos;
}
public void setBotanicalInfos(Set<BotanicalInfo> botanicalInfos) {
this.botanicalInfos = botanicalInfos;
}
public Set<Diary> getDiaries() {
return diaries;
}
public void setDiaries(Set<Diary> diaries) {
this.diaries = diaries;
}
public Set<NotificationDispatcherName> getNotificationDispatchers() {
return notificationDispatchers;
}
public void setNotificationDispatchers(Set<NotificationDispatcherName> notificationDispatchers) {
this.notificationDispatchers = notificationDispatchers;
}
public Set<AbstractNotificationDispatcherConfig> getConfigs() {
return configs;
}
public void setConfigs(Set<AbstractNotificationDispatcherConfig> configs) {
this.configs = configs;
}
public Date getLastLogin() {
return lastLogin;
}
public void setLastLogin(Date lastLogin) {
this.lastLogin = lastLogin;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
User user = (User) o;
return Objects.equals(id, user.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}

View File

@ -1,96 +0,0 @@
package com.github.mdeluise.plantit.authentication;
import javax.naming.AuthenticationException;
import com.github.mdeluise.plantit.authentication.payload.request.ChangeEmailRequest;
import com.github.mdeluise.plantit.authentication.payload.request.ChangePasswordRequest;
import com.github.mdeluise.plantit.authentication.payload.request.ChangeUsernameRequest;
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user")
@Tag(name = "User", description = "Endpoints for CRUD operations on users")
public class UserController {
private final UserService userService;
private final AuthenticatedUserService authenticatedUserService;
private final UserDTOConverter userDtoConverter;
@Autowired
public UserController(UserService userService, AuthenticatedUserService authenticatedUserService,
UserDTOConverter userDtoConverter) {
this.userService = userService;
this.authenticatedUserService = authenticatedUserService;
this.userDtoConverter = userDtoConverter;
}
@GetMapping
@Operation(
summary = "Get the User details", description = "Get the details of the authenticated User."
)
public ResponseEntity<UserDTO> getDetail() {
final User result = authenticatedUserService.getAuthenticatedUser();
return new ResponseEntity<>(userDtoConverter.convertToDTO(result), HttpStatus.OK);
}
@PutMapping("/_password")
@Operation(
summary = "Update a User's password", description = "Update the password of the authenticated User"
)
public ResponseEntity<String> updatePassword(@RequestBody ChangePasswordRequest changePasswordRequest)
throws AuthenticationException {
final Long idOfUserToUpdate = authenticatedUserService.getAuthenticatedUser().getId();
userService.updatePassword(idOfUserToUpdate, changePasswordRequest.currentPassword(),
changePasswordRequest.newPassword()
);
return ResponseEntity.ok("updated");
}
@DeleteMapping
@Operation(
summary = "Delete a single User", description = "Delete the authenticated User."
)
public void remove() {
final Long toRemove = authenticatedUserService.getAuthenticatedUser().getId();
userService.remove(toRemove);
}
@PutMapping("/_username")
@Operation(
summary = "Update a user's username", description = "Update the username of the authenticated User"
)
public ResponseEntity<String> updateUsername(@RequestBody ChangeUsernameRequest changeUsernameRequest)
throws AuthenticationException {
final Long idOfUserToUpdate = authenticatedUserService.getAuthenticatedUser().getId();
userService.updateUsername(
idOfUserToUpdate, changeUsernameRequest.password(), changeUsernameRequest.newUsername());
return ResponseEntity.ok("updated");
}
@PutMapping("/_email")
@Operation(
summary = "Update a user's email", description = "Update the email of the authenticated User"
)
public ResponseEntity<String> updateEmail(@RequestBody ChangeEmailRequest changeEmailRequest)
throws AuthenticationException {
final Long idOfUserToUpdate = authenticatedUserService.getAuthenticatedUser().getId();
userService.updateEmail(idOfUserToUpdate, changeEmailRequest.password(), changeEmailRequest.newEmail());
return ResponseEntity.ok("updated");
}
}

View File

@ -1,65 +0,0 @@
package com.github.mdeluise.plantit.authentication;
import java.util.Objects;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "User", description = "Represents a user of the system.")
public class UserDTO {
@Schema(description = "ID of the user.", accessMode = Schema.AccessMode.READ_ONLY)
private Long id;
@Schema(description = "Username of the user.")
private String username;
@Schema(description = "Email of the user.")
private String email;
public void setId(Long id) {
this.id = id;
}
public void setUsername(String username) {
this.username = username;
}
public Long getId() {
return id;
}
public String getUsername() {
return username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final UserDTO userDTO = (UserDTO) o;
return Objects.equals(id, userDTO.id) && Objects.equals(username, userDTO.username) &&
Objects.equals(email, userDTO.email);
}
@Override
public int hashCode() {
return Objects.hash(id, username, email);
}
}

View File

@ -1,27 +0,0 @@
package com.github.mdeluise.plantit.authentication;
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class UserDTOConverter extends AbstractDTOConverter<User, UserDTO> {
@Autowired
public UserDTOConverter(ModelMapper modelMapper) {
super(modelMapper);
}
@Override
public User convertFromDTO(UserDTO dto) {
return modelMapper.map(dto, User.class);
}
@Override
public UserDTO convertToDTO(User data) {
return modelMapper.map(data, UserDTO.class);
}
}

View File

@ -1,15 +0,0 @@
package com.github.mdeluise.plantit.authentication;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}

View File

@ -1,176 +0,0 @@
package com.github.mdeluise.plantit.authentication;
import java.util.List;
import java.util.Optional;
import javax.naming.AuthenticationException;
import com.github.mdeluise.plantit.exception.ResourceNotFoundException;
import com.github.mdeluise.plantit.notification.email.EmailException;
import com.github.mdeluise.plantit.notification.email.EmailService;
import com.github.mdeluise.plantit.notification.password.TemporaryPassword;
import com.github.mdeluise.plantit.notification.password.TemporaryPasswordService;
import jakarta.transaction.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder encoder;
private final EmailService emailService;
private final TemporaryPasswordService temporaryPasswordService;
private final Logger logger = LoggerFactory.getLogger(UserService.class);
@Autowired
public UserService(UserRepository userRepository, PasswordEncoder encoder,
EmailService emailService, TemporaryPasswordService temporaryPasswordService) {
this.userRepository = userRepository;
this.encoder = encoder;
this.emailService = emailService;
this.temporaryPasswordService = temporaryPasswordService;
}
public User get(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new ResourceNotFoundException("username", username));
}
public User get(Long id) {
return userRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
}
public List<User> getAll() {
return userRepository.findAll();
}
public User save(String username, String plainPassword, String email) {
final User user = new User();
user.setUsername(username);
user.setPassword(plainPassword);
user.setEmail(email);
return save(user);
}
@Transactional
public User updateInternal(Long id, User updatedUser) {
final User toUpdate = get(id);
toUpdate.setUsername(updatedUser.getUsername());
toUpdate.setLastLogin(updatedUser.getLastLogin());
return userRepository.save(toUpdate);
}
public void updatePassword(Long userId, String currentPassword, String newPassword) throws AuthenticationException {
final User toUpdate = get(userId);
if (!encoder.matches(currentPassword, toUpdate.getPassword())) {
final Optional<TemporaryPassword> optionalTemporaryPassword =
temporaryPasswordService.get(toUpdate.getUsername());
if (optionalTemporaryPassword.isEmpty() ||
!encoder.matches(currentPassword, optionalTemporaryPassword.get().getPassword())) {
logger.error(
"Error while updating password for user {}, incorrect current password.", toUpdate.getUsername());
throw new AuthenticationException("Current password does not match");
} else {
logger.debug("User {} updateInternal his password using a temporary password", toUpdate.getUsername());
}
}
if (newPassword != null && !newPassword.isBlank() && !newPassword.equals(toUpdate.getPassword())) {
final String encodedNewPassword = encoder.encode(newPassword);
toUpdate.setPassword(encodedNewPassword);
userRepository.save(toUpdate);
temporaryPasswordService.removeIfExists(toUpdate.getUsername());
logger.info("Password for user {} updated", toUpdate.getUsername());
if (emailService.isEnabled()) {
try {
emailService.sendPasswordChangeNotification(toUpdate.getUsername(), toUpdate.getEmail());
logger.info("Sent email to user in order to notify about the password change");
} catch (EmailException e) {
logger.error("Error while sending password change notification to user", e);
}
}
}
}
public void updateUsername(Long userId, String password, String newUsername) throws AuthenticationException {
final User toUpdate = get(userId);
if (!encoder.matches(password, toUpdate.getPassword())) {
logger.error("Error while updating username for user {}, incorrect current password.", toUpdate.getUsername());
throw new AuthenticationException("Incorrect password");
}
if (!newUsername.equals(toUpdate.getUsername()) && existsByUsername(newUsername)) {
logger.error("Error while updating username for user {}, new username already used.", toUpdate.getUsername());
throw new IllegalArgumentException("New username already used");
}
if (newUsername != null && !newUsername.isBlank() && !newUsername.equals(toUpdate.getUsername())) {
toUpdate.setUsername(newUsername);
userRepository.save(toUpdate);
}
}
public void updateEmail(Long userId, String password, String newEmail) throws AuthenticationException {
final User toUpdate = get(userId);
if (!encoder.matches(password, toUpdate.getPassword())) {
logger.error("Error while updating email for user {}, incorrect current password.", toUpdate.getUsername());
throw new AuthenticationException("Incorrect password");
}
if (!newEmail.equals(toUpdate.getUsername()) && existsByEmail(newEmail)) {
logger.error("Error while updating email for user {}, new email already used.", toUpdate.getUsername());
throw new IllegalArgumentException("New email already used");
}
if (newEmail != null && !newEmail.isBlank() && !newEmail.equals(toUpdate.getPassword())) {
toUpdate.setEmail(newEmail);
userRepository.save(toUpdate);
logger.info("Email for user {} updated", toUpdate.getUsername());
if (emailService.isEnabled()) {
try {
emailService.sendEmailChangeNotification(toUpdate.getUsername(), newEmail);
logger.info("Sent email to user in order to notify about the email change");
} catch (EmailException e) {
logger.error("Error while sending email change notification to user", e);
}
}
}
}
public void remove(Long id) {
userRepository.deleteById(id);
}
@Transactional
public User save(User entityToSave) {
entityToSave.setPassword(encoder.encode(entityToSave.getPassword()));
return userRepository.save(entityToSave);
}
public boolean existsByUsername(String username) {
return userRepository.existsByUsername(username);
}
public void removeAll() {
userRepository.deleteAll();
}
public long count() {
return userRepository.count();
}
public boolean existsByEmail(String email) {
return userRepository.existsByEmail(email);
}
}

View File

@ -1,10 +0,0 @@
package com.github.mdeluise.plantit.authentication.payload.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
@Schema(description = "Represents a request to change the user password.")
public record ChangeEmailRequest(
@Schema(description = "Password.", example = "password1") @NotBlank String password,
@Schema(description = "New email.", example = "foo@bar.com") @NotBlank String newEmail) {
}

View File

@ -1,10 +0,0 @@
package com.github.mdeluise.plantit.authentication.payload.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
@Schema(description = "Represents a request to change the user password.")
public record ChangePasswordRequest(
@Schema(description = "Current password.", example = "password1") @NotBlank String currentPassword,
@Schema(description = "New password.", example = "password2") @NotBlank String newPassword) {
}

View File

@ -1,10 +0,0 @@
package com.github.mdeluise.plantit.authentication.payload.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
@Schema(description = "Represents a request to change the user username.")
public record ChangeUsernameRequest(
@Schema(description = "Password.", example = "password1") @NotBlank String password,
@Schema(description = "New username.", example = "Username1") @NotBlank String newUsername) {
}

View File

@ -1,10 +0,0 @@
package com.github.mdeluise.plantit.authentication.payload.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
@Schema(description = "Represents a request to login into the system.")
public record LoginRequest(
@Schema(description = "Username to use to login.", example = "user") @NotBlank String username,
@Schema(description = "Password to use to login.", example = "user") @NotBlank String password) {
}

View File

@ -1,15 +0,0 @@
package com.github.mdeluise.plantit.authentication.payload.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Schema(description = "Represents a request to register into the system.")
public record SignupRequest(
@Schema(description = "Username to use to register.", example = "admin") @NotBlank @Size(min = 3, max = 20)
String username,
@Schema(description = "Username to use to register.", example = "admin") @NotBlank @Size(min = 6, max = 40)
String password,
@Schema(description = "Email to use to register.", example = "foo@bar.com") @NotBlank @Size(min = 5, max = 70)
String email) {
}

View File

@ -1,8 +0,0 @@
package com.github.mdeluise.plantit.authentication.payload.response;
import java.util.Date;
import com.github.mdeluise.plantit.security.jwt.JwtTokenInfo;
public record UserInfoResponse(long id, String username, String email, Date lastLogin, JwtTokenInfo jwt) {
}

View File

@ -1,225 +0,0 @@
package com.github.mdeluise.plantit.botanicalinfo;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.botanicalinfo.care.PlantCareInfo;
import com.github.mdeluise.plantit.image.BotanicalInfoImage;
import com.github.mdeluise.plantit.image.ImageTarget;
import com.github.mdeluise.plantit.plant.Plant;
import jakarta.persistence.CascadeType;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import jakarta.validation.constraints.NotNull;
@Entity
@Table(name = "botanical_infos")
public class BotanicalInfo implements Serializable, ImageTarget {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
// https://landscapeplants.oregonstate.edu/scientific-plant-names-binomial-nomenclature
@Transient
private String scientificName;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "synonyms")
@Column(name = "synonym_value")
private Set<String> synonyms = new HashSet<>();
private String family;
private String genus;
private String species;
@Embedded
private PlantCareInfo plantCareInfo = new PlantCareInfo();
@NotNull
@OneToMany(mappedBy = "botanicalInfo")
private Set<Plant> plants = new HashSet<>();
@OneToOne(mappedBy = "target", cascade = CascadeType.ALL)
private BotanicalInfoImage image;
@NotNull
@Enumerated(EnumType.STRING)
private BotanicalInfoCreator creator;
@ManyToOne
@JoinColumn(name = "user_creator_id")
private User userCreator;
private String externalId;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getScientificName() {
return species;
}
public Set<String> getSynonyms() {
return synonyms;
}
public void setSynonyms(Set<String> synonyms) {
this.synonyms = synonyms;
}
public String getFamily() {
return family;
}
public void setFamily(String family) {
this.family = family;
}
public String getGenus() {
return genus;
}
public void setGenus(String genus) {
this.genus = genus;
}
public String getSpecies() {
return species;
}
public void setSpecies(String species) {
this.species = species;
}
public PlantCareInfo getPlantCareInfo() {
// see https://coderanch.com/t/629485/databases/columns-Embedded-field-NULL-JPA
return plantCareInfo != null ? plantCareInfo : new PlantCareInfo();
}
public void setPlantCareInfo(PlantCareInfo plantCareInfo) {
this.plantCareInfo = plantCareInfo;
}
public Set<Plant> getPlants() {
return plants;
}
public void setPlants(Set<Plant> plants) {
this.plants = plants;
}
public BotanicalInfoImage getImage() {
return image;
}
public void setImage(BotanicalInfoImage image) {
this.image = image;
}
public BotanicalInfoCreator getCreator() {
return creator;
}
public void setCreator(BotanicalInfoCreator creator) {
this.creator = creator;
}
public User getUserCreator() {
return userCreator;
}
public void setUserCreator(User userCreator) {
this.userCreator = userCreator;
if (userCreator != null) {
this.creator = BotanicalInfoCreator.USER;
this.externalId = null;
}
}
public String getExternalId() {
return externalId;
}
public void setExternalId(String externalId) {
this.externalId = externalId;
}
public boolean isAccessibleToUser(User user) {
return creator != BotanicalInfoCreator.USER || userCreator.equals(user);
}
public boolean isUserCreated() {
return creator == BotanicalInfoCreator.USER;
}
public boolean isPlantCareEmpty() {
return getPlantCareInfo().isAllNull();
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final BotanicalInfo that = (BotanicalInfo) o;
return fieldEquals(that);
}
@Override
public int hashCode() {
return Objects.hash(scientificName, family, genus, species);
}
@SuppressWarnings("BooleanExpressionComplexity") //FIXME
private boolean fieldEquals(BotanicalInfo that) {
return Objects.equals(scientificName, that.scientificName) && Objects.equals(family, that.family) &&
Objects.equals(genus, that.genus) && Objects.equals(species, that.species) &&
Objects.equals(externalId, that.externalId);
}
}

View File

@ -1,118 +0,0 @@
package com.github.mdeluise.plantit.botanicalinfo;
import java.net.MalformedURLException;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import com.github.mdeluise.plantit.common.MessageResponse;
import com.github.mdeluise.plantit.plantinfo.PlantInfoExtractorFacade;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/botanical-info")
@Tag(name = "Botanical Info", description = "Endpoints for operations on botanical info.")
public class BotanicalInfoController {
private final BotanicalInfoDTOConverter botanicalInfoDtoConverter;
private final BotanicalInfoService botanicalInfoService;
private final PlantInfoExtractorFacade plantInfoExtractorFacade;
@Autowired
public BotanicalInfoController(BotanicalInfoDTOConverter botanicalInfoDtoConverter,
BotanicalInfoService botanicalInfoService,
PlantInfoExtractorFacade plantInfoExtractorFacade) {
this.botanicalInfoDtoConverter = botanicalInfoDtoConverter;
this.botanicalInfoService = botanicalInfoService;
this.plantInfoExtractorFacade = plantInfoExtractorFacade;
}
@GetMapping
@Operation(summary = "Get all the botanical info", description = "Get all the botanical info.")
public ResponseEntity<List<BotanicalInfoDTO>> getAll(
@RequestParam(defaultValue = "5", required = false) Integer size) {
final Collection<BotanicalInfo> result = plantInfoExtractorFacade.getAll(size);
final List<BotanicalInfoDTO> convertedResult =
result.stream().map(botanicalInfoDtoConverter::convertToDTO).collect(Collectors.toList());
return ResponseEntity.ok(convertedResult);
}
@GetMapping("/partial/{partialScientificName}")
@Operation(
summary = "Get all the botanical info matching the provided partial scientific name",
description = "Get all the botanical info, according to the `partialScientificName` parameter"
)
public ResponseEntity<List<BotanicalInfoDTO>> getPartial(
@RequestParam(defaultValue = "5", required = false) Integer size,
@PathVariable String partialScientificName) {
final Collection<BotanicalInfo> result = plantInfoExtractorFacade.extractPlants(
partialScientificName, size);
final List<BotanicalInfoDTO> convertedResult =
result.stream().map(botanicalInfoDtoConverter::convertToDTO).collect(Collectors.toList());
return ResponseEntity.ok(convertedResult);
}
@GetMapping("/{botanicalInfoId}/_count")
@Operation(
summary = "Count the existing plant for a botanical info.",
description = "Count the existing plants with the botanical info which id is `botanicalInfoId`."
)
public ResponseEntity<Integer> count(@PathVariable Long botanicalInfoId) {
return ResponseEntity.ok(botanicalInfoService.countPlants(botanicalInfoId));
}
@GetMapping("/_count")
@Operation(summary = "Count the botanical info.", description = "Count all the botanical info.")
public ResponseEntity<Long> countAll() {
return ResponseEntity.ok(botanicalInfoService.count());
}
@PostMapping
@Operation(summary = "Save a new botanical info.", description = "Save a new botanical info.")
public ResponseEntity<BotanicalInfoDTO> save(@RequestBody BotanicalInfoDTO toSave) throws MalformedURLException {
final BotanicalInfo result = botanicalInfoService.save(botanicalInfoDtoConverter.convertFromDTO(toSave));
return ResponseEntity.ok(botanicalInfoDtoConverter.convertToDTO(result));
}
@GetMapping("/{id}")
@Operation(summary = "Get a botanical info.", description = "Get the botanical info with the specified `id`.")
public ResponseEntity<BotanicalInfoDTO> get(@PathVariable Long id) {
final BotanicalInfo result = botanicalInfoService.get(id);
return ResponseEntity.ok(botanicalInfoDtoConverter.convertToDTO(result));
}
@PutMapping("/{id}")
@Operation(summary = "Update a botanical info.", description = "Update the botanical info with the specified `id`.")
public ResponseEntity<BotanicalInfoDTO> update(@PathVariable Long id, @RequestBody BotanicalInfoDTO updated)
throws MalformedURLException {
final BotanicalInfo result = botanicalInfoService.update(id, botanicalInfoDtoConverter.convertFromDTO(updated));
return ResponseEntity.ok(botanicalInfoDtoConverter.convertToDTO(result));
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete a botanical info.", description = "Delete the botanical info with the specified `id`.")
public ResponseEntity<MessageResponse> remove(@PathVariable Long id) {
botanicalInfoService.delete(id);
return ResponseEntity.ok(new MessageResponse("Success"));
}
}

View File

@ -1,7 +0,0 @@
package com.github.mdeluise.plantit.botanicalinfo;
public enum BotanicalInfoCreator {
USER,
TREFLE,
FLORA_CODEX,
}

View File

@ -1,187 +0,0 @@
package com.github.mdeluise.plantit.botanicalinfo;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import com.github.mdeluise.plantit.botanicalinfo.care.PlantCareInfoDTO;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "Botanical info", description = "Represents a plant's botanical info.")
public class BotanicalInfoDTO {
@Schema(description = "ID of the botanical info.", accessMode = Schema.AccessMode.READ_ONLY)
private Long id;
@Schema(description = "Scientific name of the botanical info.", accessMode = Schema.AccessMode.READ_ONLY)
private String scientificName;
@Schema(description = "Synonyms of the botanical info.")
private Set<String> synonyms = new HashSet<>();
@Schema(description = "Family of the botanical info.")
private String family;
@Schema(description = "Genus of the botanical info.")
private String genus;
@Schema(description = "Species of the botanical info.")
private String species;
@Schema(description = "Care information of the botanical info.")
private PlantCareInfoDTO plantCareInfo;
@Schema(description = "ID of the botanical info image.", accessMode = Schema.AccessMode.READ_ONLY)
private String imageId;
@Schema(description = "URL of the botanical info image.")
private String imageUrl;
@Schema(description = "Content of the botanical info image.", accessMode = Schema.AccessMode.WRITE_ONLY)
private byte[] imageContent;
@Schema(description = "Content type of the botanical info image.", accessMode = Schema.AccessMode.WRITE_ONLY)
private String imageContentType;
@Schema(description = "Creator of the botanical info")
private String creator;
@Schema(description = "ID of the botanical info in the creator service")
private String externalId;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getScientificName() {
return scientificName;
}
public void setScientificName(String scientificName) {
this.scientificName = scientificName;
}
public Set<String> getSynonyms() {
return synonyms;
}
public void setSynonyms(Set<String> synonyms) {
this.synonyms = synonyms;
}
public String getFamily() {
return family;
}
public void setFamily(String family) {
this.family = family;
}
public String getGenus() {
return genus;
}
public void setGenus(String genus) {
this.genus = genus;
}
public String getSpecies() {
return species;
}
public void setSpecies(String species) {
this.species = species;
}
public PlantCareInfoDTO getPlantCareInfo() {
return plantCareInfo;
}
public void setPlantCareInfo(PlantCareInfoDTO plantCareInfo) {
this.plantCareInfo = plantCareInfo;
}
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public String getImageId() {
return imageId;
}
public void setImageId(String imageId) {
this.imageId = imageId;
}
public byte[] getImageContent() {
return imageContent;
}
public void setImageContent(byte[] imageContent) {
this.imageContent = imageContent;
}
public String getImageContentType() {
return imageContentType;
}
public void setImageContentType(String imageContentType) {
this.imageContentType = imageContentType;
}
public void setCreator(String creator) {
this.creator = creator;
}
public String getCreator() {
return creator;
}
public String getExternalId() {
return externalId;
}
public void setExternalId(String externalId) {
this.externalId = externalId;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final BotanicalInfoDTO that = (BotanicalInfoDTO) o;
return Objects.equals(id, that.id) || Objects.equals(species, that.species);
}
@Override
public int hashCode() {
return Objects.hash(id, species);
}
}

View File

@ -1,42 +0,0 @@
package com.github.mdeluise.plantit.botanicalinfo;
import com.github.mdeluise.plantit.botanicalinfo.care.PlantCareInfo;
import com.github.mdeluise.plantit.botanicalinfo.care.PlantCareInfoDTO;
import com.github.mdeluise.plantit.botanicalinfo.care.PlantCareInfoDTOConverter;
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class BotanicalInfoDTOConverter extends AbstractDTOConverter<BotanicalInfo, BotanicalInfoDTO> {
private final PlantCareInfoDTOConverter plantCareInfoDtoConverter;
@Autowired
public BotanicalInfoDTOConverter(ModelMapper modelMapper, PlantCareInfoDTOConverter plantCareInfoDtoConverter) {
super(modelMapper);
this.plantCareInfoDtoConverter = plantCareInfoDtoConverter;
}
@Override
public BotanicalInfo convertFromDTO(BotanicalInfoDTO dto) {
final BotanicalInfo result = modelMapper.map(dto, BotanicalInfo.class);
final PlantCareInfo plantCareInfo = plantCareInfoDtoConverter.convertFromDTO(dto.getPlantCareInfo());
result.setPlantCareInfo(plantCareInfo);
if (dto.getImageContentType() != null) {
result.getImage().setContentType(dto.getImageContentType());
}
return result;
}
@Override
public BotanicalInfoDTO convertToDTO(BotanicalInfo data) {
final BotanicalInfoDTO result = modelMapper.map(data, BotanicalInfoDTO.class);
final PlantCareInfoDTO plantCareInfoDTO = plantCareInfoDtoConverter.convertToDTO(data.getPlantCareInfo());
result.setPlantCareInfo(plantCareInfoDTO);
return result;
}
}

View File

@ -1,36 +0,0 @@
package com.github.mdeluise.plantit.botanicalinfo;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import com.github.mdeluise.plantit.authentication.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface BotanicalInfoRepository extends JpaRepository<BotanicalInfo, Long> {
List<BotanicalInfo> findBySpeciesContainsIgnoreCase(String partialScientificName);
@Query("SELECT b FROM BotanicalInfo b JOIN b.synonyms s WHERE LOWER(s) LIKE LOWER(:synonym)")
List<BotanicalInfo> findBySynonymsContainsIgnoreCase(@Param("synonym") String synonym);
default List<BotanicalInfo> getBySpeciesOrSynonym(String search) {
final Set<BotanicalInfo> result = new HashSet<>();
result.addAll(findBySpeciesContainsIgnoreCase(search));
result.addAll(findBySynonymsContainsIgnoreCase(search));
return result.stream().toList();
}
List<BotanicalInfo> findAll();
List<BotanicalInfo> findAllBySpecies(String species);
List<BotanicalInfo> findAllByCreatorAndExternalId(BotanicalInfoCreator creator, String externalId);
boolean existsBySpeciesAndCreatorAndUserCreator(String species, BotanicalInfoCreator creator, User userCreator);
Optional<BotanicalInfo> findBySpeciesAndCreatorAndUserCreator(String species, BotanicalInfoCreator creator, User userCreator);
}

View File

@ -1,318 +0,0 @@
package com.github.mdeluise.plantit.botanicalinfo;
import java.net.MalformedURLException;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
import com.github.mdeluise.plantit.exception.ResourceNotFoundException;
import com.github.mdeluise.plantit.exception.UnauthorizedException;
import com.github.mdeluise.plantit.image.BotanicalInfoImage;
import com.github.mdeluise.plantit.image.EntityImage;
import com.github.mdeluise.plantit.image.storage.ImageStorageService;
import com.github.mdeluise.plantit.plant.Plant;
import com.github.mdeluise.plantit.plant.PlantRepository;
import jakarta.transaction.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@Service
public class BotanicalInfoService {
private final AuthenticatedUserService authenticatedUserService;
private final BotanicalInfoRepository botanicalInfoRepository;
private final ImageStorageService imageStorageService;
private final PlantRepository plantRepository;
private final Logger logger = LoggerFactory.getLogger(BotanicalInfoService.class);
@Autowired
public BotanicalInfoService(AuthenticatedUserService authenticatedUserService,
BotanicalInfoRepository botanicalInfoRepository,
ImageStorageService imageStorageService, PlantRepository plantRepository) {
this.authenticatedUserService = authenticatedUserService;
this.botanicalInfoRepository = botanicalInfoRepository;
this.imageStorageService = imageStorageService;
this.plantRepository = plantRepository;
}
public Set<BotanicalInfo> getByPartialScientificName(String partialScientificName, int size) {
logger.debug(String.format("Search for DB saved botanical info matching '%s' scientific name (max size %s)",
partialScientificName, size
));
final List<BotanicalInfo> result = botanicalInfoRepository
.getBySpeciesOrSynonym(partialScientificName).stream()
.filter(botanicalInfo -> botanicalInfo.isAccessibleToUser(
authenticatedUserService.getAuthenticatedUser()))
.limit(size)
.toList();
return new HashSet<>(result.subList(0, Math.min(size, result.size())));
}
public Set<BotanicalInfo> getAll(int size) {
logger.debug(String.format("Search for DB saved botanical info (max size %s)", size));
final List<BotanicalInfo> result = botanicalInfoRepository.findAll().stream().filter(
botanicalInfo -> botanicalInfo.isAccessibleToUser(authenticatedUserService.getAuthenticatedUser()))
.limit(size).toList();
return new HashSet<>(result);
}
public int countPlants(Long botanicalInfoId) {
return botanicalInfoRepository.findById(botanicalInfoId)
.orElseThrow(() -> new ResourceNotFoundException(botanicalInfoId)).getPlants()
.stream()
.filter(
pl -> pl.getOwner().equals(authenticatedUserService.getAuthenticatedUser()))
.collect(Collectors.toSet())
.size();
}
public long count() {
return plantRepository.findAllByOwner(authenticatedUserService.getAuthenticatedUser(), Pageable.unpaged())
.getContent()
.stream()
.map(entity -> entity.getBotanicalInfo().getId())
.collect(Collectors.toSet()).size();
}
@CacheEvict(cacheNames = "botanical-info", allEntries = true)
@Transactional
public BotanicalInfo save(BotanicalInfo toSave) throws MalformedURLException {
checkSpeciesNotDuplicatedForSameCreator(toSave);
if (toSave.isUserCreated()) {
toSave.setUserCreator(authenticatedUserService.getAuthenticatedUser());
}
removeDuplicatedCaseInsensitiveSynonyms(toSave);
final BotanicalInfoImage imageToSave = toSave.getImage();
toSave.setImage(null);
final BotanicalInfo result = botanicalInfoRepository.save(toSave);
try {
linkNewImage(imageToSave, result);
} catch (MalformedURLException e) {
logger.error("Error while setting the image for the new species", e);
throw e;
}
return result;
}
private void linkNewImage(BotanicalInfoImage toSave, BotanicalInfo toUpdate) throws MalformedURLException {
if (toSave == null) {
logger.debug("Species {} does not have a linked image", toUpdate.getSpecies());
return;
}
if (toSave.getContent() != null) {
logger.debug("Species {} have a linked image's content, updating...", toUpdate.getSpecies());
final BotanicalInfoImage saved =
(BotanicalInfoImage) imageStorageService.saveBotanicalInfoThumbnailImage(toSave.getContent(), toSave.getContentType(), toUpdate);
toUpdate.setImage(saved);
} else if (toSave.getUrl() != null && !toSave.getUrl().isBlank()) {
logger.debug("Species {} have a linked image's url, updating...", toUpdate.getSpecies());
final BotanicalInfoImage saved = (BotanicalInfoImage) imageStorageService.save(toSave.getUrl(), toUpdate);
toUpdate.setImage(saved);
} else if (toSave.getId() != null && !toSave.getId().isBlank()) {
logger.debug("Species {} have a linked image's id, updating...", toUpdate.getSpecies());
final BotanicalInfoImage entityImage = (BotanicalInfoImage) imageStorageService.get(toSave.getId());
toUpdate.setImage(entityImage);
botanicalInfoRepository.save(toUpdate);
}
}
private void checkSpeciesNotDuplicatedForSameCreator(BotanicalInfo toSave) {
final String species = toSave.getSpecies();
final BotanicalInfoCreator creator = toSave.getCreator();
final User userCreator = toSave.getUserCreator();
if (botanicalInfoRepository.existsBySpeciesAndCreatorAndUserCreator(species, creator, userCreator)) {
throw new IllegalArgumentException(
String.format("Species %s with creator %s already exists", species, creator));
}
}
public BotanicalInfo get(Long id) {
final BotanicalInfo result =
botanicalInfoRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
if (!result.isAccessibleToUser(authenticatedUserService.getAuthenticatedUser())) {
throw new UnauthorizedException();
}
return result;
}
public BotanicalInfo getInternal(String scientificName) {
return botanicalInfoRepository.getBySpeciesOrSynonym(scientificName).get(0);
}
@Transactional
@CacheEvict(cacheNames = {"botanical-info", "plants"}, allEntries = true)
public void delete(Long id) {
final BotanicalInfo toDelete = get(id);
if (toDelete.getImage() != null) {
imageStorageService.remove(toDelete.getImage().getId());
}
// cascade will not remove plant images
toDelete.getPlants().forEach(pl -> {
pl.getImages().forEach(im -> imageStorageService.remove(im.getId()));
plantRepository.delete(pl);
});
botanicalInfoRepository.deleteById(id);
}
@Transactional
public void deleteInternal(BotanicalInfo toDelete) {
imageStorageService.removeAll();
botanicalInfoRepository.delete(toDelete);
}
@Transactional
public void deleteAll() {
botanicalInfoRepository.findAll().forEach(this::deleteInternal);
}
@Transactional
@CacheEvict(cacheNames = {"botanical-info", "plants"}, allEntries = true)
public BotanicalInfo update(Long id, BotanicalInfo updated) throws MalformedURLException {
final BotanicalInfo toUpdate = get(id);
if (!toUpdate.isAccessibleToUser(authenticatedUserService.getAuthenticatedUser())) {
throw new UnauthorizedException();
}
final String species = updated.getSpecies();
final BotanicalInfoCreator creator = updated.getCreator();
final User userCreator = updated.getUserCreator();
final Optional<BotanicalInfo> optionalDuplicatedSpecies =
botanicalInfoRepository.findBySpeciesAndCreatorAndUserCreator(species, updated.getCreator(), userCreator);
final boolean existDistinctDuplicate =
optionalDuplicatedSpecies.isPresent() && !optionalDuplicatedSpecies.get().getId().equals(updated.getId());
if (existDistinctDuplicate) {
throw new IllegalArgumentException(
String.format("Species %s with creator %s already exists", species, creator));
}
final BotanicalInfoImage updatedImage = updated.getImage();
updated.setImage(toUpdate.getImage());
if (toUpdate.isUserCreated()) {
final BotanicalInfo saved = updateUserCreatedBotanicalInfo(updated, toUpdate);
return updateImage(updatedImage, saved);
}
logger.info("Trying to updateInternal a NON custom botanical info. Creating custom copy...");
final BotanicalInfo userCreatedCopy = createUserCreatedCopy(toUpdate);
removeDuplicatedCaseInsensitiveSynonyms(updated);
final BotanicalInfo result = updateUserCreatedBotanicalInfo(updated, userCreatedCopy);
try {
return updateImage(updatedImage, result);
} catch (MalformedURLException e) {
logger.error("Error while updating image for species {}", updated.getSpecies(), e);
throw e;
}
}
private BotanicalInfo updateImage(BotanicalInfoImage updatedImage, BotanicalInfo toUpdate) throws MalformedURLException {
final BotanicalInfoImage oldImage = toUpdate.getImage();
if (oldImage != updatedImage) { // both not null
linkNewImage(updatedImage, toUpdate);
if (oldImage != null && (updatedImage == null || !updatedImage.equals(oldImage))) {
imageStorageService.remove(oldImage.getId());
}
}
return toUpdate;
}
private BotanicalInfo updateUserCreatedBotanicalInfo(BotanicalInfo updated, BotanicalInfo toUpdate) {
toUpdate.setUserCreator(authenticatedUserService.getAuthenticatedUser());
toUpdate.setFamily(updated.getFamily());
toUpdate.setGenus(updated.getGenus());
toUpdate.setSpecies(updated.getSpecies());
toUpdate.setPlantCareInfo(updated.getPlantCareInfo());
toUpdate.setSynonyms(new HashSet<>(updated.getSynonyms()));
return botanicalInfoRepository.save(toUpdate);
}
private BotanicalInfo createUserCreatedCopy(BotanicalInfo toCopy) throws MalformedURLException {
BotanicalInfo userCreatedCopy = new BotanicalInfo();
userCreatedCopy.setUserCreator(authenticatedUserService.getAuthenticatedUser());
userCreatedCopy.setFamily(toCopy.getFamily());
userCreatedCopy.setGenus(toCopy.getGenus());
userCreatedCopy.setSpecies(toCopy.getSpecies());
userCreatedCopy.setPlantCareInfo(toCopy.getPlantCareInfo());
userCreatedCopy.setSynonyms(new HashSet<>(toCopy.getSynonyms()));
userCreatedCopy = save(userCreatedCopy);
if (toCopy.getImage() != null) {
logger.debug("Copy botanical info thumbnail...");
final EntityImage toClone = imageStorageService.get(toCopy.getImage().getId());
final EntityImage clonedEntityImage = imageStorageService.clone(toClone.getId(), userCreatedCopy);
userCreatedCopy.setImage((BotanicalInfoImage) clonedEntityImage);
}
logger.debug("Move botanical info plants to the custom copy...");
final BotanicalInfo finalUserCreatedCopy = userCreatedCopy;
toCopy.getPlants().forEach(pl -> {
if (pl.getOwner() != authenticatedUserService.getAuthenticatedUser()) {
return;
}
final Plant toChangeBotanicalInfo =
plantRepository.findById(pl.getId()).orElseThrow(() -> new ResourceNotFoundException(pl.getId()));
toChangeBotanicalInfo.setBotanicalInfo(finalUserCreatedCopy);
plantRepository.save(toChangeBotanicalInfo);
});
return userCreatedCopy;
}
public boolean existsSpecies(String species) {
return botanicalInfoRepository.findAllBySpecies(species).stream().anyMatch(
botanicalInfo -> botanicalInfo.isAccessibleToUser(authenticatedUserService.getAuthenticatedUser()));
}
public boolean existsExternalId(BotanicalInfoCreator creator, String externalId) {
return botanicalInfoRepository.findAllByCreatorAndExternalId(creator, externalId).stream().anyMatch(
botanicalInfo -> botanicalInfo.isAccessibleToUser(authenticatedUserService.getAuthenticatedUser()));
}
private void removeDuplicatedCaseInsensitiveSynonyms(BotanicalInfo botanicalInfo) {
final Set<String> newSynonyms = new HashSet<>();
botanicalInfo.getSynonyms().forEach(synonym -> {
if (!containsCaseInsensitive(newSynonyms, synonym)) {
newSynonyms.add(synonym);
}
});
botanicalInfo.setSynonyms(newSynonyms);
}
private boolean containsCaseInsensitive(Set<String> set, String value) {
for (String str : set) {
if (str.equalsIgnoreCase(value)) {
return true;
}
}
return false;
}
}

View File

@ -1,86 +0,0 @@
package com.github.mdeluise.plantit.botanicalinfo.care;
import java.io.Serializable;
import java.util.Objects;
import java.util.stream.Stream;
import jakarta.persistence.Embeddable;
@Embeddable
public class PlantCareInfo implements Serializable {
private Integer light;
private Integer humidity;
private Double minTemp;
private Double maxTemp;
private Double phMax;
private Double phMin;
public PlantCareInfo() {
}
public Integer getLight() {
return light;
}
public void setLight(Integer light) {
this.light = light;
}
public Integer getHumidity() {
return humidity;
}
public void setHumidity(Integer humidity) {
this.humidity = humidity;
}
public Double getMinTemp() {
return minTemp;
}
public void setMinTemp(Double minTemp) {
this.minTemp = minTemp;
}
public Double getMaxTemp() {
return maxTemp;
}
public void setMaxTemp(Double maxTemp) {
this.maxTemp = maxTemp;
}
public Double getPhMax() {
return phMax;
}
public void setPhMax(Double phMax) {
this.phMax = phMax;
}
public Double getPhMin() {
return phMin;
}
public void setPhMin(Double phMi) {
this.phMin = phMi;
}
public boolean isAllNull() {
return Stream.of(light, humidity, minTemp, maxTemp, phMin, phMax).allMatch(Objects::isNull);
}
}

View File

@ -1,91 +0,0 @@
package com.github.mdeluise.plantit.botanicalinfo.care;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "Plant care info", description = "Represents a plant's care info.")
public class PlantCareInfoDTO {
@Schema(description = "Light requirement")
private Integer light;
@Schema(description = "Humidity requirement")
private Integer humidity;
@Schema(description = "Minimum temperature requirement")
private Double minTemp;
@Schema(description = "Maximum temperature requirement")
private Double maxTemp;
@Schema(description = "Maximum PH requirement")
private Double phMax;
@Schema(description = "Minimum PH requirement")
private Double phMin;
@Schema(description = "Are all fields null?", accessMode = Schema.AccessMode.READ_ONLY)
private boolean allNull;
public Integer getLight() {
return light;
}
public void setLight(Integer light) {
this.light = light;
}
public Integer getHumidity() {
return humidity;
}
public void setHumidity(Integer humidity) {
this.humidity = humidity;
}
public Double getMinTemp() {
return minTemp;
}
public void setMinTemp(Double minTemp) {
this.minTemp = minTemp;
}
public Double getMaxTemp() {
return maxTemp;
}
public void setMaxTemp(Double maxTemp) {
this.maxTemp = maxTemp;
}
public Double getPhMax() {
return phMax;
}
public void setPhMax(Double phMax) {
this.phMax = phMax;
}
public Double getPhMin() {
return phMin;
}
public void setPhMin(Double phMin) {
this.phMin = phMin;
}
public boolean isAllNull() {
return allNull;
}
public void setAllNull(boolean allNull) {
this.allNull = allNull;
}
}

View File

@ -1,27 +0,0 @@
package com.github.mdeluise.plantit.botanicalinfo.care;
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Component;
@Component
public class PlantCareInfoDTOConverter extends AbstractDTOConverter<PlantCareInfo, PlantCareInfoDTO> {
public PlantCareInfoDTOConverter(ModelMapper modelMapper) {
super(modelMapper);
}
@Override
public PlantCareInfo convertFromDTO(PlantCareInfoDTO dto) {
if (dto == null) {
return new PlantCareInfo();
}
return modelMapper.map(dto, PlantCareInfo.class);
}
@Override
public PlantCareInfoDTO convertToDTO(PlantCareInfo data) {
return modelMapper.map(data, PlantCareInfoDTO.class);
}
}

View File

@ -1,17 +0,0 @@
package com.github.mdeluise.plantit.common;
import org.modelmapper.ModelMapper;
public abstract class AbstractDTOConverter<T, D> {
protected final ModelMapper modelMapper;
public AbstractDTOConverter(ModelMapper modelMapper) {
this.modelMapper = modelMapper;
}
public abstract T convertFromDTO(D dto);
public abstract D convertToDTO(T data);
}

View File

@ -1,28 +0,0 @@
package com.github.mdeluise.plantit.common;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.authentication.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@Component
public class AuthenticatedUserService {
private final UserService userService;
@Autowired
public AuthenticatedUserService(UserService userService) {
this.userService = userService;
}
public User getAuthenticatedUser() {
final SecurityContext context = SecurityContextHolder.getContext();
final Authentication authentication = context.getAuthentication();
final String username = authentication.getName();
return userService.get(username);
}
}

View File

@ -1,24 +0,0 @@
package com.github.mdeluise.plantit.common;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "API Response", description = "Represents a response from an API")
public class MessageResponse {
@Schema(description = "Message of the response", accessMode = Schema.AccessMode.READ_ONLY)
private String message;
public MessageResponse(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}

View File

@ -1,97 +0,0 @@
package com.github.mdeluise.plantit.diary;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.diary.entry.DiaryEntry;
import com.github.mdeluise.plantit.plant.Plant;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
@Entity
@Table(name = "diaries")
public class Diary implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@OneToOne
@JoinColumn(name = "target_id", nullable = false)
private Plant target;
@NotNull
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private User owner;
@OneToMany(mappedBy = "diary", cascade = CascadeType.ALL)
private Set<DiaryEntry> entries = new HashSet<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Plant getTarget() {
return target;
}
public void setTarget(Plant target) {
this.target = target;
}
public User getOwner() {
return owner;
}
public void setOwner(User owner) {
this.owner = owner;
}
public Set<DiaryEntry> getEntries() {
return entries;
}
public void setEntries(Set<DiaryEntry> entries) {
this.entries = entries;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final Diary diary = (Diary) o;
return Objects.equals(id, diary.id) || Objects.equals(target, diary.target);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}

View File

@ -1,55 +0,0 @@
package com.github.mdeluise.plantit.diary;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "Diary", description = "Represents a diary.")
public class DiaryDTO {
@Schema(description = "ID of the diary.", accessMode = Schema.AccessMode.READ_ONLY)
private Long id;
@Schema(description = "Target ID of the diary.")
private Long targetId;
@Schema(description = "Owner ID of the diary.")
private Long userId;
@Schema(description = "Number of entities in the diary.", accessMode = Schema.AccessMode.READ_ONLY)
private Long entriesCount;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTargetId() {
return targetId;
}
public void setTargetId(Long targetId) {
this.targetId = targetId;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getEntriesCount() {
return entriesCount;
}
public void setEntriesCount(Long entriesCount) {
this.entriesCount = entriesCount;
}
}

View File

@ -1,28 +0,0 @@
package com.github.mdeluise.plantit.diary;
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class DiaryDTOConverter extends AbstractDTOConverter<Diary, DiaryDTO> {
@Autowired
public DiaryDTOConverter(ModelMapper modelMapper) {
super(modelMapper);
}
@Override
public Diary convertFromDTO(DiaryDTO dto) {
return modelMapper.map(dto, Diary.class);
}
@Override
public DiaryDTO convertToDTO(Diary data) {
final DiaryDTO result = modelMapper.map(data, DiaryDTO.class);
result.setEntriesCount((long) data.getEntries().size());
return result;
}
}

View File

@ -1,15 +0,0 @@
package com.github.mdeluise.plantit.diary;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.plant.Plant;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface DiaryRepository extends JpaRepository<Diary, Long> {
Page<Diary> findAllByOwner(User user, Pageable pageable);
Optional<Diary> findByOwnerAndTarget(User user, Plant plant);
}

View File

@ -1,38 +0,0 @@
package com.github.mdeluise.plantit.diary;
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
import com.github.mdeluise.plantit.exception.ResourceNotFoundException;
import com.github.mdeluise.plantit.plant.Plant;
import com.github.mdeluise.plantit.plant.PlantService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@Service
public class DiaryService {
private final AuthenticatedUserService authenticatedUserService;
private final DiaryRepository diaryRepository;
private final PlantService plantService;
@Autowired
protected DiaryService(AuthenticatedUserService authenticatedUserService, DiaryRepository diaryRepository,
PlantService plantService) {
this.authenticatedUserService = authenticatedUserService;
this.diaryRepository = diaryRepository;
this.plantService = plantService;
}
public Page<Diary> getAll(Pageable pageable) {
return diaryRepository.findAllByOwner(authenticatedUserService.getAuthenticatedUser(), pageable);
}
public Diary get(Long targetId) {
final Plant plant = plantService.get(targetId);
return diaryRepository.findByOwnerAndTarget(authenticatedUserService.getAuthenticatedUser(), plant)
.orElseThrow(() -> new ResourceNotFoundException(targetId));
}
}

View File

@ -1,82 +0,0 @@
package com.github.mdeluise.plantit.diary.entry;
import com.github.mdeluise.plantit.diary.Diary;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import java.util.Date;
@Entity
@Table(name = "diary_entries")
public class DiaryEntry {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@NotNull
@Enumerated(EnumType.STRING)
private DiaryEntryType type;
private String note;
@NotNull
private Date date;
@ManyToOne
@JoinColumn(name = "diary_id", nullable = false)
private Diary diary;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public DiaryEntryType getType() {
return type;
}
public void setType(DiaryEntryType type) {
this.type = type;
}
public String getNote() {
return note;
}
public void setNote(String note) {
this.note = note;
}
public Date getDate() {
return date;
}
public void setDate(Date date) {
this.date = date;
}
public Diary getDiary() {
return diary;
}
public void setDiary(Diary diary) {
this.diary = diary;
}
}

View File

@ -1,117 +0,0 @@
package com.github.mdeluise.plantit.diary.entry;
import java.util.Collection;
import java.util.List;
import com.github.mdeluise.plantit.common.MessageResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/diary/entry")
public class DiaryEntryController {
private final DiaryEntryService diaryEntryService;
private final DiaryEntryDTOConverter diaryEntryDtoConverter;
@Autowired
public DiaryEntryController(DiaryEntryService diaryEntryService, DiaryEntryDTOConverter diaryEntryDtoConverter) {
this.diaryEntryService = diaryEntryService;
this.diaryEntryDtoConverter = diaryEntryDtoConverter;
}
@SuppressWarnings("ParameterNumber") // FIXME
@GetMapping
public ResponseEntity<Page<DiaryEntryDTO>> getAllEntries(
@RequestParam(defaultValue = "0", required = false) Integer pageNo,
@RequestParam(defaultValue = "25", required = false) Integer pageSize,
@RequestParam(defaultValue = "date", required = false) String sortBy,
@RequestParam(defaultValue = "DESC", required = false) Sort.Direction sortDir,
@RequestParam(defaultValue = "", required = false) List<Long> plantIds,
@RequestParam(defaultValue = "", required = false) List<String> eventTypes) {
final Pageable pageable = PageRequest.of(pageNo, pageSize, sortDir, sortBy);
final Page<DiaryEntry> result = diaryEntryService.getAll(pageable, plantIds, eventTypes);
return ResponseEntity.ok(result.map(diaryEntryDtoConverter::convertToDTO));
}
@GetMapping("/{id}")
public ResponseEntity<DiaryEntryDTO> get(@PathVariable Long id) {
final DiaryEntry result = diaryEntryService.get(id);
return ResponseEntity.ok(diaryEntryDtoConverter.convertToDTO(result));
}
@GetMapping("/all/{diaryId}")
public ResponseEntity<Page<DiaryEntryDTO>> getEntries(
@RequestParam(defaultValue = "0", required = false) Integer pageNo,
@RequestParam(defaultValue = "25", required = false) Integer pageSize,
@RequestParam(defaultValue = "date", required = false) String sortBy,
@RequestParam(defaultValue = "DESC", required = false) Sort.Direction sortDir,
@PathVariable Long diaryId) {
final Pageable pageable = PageRequest.of(pageNo, pageSize, sortDir, sortBy);
final Page<DiaryEntry> result = diaryEntryService.getAll(diaryId, pageable);
return ResponseEntity.ok(result.map(diaryEntryDtoConverter::convertToDTO));
}
@GetMapping("/type")
public ResponseEntity<Collection<DiaryEntryType>> getEntryTypes() {
final Collection<DiaryEntryType> result = diaryEntryService.getAllTypes();
return ResponseEntity.ok(result);
}
@PostMapping
public ResponseEntity<DiaryEntryDTO> save(@RequestBody DiaryEntryDTO diaryEntryDTO) {
final DiaryEntry result = diaryEntryService.save(diaryEntryDtoConverter.convertFromDTO(diaryEntryDTO));
return ResponseEntity.ok(diaryEntryDtoConverter.convertToDTO(result));
}
@PutMapping("/{id}")
public ResponseEntity<DiaryEntryDTO> save(@PathVariable Long id, @RequestBody DiaryEntryDTO diaryEntryDTO) {
final DiaryEntry result = diaryEntryService.update(id, diaryEntryDtoConverter.convertFromDTO(diaryEntryDTO));
return ResponseEntity.ok(diaryEntryDtoConverter.convertToDTO(result));
}
@DeleteMapping("/{id}")
public ResponseEntity<MessageResponse> delete(@PathVariable Long id) {
diaryEntryService.delete(id);
return ResponseEntity.ok(new MessageResponse("Success"));
}
@GetMapping("/_count")
public ResponseEntity<Long> count() {
return ResponseEntity.ok(diaryEntryService.count());
}
@GetMapping("/{plantId}/_count")
public ResponseEntity<Long> countByPlant(@PathVariable Long plantId) {
return ResponseEntity.ok(diaryEntryService.count(plantId));
}
@GetMapping("/{plantId}/stats")
public ResponseEntity<Collection<DiaryEntryStats>> getStats(@PathVariable Long plantId) {
final Collection<DiaryEntryStats> result = diaryEntryService.getStats(plantId);
return ResponseEntity.ok(result);
}
}

View File

@ -1,95 +0,0 @@
package com.github.mdeluise.plantit.diary.entry;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "Diary entry", description = "Represents an entry in the diary.")
public class DiaryEntryDTO {
@Schema(description = "ID of the diary entry.", accessMode = Schema.AccessMode.READ_ONLY)
private Long id;
@Schema(description = "Type of the diary entry.")
private String type;
@Schema(description = "Note attached to the diary entry.")
private String note;
@Schema(description = "Date of the diary entry.")
private Date date;
@Schema(description = "Diary ID of the entry.")
private Long diaryId;
@Schema(description = "ID of the tracked entity.", accessMode = Schema.AccessMode.READ_ONLY)
private Long diaryTargetId;
@JsonProperty("diaryTargetPersonalName")
@Schema(description = "Personal name of the tracked entity.", accessMode = Schema.AccessMode.READ_ONLY)
private String diaryTargetInfoPersonalName;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getNote() {
return note;
}
public void setNote(String note) {
this.note = note;
}
public Date getDate() {
return date;
}
public void setDate(Date date) {
this.date = date;
}
public Long getDiaryId() {
return diaryId;
}
public void setDiaryId(Long diaryId) {
this.diaryId = diaryId;
}
public Long getDiaryTargetId() {
return diaryTargetId;
}
public void setDiaryTargetId(Long diaryTargetId) {
this.diaryTargetId = diaryTargetId;
}
public void setDiaryTargetInfoPersonalName(String diaryTargetInfoPersonalName) {
this.diaryTargetInfoPersonalName = diaryTargetInfoPersonalName;
}
public String getDiaryTargetInfoPersonalName() {
return diaryTargetInfoPersonalName;
}
}

View File

@ -1,30 +0,0 @@
package com.github.mdeluise.plantit.diary.entry;
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class DiaryEntryDTOConverter extends AbstractDTOConverter<DiaryEntry, DiaryEntryDTO> {
public DiaryEntryDTOConverter(ModelMapper modelMapper) {
super(modelMapper);
}
@Override
public DiaryEntry convertFromDTO(DiaryEntryDTO dto) {
final DiaryEntry result = modelMapper.map(dto, DiaryEntry.class);
if (result.getDate() == null) {
result.setDate(new Date());
}
return result;
}
@Override
public DiaryEntryDTO convertToDTO(DiaryEntry data) {
return modelMapper.map(data, DiaryEntryDTO.class);
}
}

View File

@ -1,25 +0,0 @@
package com.github.mdeluise.plantit.diary.entry;
import java.util.Optional;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.diary.Diary;
import com.github.mdeluise.plantit.plant.Plant;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DiaryEntryRepository extends JpaRepository<DiaryEntry, Long> {
Optional<DiaryEntry> findFirstByDiaryTargetAndTypeOrderByDateDesc(Plant target, DiaryEntryType type);
Page<DiaryEntry> findAllByDiaryOwner(User user, Pageable pageable);
Page<DiaryEntry> findAllByDiaryOwnerAndDiary(User user, Diary diary, Pageable pageable);
Long countByDiaryOwner(User user);
Long countByDiaryOwnerAndDiaryTargetId(User authenticatedUser, Long plantId);
Optional<DiaryEntry> findFirstByDiaryOwnerAndDiaryTargetIdAndTypeOrderByDateDesc(User user, Long id,
DiaryEntryType diaryEntryType);
}

View File

@ -1,166 +0,0 @@
package com.github.mdeluise.plantit.diary.entry;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
import com.github.mdeluise.plantit.diary.Diary;
import com.github.mdeluise.plantit.diary.DiaryService;
import com.github.mdeluise.plantit.exception.ResourceNotFoundException;
import com.github.mdeluise.plantit.exception.UnauthorizedException;
import com.github.mdeluise.plantit.plant.Plant;
import com.github.mdeluise.plantit.plant.PlantRepository;
import com.github.mdeluise.plantit.plant.PlantService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@Service
public class DiaryEntryService {
private final AuthenticatedUserService authenticatedUserService;
private final DiaryEntryRepository diaryEntryRepository;
private final DiaryService diaryService;
private final PlantService plantService;
private final PlantRepository plantRepository;
@Autowired
public DiaryEntryService(AuthenticatedUserService authenticatedUserService,
DiaryEntryRepository diaryEntryRepository, DiaryService diaryService,
PlantService plantService, PlantRepository plantRepository) {
this.authenticatedUserService = authenticatedUserService;
this.diaryEntryRepository = diaryEntryRepository;
this.diaryService = diaryService;
this.plantService = plantService;
this.plantRepository = plantRepository;
}
public Page<DiaryEntry> getAll(Pageable pageable, List<Long> plantIds, List<String> eventTypes) {
checkPlantExistenceAndVisibility(plantIds);
checkEventTypeExistence(eventTypes);
if (plantIds.isEmpty() && eventTypes.isEmpty()) {
return diaryEntryRepository.findAllByDiaryOwner(authenticatedUserService.getAuthenticatedUser(), pageable);
}
final Pageable pageableToUse = PageRequest.of(0, Math.max(1, count().intValue()), pageable.getSort());
final List<DiaryEntry> filteredResult =
diaryEntryRepository.findAllByDiaryOwner(
authenticatedUserService.getAuthenticatedUser(), pageableToUse).stream()
.filter(entry -> plantIds.isEmpty() || plantIds.contains(entry.getDiary().getTarget().getId()))
.filter(entry -> eventTypes.isEmpty() || eventTypes.contains(entry.getType().name()))
.toList();
final int start = (int) pageable.getOffset();
final int end = Math.min(start + pageable.getPageSize(), filteredResult.size());
return new PageImpl<>(filteredResult.subList(start, end), pageable, filteredResult.size());
}
private void checkEventTypeExistence(List<String> eventTypes) {
eventTypes.forEach(DiaryEntryType::valueOf);
}
private void checkPlantExistenceAndVisibility(List<Long> plantIds) {
plantIds.forEach(plantService::get);
}
public Page<DiaryEntry> getAll(Long diaryId, Pageable pageable) {
final Diary diary = diaryService.get(diaryId);
return diaryEntryRepository.findAllByDiaryOwnerAndDiary(
authenticatedUserService.getAuthenticatedUser(), diary, pageable);
}
public Collection<DiaryEntryType> getAllTypes() {
final DiaryEntryType[] result = DiaryEntryType.class.getEnumConstants();
return List.of(result);
}
public DiaryEntry save(DiaryEntry diaryEntry) {
final Diary diary = diaryService.get(diaryEntry.getDiary().getId());
final User diaryOwner = diary.getOwner();
if (!diaryOwner.equals(authenticatedUserService.getAuthenticatedUser())) {
throw new UnauthorizedException();
}
diaryEntry.setDiary(diary); // if not, diaryEntry.diary has only the id and not other fields
return diaryEntryRepository.save(diaryEntry);
}
public DiaryEntry get(Long id) {
final DiaryEntry result =
diaryEntryRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
if (!result.getDiary().getOwner().equals(authenticatedUserService.getAuthenticatedUser())) {
throw new UnauthorizedException();
}
return result;
}
public void delete(Long diaryEntryId) {
final DiaryEntry toDelete = get(diaryEntryId);
diaryEntryRepository.delete(toDelete);
}
public DiaryEntry update(Long id, DiaryEntry updated) {
final DiaryEntry toUpdate = get(id);
if (updated.getDiary() == null) {
updated.setDiary(toUpdate.getDiary());
}
updated.getDiary().setOwner(authenticatedUserService.getAuthenticatedUser());
updated.setId(id);
return diaryEntryRepository.save(updated);
}
public Long count() {
return diaryEntryRepository.countByDiaryOwner(authenticatedUserService.getAuthenticatedUser());
}
public Long count(Long plantId) {
diaryService.get(plantId);
return diaryEntryRepository.countByDiaryOwnerAndDiaryTargetId(
authenticatedUserService.getAuthenticatedUser(), plantId);
}
public Collection<DiaryEntryStats> getStats(Long plantId) {
diaryService.get(plantId);
final Collection<DiaryEntryStats> result = new HashSet<>();
final User authenticatedUser = authenticatedUserService.getAuthenticatedUser();
for (DiaryEntryType type : getAllTypes()) {
final Optional<DiaryEntry> lastEvent =
diaryEntryRepository.findFirstByDiaryOwnerAndDiaryTargetIdAndTypeOrderByDateDesc(authenticatedUser, plantId,
type
);
lastEvent.ifPresent(
diaryEntry -> result.add(new DiaryEntryStats(diaryEntry.getType(), diaryEntry.getDate())));
}
final List<DiaryEntryStats> sortedResult = new ArrayList<>(result.stream().toList());
sortedResult.sort(Comparator.comparing(DiaryEntryStats::date));
Collections.reverse(sortedResult);
return sortedResult;
}
public Optional<DiaryEntry> getLast(Long plantId, DiaryEntryType type) {
final Plant plant = plantRepository.findById(plantId).orElseThrow(() -> new ResourceNotFoundException(plantId));
return diaryEntryRepository.findFirstByDiaryTargetAndTypeOrderByDateDesc(plant, type);
}
}

View File

@ -1,8 +0,0 @@
package com.github.mdeluise.plantit.diary.entry;
import java.util.Date;
public record DiaryEntryStats(
DiaryEntryType type,
Date date
) { }

View File

@ -1,16 +0,0 @@
package com.github.mdeluise.plantit.diary.entry;
public enum DiaryEntryType {
SEEDING,
WATERING,
FERTILIZING,
BIOSTIMULATING,
MISTING,
TRANSPLANTING,
WATER_CHANGING,
OBSERVATION,
TREATMENT,
PROPAGATING,
PRUNING,
REPOTTING,
}

View File

@ -1,46 +0,0 @@
package com.github.mdeluise.plantit.exception;
import com.github.mdeluise.plantit.exception.error.ErrorCode;
import com.github.mdeluise.plantit.exception.error.ErrorMessage;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
@ControllerAdvice
@ResponseBody
public class ControllerExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorMessage> entityNotFoundException(ResourceNotFoundException ex) {
final ErrorMessage message = new ErrorMessage(
HttpStatus.NOT_FOUND.value(),
ErrorCode.RESOURCE_NOT_FOUND,
ex.getMessage()
);
return new ResponseEntity<>(message, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<ErrorMessage> unauthorizedException(UnauthorizedException ex) {
final ErrorMessage message = new ErrorMessage(
HttpStatus.UNAUTHORIZED.value(),
ErrorCode.USER_UNAUTHORIZED,
ex.getMessage()
);
return new ResponseEntity<>(message, HttpStatus.UNAUTHORIZED);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorMessage> globalExceptionHandler(Exception ex) {
final ErrorMessage message = new ErrorMessage(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
ErrorCode.INTERNAL_SERVER_ERROR,
ex.getMessage()
);
return new ResponseEntity<>(message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}

View File

@ -1,7 +0,0 @@
package com.github.mdeluise.plantit.exception;
public class DuplicatedSpeciesException extends RuntimeException {
public DuplicatedSpeciesException(String species) {
super(String.format("Species \"%s\" already exists in another botanical info", species));
}
}

View File

@ -1,12 +0,0 @@
package com.github.mdeluise.plantit.exception;
public class InfoExtractionException extends RuntimeException {
public InfoExtractionException(Exception e) {
super("Error while extracting information: " + e.getMessage());
}
public InfoExtractionException(String message) {
super("Error while extracting information: " + message);
}
}

View File

@ -1,7 +0,0 @@
package com.github.mdeluise.plantit.exception;
public class MaximumNumberOfUsersReachedExceptions extends RuntimeException {
public MaximumNumberOfUsersReachedExceptions() {
super("Maximum number of user reached.");
}
}

View File

@ -1,29 +0,0 @@
package com.github.mdeluise.plantit.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(Class clazz, String field, String fieldValue) {
super(String.format("Resource of type %s with %s %s not found", clazz.getSimpleName(), field, fieldValue));
}
public ResourceNotFoundException(Object id) {
this("id", String.valueOf(id));
}
public ResourceNotFoundException(Class clazz, Object id) {
this(clazz, "id", String.valueOf(id));
}
public ResourceNotFoundException(String field, String fieldValue) {
this(Object.class, field, fieldValue);
}
@Override
public String getMessage() {
return super.getMessage();
}
}

View File

@ -1,7 +0,0 @@
package com.github.mdeluise.plantit.exception;
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException() {
super("User not authorized to perform the action");
}
}

View File

@ -1,9 +0,0 @@
package com.github.mdeluise.plantit.exception.error;
public enum ErrorCode {
RESOURCE_NOT_FOUND,
USER_UNAUTHORIZED,
INTERNAL_SERVER_ERROR,
AUTHENTICATION_ERROR,
TOO_MANY_REQUESTS,
}

View File

@ -1,4 +0,0 @@
package com.github.mdeluise.plantit.exception.error;
public record ErrorMessage(int statusCode, ErrorCode errorCode, String message) {
}

View File

@ -1,44 +0,0 @@
package com.github.mdeluise.plantit.image;
import com.github.mdeluise.plantit.botanicalinfo.BotanicalInfo;
import jakarta.persistence.CascadeType;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Transient;
@Entity
@DiscriminatorValue("1")
public class BotanicalInfoImage extends EntityImageImpl {
@OneToOne(cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH})
@JoinColumn(name = "botanical_info_entity_id")
private BotanicalInfo target;
@Transient
private byte[] content;
public BotanicalInfoImage() {
super();
}
public BotanicalInfo getTarget() {
return target;
}
public void setTarget(BotanicalInfo target) {
this.target = target;
}
public byte[] getContent() {
return content;
}
public void setContent(byte[] content) {
this.content = content;
}
}

View File

@ -1,25 +0,0 @@
package com.github.mdeluise.plantit.image;
import java.util.Date;
public interface EntityImage {
String getId();
void setId(String id);
void setPath(String path);
String getPath();
void setUrl(String url);
String getUrl();
void setContentType(String type);
String getContentType();
void setCreateOn(Date createOn);
Date getCreateOn();
}

View File

@ -1,107 +0,0 @@
package com.github.mdeluise.plantit.image;
import java.io.Serializable;
import java.util.Date;
import java.util.UUID;
import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.DiscriminatorType;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.validation.constraints.NotNull;
import org.hibernate.validator.constraints.Length;
@Entity(name = "entity_images")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(
name = "image_type", discriminatorType = DiscriminatorType.INTEGER, columnDefinition = "TINYINT(1)"
)
public class EntityImageImpl implements EntityImage, Serializable {
@Id
@NotNull
private String id;
@Length(max = 100)
private String description;
@NotNull
private Date createOn;
@Length(max = 255)
private String url;
private String path;
private String contentType;
public EntityImageImpl() {
this.id = UUID.randomUUID().toString();
this.createOn = new Date();
}
@Override
public String getId() {
return id;
}
@Override
public void setId(String id) {
this.id = id;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
@Override
public String getPath() {
return path;
}
@Override
public void setPath(String path) {
this.path = path;
}
@Override
public String getUrl() {
return url;
}
@Override
public void setUrl(String url) {
this.url = url;
}
public String getContentType() {
return contentType;
}
public void setContentType(String type) {
this.contentType = type;
}
@Override
public void setCreateOn(Date createOn) {
this.createOn = createOn;
}
@Override
public Date getCreateOn() {
return createOn;
}
}

View File

@ -1,37 +0,0 @@
package com.github.mdeluise.plantit.image;
import java.io.Serializable;
import org.springframework.http.MediaType;
public class ImageContentResponse implements Serializable {
private byte[] content;
private MediaType type;
public ImageContentResponse(byte[] content, MediaType type) {
this.content = content;
this.type = type;
}
public byte[] getContent() {
return content;
}
public void setContent(byte[] content) {
this.content = content;
}
public MediaType getType() {
return type;
}
public void setType(MediaType type) {
this.type = type;
}
}

View File

@ -1,131 +0,0 @@
package com.github.mdeluise.plantit.image;
import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Base64;
import java.util.Collection;
import java.util.Date;
import com.github.mdeluise.plantit.common.MessageResponse;
import com.github.mdeluise.plantit.image.storage.ImageStorageService;
import com.github.mdeluise.plantit.plant.Plant;
import com.github.mdeluise.plantit.plant.PlantDTO;
import com.github.mdeluise.plantit.plant.PlantDTOConverter;
import com.github.mdeluise.plantit.plant.PlantService;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/image")
public class ImageController {
private final ImageStorageService imageStorageService;
private final PlantService plantService;
private final ImageDTOConverter imageDtoConverter;
private final PlantDTOConverter plantDtoConverter;
@Autowired
public ImageController(ImageStorageService imageStorageService, PlantService plantService, ImageDTOConverter imageDtoConverter,
PlantDTOConverter plantDtoConverter) {
this.imageStorageService = imageStorageService;
this.plantService = plantService;
this.imageDtoConverter = imageDtoConverter;
this.plantDtoConverter = plantDtoConverter;
}
@PostMapping("/plant/{plantId}/{imageId}")
@Transactional
public ResponseEntity<PlantDTO> updatePlantAvatarImageId(@PathVariable Long plantId, @PathVariable String imageId) {
final Plant linkedEntity = plantService.get(plantId);
final PlantImage newAvatarImage = (PlantImage) imageStorageService.get(imageId);
linkedEntity.setAvatarImage(newAvatarImage);
final Plant saved = plantService.update(linkedEntity.getId(), linkedEntity);
return ResponseEntity.ok(plantDtoConverter.convertToDTO(saved));
}
@GetMapping("/metadata/{id}")
public ResponseEntity<ImageDTO> get(@PathVariable("id") String id) {
final EntityImage result = imageStorageService.get(id);
return ResponseEntity.ok(imageDtoConverter.convertToDTO(result));
}
@GetMapping("/content/{id}")
@SuppressWarnings("ReturnCount")
public ResponseEntity<Resource> getContent(@PathVariable("id") String id) {
try {
final ImageContentResponse imageContent = imageStorageService.getImageContent(id);
final HttpHeaders headers = new HttpHeaders();
headers.setContentType(imageContent.getType());
final ByteArrayResource resource = new ByteArrayResource(imageContent.getContent());
return ResponseEntity.ok()
.headers(headers)
.contentLength(resource.contentLength())
.body(resource);
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/thumbnail/{id}")
public ResponseEntity<byte[]> getThumbnail(@PathVariable("id") String id) {
final byte[] result = Base64.getEncoder().encode(imageStorageService.getThumbnail(id));
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.IMAGE_JPEG);
return new ResponseEntity<>(result, headers, HttpStatus.OK);
}
@DeleteMapping("/{id}")
public ResponseEntity<MessageResponse> delete(@PathVariable("id") String id) {
imageStorageService.remove(id);
return ResponseEntity.ok(new MessageResponse("Success"));
}
@PostMapping("/entity/{id}")
public ResponseEntity<String> saveEntityImage(@RequestParam("image") MultipartFile file,
@PathVariable("id") Long entityId,
@RequestParam(value = "creationDate", required = false) String creationDateStr,
@RequestParam(required = false) String description)
throws ParseException {
final Plant linkedEntity = plantService.get(entityId);
final Date creationDate = creationDateStr != null ?
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").parse(creationDateStr) :
null;
final EntityImage saved = imageStorageService.save(file, linkedEntity, creationDate, description);
return ResponseEntity.ok(saved.getId());
}
@GetMapping("/entity/all/{id}")
public ResponseEntity<Collection<String>> getAllImageIdsFromEntity(@PathVariable("id") Long id) {
final Plant linkedEntity = plantService.get(id);
final Collection<String> result = imageStorageService.getAllIds(linkedEntity);
return ResponseEntity.ok(result);
}
@GetMapping("/entity/_count")
public ResponseEntity<Integer> countUserImage() {
return ResponseEntity.ok(imageStorageService.count());
}
}

View File

@ -1,57 +0,0 @@
package com.github.mdeluise.plantit.image;
import java.util.Date;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "Image", description = "Represents a plant's image.")
public class ImageDTO {
@Schema(description = "ID of the image.", accessMode = Schema.AccessMode.READ_ONLY)
private String id;
@Schema(description = "Creation date of the image.", accessMode = Schema.AccessMode.READ_ONLY)
private Date createOn;
@Schema(description = "Description of the image.", accessMode = Schema.AccessMode.READ_ONLY)
private String description;
@Schema(description = "Target ID of the image.", accessMode = Schema.AccessMode.READ_ONLY)
private Long targetId;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public Date getCreateOn() {
return createOn;
}
public void setCreateOn(Date createOn) {
this.createOn = createOn;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Long getTargetId() {
return targetId;
}
public void setTargetId(Long targetId) {
this.targetId = targetId;
}
}

View File

@ -1,26 +0,0 @@
package com.github.mdeluise.plantit.image;
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class ImageDTOConverter extends AbstractDTOConverter<EntityImage, ImageDTO> {
@Autowired
public ImageDTOConverter(ModelMapper modelMapper) {
super(modelMapper);
}
@Override
public EntityImage convertFromDTO(ImageDTO dto) {
return modelMapper.map(dto, EntityImageImpl.class);
}
@Override
public ImageDTO convertToDTO(EntityImage data) {
return modelMapper.map(data, ImageDTO.class);
}
}

View File

@ -1,6 +0,0 @@
package com.github.mdeluise.plantit.image;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ImageRepository extends JpaRepository<EntityImageImpl, String> {
}

View File

@ -1,5 +0,0 @@
package com.github.mdeluise.plantit.image;
// Marker interface
public interface ImageTarget {
}

View File

@ -1,63 +0,0 @@
package com.github.mdeluise.plantit.image;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Iterator;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
public class ImageUtility {
public static byte[] compressImage(File data) throws IOException {
return compressImage(data, 0.8f);
}
public static byte[] compressImage(File data, float quality) throws IOException {
final String fileExtension = getFileExtension(data);
final String filePath =
String.format("%s/%s_compressed.%s", System.getProperty("java.io.tmpdir"), data.getName(), fileExtension);
final File compressedImageFile = new File(filePath);
final OutputStream os = new FileOutputStream(compressedImageFile);
final Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName(fileExtension);
final ImageWriter writer = writers.next();
final ImageOutputStream ios = ImageIO.createImageOutputStream(os);
writer.setOutput(ios);
final ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(quality);
final BufferedImage image = ImageIO.read(data);
writer.write(null, new IIOImage(image, null, null), param);
os.close();
ios.close();
writer.dispose();
final FileInputStream fl = new FileInputStream(compressedImageFile);
byte[] arr = new byte[(int) compressedImageFile.length()];
fl.read(arr);
fl.close();
return arr;
}
public static String getFileExtension(File file) {
final String fileName = file.getName();
final int lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex != -1 && lastDotIndex < fileName.length() - 1) {
return fileName.substring(lastDotIndex + 1);
} else {
throw new UnsupportedOperationException(String.format("File %s does not have an extension", file.getName()));
}
}
}

View File

@ -1,43 +0,0 @@
package com.github.mdeluise.plantit.image;
import com.github.mdeluise.plantit.plant.Plant;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
@Entity
@DiscriminatorValue("2")
public class PlantImage extends EntityImageImpl {
@ManyToOne
@JoinColumn(name = "plant_entity_id")
private Plant target;
@OneToOne
private Plant avatarOf;
public PlantImage() {
super();
}
public Plant getTarget() {
return target;
}
public void setTarget(Plant target) {
this.target = target;
}
public Plant getAvatarOf() {
return avatarOf;
}
public void setAvatarOf(Plant avatarOf) {
this.avatarOf = avatarOf;
}
}

View File

@ -1,18 +0,0 @@
package com.github.mdeluise.plantit.image;
import java.util.List;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.plant.Plant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface PlantImageRepository extends JpaRepository<PlantImage, String> {
// Bugged due to the inheritance
//List<PlantImage> findAllByPlantImageTarget(Plant target);
@Query("SELECT i.id FROM PlantImage i WHERE i.target = ?1 ORDER BY i.createOn DESC")
List<String> findAllIdsPlantByImageTargetOrderBySavedAtDesc(Plant target);
Integer countByTargetOwner(User user);
}

View File

@ -1,328 +0,0 @@
package com.github.mdeluise.plantit.image.storage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.Date;
import java.util.Objects;
import com.github.mdeluise.plantit.botanicalinfo.BotanicalInfo;
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
import com.github.mdeluise.plantit.exception.ResourceNotFoundException;
import com.github.mdeluise.plantit.exception.UnauthorizedException;
import com.github.mdeluise.plantit.image.BotanicalInfoImage;
import com.github.mdeluise.plantit.image.EntityImage;
import com.github.mdeluise.plantit.image.EntityImageImpl;
import com.github.mdeluise.plantit.image.ImageContentResponse;
import com.github.mdeluise.plantit.image.ImageRepository;
import com.github.mdeluise.plantit.image.ImageTarget;
import com.github.mdeluise.plantit.image.ImageUtility;
import com.github.mdeluise.plantit.image.PlantImage;
import com.github.mdeluise.plantit.image.PlantImageRepository;
import com.github.mdeluise.plantit.plant.Plant;
import com.github.mdeluise.plantit.plant.PlantAvatarMode;
import com.github.mdeluise.plantit.plant.PlantRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
@Service
@SuppressWarnings("ClassDataAbstractionCoupling") // FIXME
public class FileSystemImageStorageService implements ImageStorageService {
private final String rootLocation;
private final ImageRepository imageRepository;
private final PlantImageRepository plantImageRepository;
private final PlantRepository plantRepository;
private final int maxOriginImgSize;
private final AuthenticatedUserService authenticatedUserService;
private final Logger logger = LoggerFactory.getLogger(FileSystemImageStorageService.class);
@Autowired
@SuppressWarnings("ParameterNumber") //FIXME
public FileSystemImageStorageService(@Value("${upload.location}") String rootLocation,
ImageRepository imageRepository, PlantImageRepository plantImageRepository,
PlantRepository plantRepository,
@Value("${image.max_origin_size}") int maxOriginImgSize,
AuthenticatedUserService authenticatedUserService) {
this.rootLocation = rootLocation;
this.imageRepository = imageRepository;
this.plantImageRepository = plantImageRepository;
this.plantRepository = plantRepository;
this.maxOriginImgSize = maxOriginImgSize;
this.authenticatedUserService = authenticatedUserService;
}
@Override
public EntityImage save(MultipartFile file, ImageTarget linkedEntity, Date creationDate, String description) {
if (file.isEmpty()) {
logger.error("File is empty");
throw new StorageException("Failed to save empty file.");
}
String fileExtension;
try {
fileExtension = file.getContentType();
} catch (NullPointerException e) {
logger.error("Error while extract file extension from file with content type " + file.getContentType());
throw new StorageException("Could not retrieve file information", e);
}
try {
InputStream fileInputStream = file.getInputStream();
if (file.getBytes().length > maxOriginImgSize) { // default to 10 MB
logger.info(String.format("File size (%s byte) exceed %s MB, compressing...", file.getBytes().length,
maxOriginImgSize
));
fileInputStream = new ByteArrayInputStream(ImageUtility.compressImage(file.getResource().getFile()));
}
return createImage(linkedEntity, creationDate, description, fileExtension, fileInputStream);
} catch (IOException e) {
logger.error("Error while reading file", e);
throw new StorageException("Could not read provided file.", e);
}
}
private EntityImageImpl createImage(ImageTarget linkedEntity, Date creationDate, String description,
String contentType, InputStream fileInputStream) {
try {
EntityImageImpl entityImage;
if (linkedEntity instanceof BotanicalInfo b) {
entityImage = new BotanicalInfoImage();
((BotanicalInfoImage) entityImage).setTarget(b);
} else if (linkedEntity instanceof Plant p) {
entityImage = new PlantImage();
((PlantImage) entityImage).setTarget(p);
} else {
throw new UnsupportedOperationException("Could not find suitable class for linkedEntity");
}
entityImage.setCreateOn(Objects.requireNonNullElseGet(creationDate, Date::new));
entityImage.setContentType(contentType);
final String fileExtension = MediaType.parseMediaType(contentType).getSubtype();
final String fileName = String.format("%s/%s.%s", rootLocation, entityImage.getId(), fileExtension);
final Path pathToFile = Path.of(fileName);
createDestinationDirectoryIfNotExist(Path.of(rootLocation));
Files.copy(fileInputStream, pathToFile);
entityImage.setPath(String.format("%s/%s.%s", rootLocation, entityImage.getId(), fileExtension));
entityImage.setDescription(description);
return imageRepository.save(entityImage);
} catch (IOException e) {
logger.error("Error while saving file", e);
throw new StorageException("Failed to save file.", e);
}
}
@Override
public EntityImage saveBotanicalInfoThumbnailImage(byte[] content, String contentType, BotanicalInfo linkedEntity) {
return createImage(linkedEntity, new Date(), null, contentType, new ByteArrayInputStream(content));
}
private void createDestinationDirectoryIfNotExist(Path destinationPath) throws IOException {
if (Files.exists(destinationPath)) {
return;
}
logger.debug("Directory {} does not exist, creating it...", destinationPath);
Files.createDirectories(destinationPath);
}
@Override
public EntityImage save(String url, ImageTarget linkedEntity) throws MalformedURLException {
if (!(linkedEntity instanceof BotanicalInfo)) {
throw new UnsupportedOperationException("URL images can be linked only to Botanical Info entities");
}
try {
new URL(url).toURI();
} catch (URISyntaxException e) {
logger.error("Provided URL not correct", e);
throw new MalformedURLException(e.getMessage());
}
final BotanicalInfoImage entityImage = new BotanicalInfoImage();
entityImage.setTarget((BotanicalInfo) linkedEntity);
entityImage.setUrl(url);
return imageRepository.save(entityImage);
}
@Override
public EntityImage clone(String id, ImageTarget linkedEntity) {
final EntityImage toClone = get(id);
EntityImageImpl entityImage;
if (linkedEntity instanceof BotanicalInfo b) {
entityImage = new BotanicalInfoImage();
((BotanicalInfoImage) entityImage).setTarget(b);
} else if (linkedEntity instanceof Plant p) {
entityImage = new PlantImage();
((PlantImage) entityImage).setTarget(p);
} else {
throw new UnsupportedOperationException("Could not find suitable class for linkedEntity");
}
entityImage.setUrl(toClone.getUrl());
if (toClone.getPath() != null) {
final String resultPath = toClone.getPath().replace(toClone.getId(), entityImage.getId());
try {
Files.copy(Path.of(toClone.getPath()), Path.of(resultPath));
} catch (IOException e) {
logger.error("Error while saving file", e);
throw new StorageException("Failed to save file.", e);
}
}
return imageRepository.save(entityImage);
}
@Cacheable(value = "image", key = "{#id}")
@Override
public EntityImage get(String id) {
final EntityImageImpl result =
imageRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
if (result instanceof PlantImage p &&
p.getTarget().getOwner() != authenticatedUserService.getAuthenticatedUser()) {
logger.warn("User not authorized to operate on image " + id);
throw new UnauthorizedException();
} else if (result instanceof BotanicalInfoImage b &&
!b.getTarget().isAccessibleToUser(authenticatedUserService.getAuthenticatedUser())) {
logger.warn("User not authorized to operate on image " + id);
throw new UnauthorizedException();
}
return result;
}
@Cacheable(value = "image-content", key = "{#id}")
public ImageContentResponse getImageContent(String id) throws IOException {
get(id);
return getImageContentInternal(id);
}
@Override
public ImageContentResponse getImageContentInternal(String id) throws IOException {
final EntityImageImpl image =
imageRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
if (image.getUrl() != null) {
try {
final RestTemplate restTemplate = new RestTemplate();
final ResponseEntity<byte[]> response = restTemplate.getForEntity(new URI(image.getUrl()), byte[].class);
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
final MediaType contentType = restTemplate.headForHeaders(new URI(image.getUrl())).getContentType();
return new ImageContentResponse(response.getBody(), contentType);
} else {
throw new IOException("Failed to retrieve image content from URL: " + image.getUrl());
}
} catch (URISyntaxException e) {
throw new IOException("Invalid image URL: " + image.getUrl(), e);
}
} else {
final MediaType contentType = MediaType.parseMediaType(image.getContentType());
final Path imagePath = Paths.get(rootLocation + "/" + image.getId() + "." + contentType.getSubtype());
final byte[] imageBytes = Files.readAllBytes(imagePath);
return new ImageContentResponse(imageBytes, contentType);
}
}
@Cacheable(value = "thumbnail", key = "{#id}")
@Override
public byte[] getThumbnail(String id) {
final EntityImage getContentFrom = get(id);
if (getContentFrom.getPath() == null) {
logger.warn("Trying to read an image that has no content but only URL");
throw new UnsupportedOperationException(String.format("Image with id %s has no content (only URL)", id));
}
try {
final File entityImageFile = new File(getContentFrom.getPath());
if (!entityImageFile.exists() || !entityImageFile.canRead()) {
logger.error("Error while reading image " + id + " permission deny");
throw new StorageFileNotFoundException("Could not read image with id: " + id);
}
return ImageUtility.compressImage(entityImageFile, .2f);
} catch (IOException e) {
logger.error("Error while reading image " + id, e);
throw new StorageFileNotFoundException("Could not read image with id: " + id, e);
}
}
@CacheEvict(cacheNames = {"image", "thumbnail", "image-content"}, allEntries = true)
@Override
public void removeAll() {
FileSystemUtils.deleteRecursively(Path.of(rootLocation).toFile());
imageRepository.deleteAll();
}
@Caching(
evict = {
@CacheEvict(cacheNames = {"image", "thumbnail", "image-content"}, key = "{#id}"), @CacheEvict(
value = "plants", allEntries = true, beforeInvocation = true,
condition = "@plantImageRepository.findById(#id).present and " +
"@plantImageRepository.findById(#id).get().avatarOf != null"
)
}
)
@Override
// FIXME plantImageRepository and PlantImage should not be used,
// maybe DeletePlantImageEntityAndFileCollaborator collaborator?
public void remove(String id) {
final EntityImage entityToDelete =
imageRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException(id));
if (entityToDelete instanceof PlantImage p && p.getAvatarOf() != null) {
final Plant toUpdate = p.getAvatarOf();
toUpdate.setAvatarImage(null);
toUpdate.setAvatarMode(PlantAvatarMode.NONE);
plantRepository.save(toUpdate);
p.setAvatarOf(null);
plantImageRepository.save(p);
}
final String entityImagePath = get(id).getPath();
if (entityImagePath != null) {
final File toRemove = new File(entityImagePath);
boolean isToRemove = true;
if (!toRemove.exists() || !toRemove.canRead()) {
logger.error("Error while reading image {}. Deleting it only from DB", id);
isToRemove = false;
}
if (isToRemove && !toRemove.delete()) {
logger.error("Error while removing image {}", id);
throw new StorageException("Could not remove image with id " + id);
}
}
imageRepository.deleteById(id);
}
@Override
public Collection<String> getAllIds(ImageTarget linkedEntity) {
return plantImageRepository.findAllIdsPlantByImageTargetOrderBySavedAtDesc((Plant) linkedEntity);
}
@Override
public int count() {
return plantImageRepository.countByTargetOwner(authenticatedUserService.getAuthenticatedUser());
}
}

View File

@ -1,38 +0,0 @@
package com.github.mdeluise.plantit.image.storage;
import java.io.IOException;
import java.net.MalformedURLException;
import java.util.Collection;
import java.util.Date;
import com.github.mdeluise.plantit.botanicalinfo.BotanicalInfo;
import com.github.mdeluise.plantit.image.EntityImage;
import com.github.mdeluise.plantit.image.ImageContentResponse;
import com.github.mdeluise.plantit.image.ImageTarget;
import org.springframework.web.multipart.MultipartFile;
public interface ImageStorageService {
EntityImage save(MultipartFile file, ImageTarget linkedEntity, Date creationDate, String description);
EntityImage saveBotanicalInfoThumbnailImage(byte[] content, String contentType, BotanicalInfo linkedEntity);
EntityImage save(String url, ImageTarget linkedEntity) throws MalformedURLException;
EntityImage clone(String id, ImageTarget linkedEntity);
EntityImage get(String id);
ImageContentResponse getImageContent(String id) throws IOException;
ImageContentResponse getImageContentInternal(String id) throws IOException;
void remove(String id);
void removeAll();
Collection<String> getAllIds(ImageTarget linkedEntity);
int count();
byte[] getThumbnail(String id);
}

View File

@ -1,12 +0,0 @@
package com.github.mdeluise.plantit.image.storage;
public class StorageException extends RuntimeException {
public StorageException(String message) {
super(message);
}
public StorageException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -1,12 +0,0 @@
package com.github.mdeluise.plantit.image.storage;
public class StorageFileNotFoundException extends StorageException {
public StorageFileNotFoundException(String message) {
super(message);
}
public StorageFileNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -1,11 +0,0 @@
package com.github.mdeluise.plantit.notification;
public class NotifyException extends Exception {
public NotifyException(Throwable cause) {
super("Error while notify about the reminder", cause);
}
public NotifyException(String cause) {
super(cause);
}
}

View File

@ -1,44 +0,0 @@
package com.github.mdeluise.plantit.notification.console;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcher;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
import com.github.mdeluise.plantit.reminder.Reminder;
import org.springframework.stereotype.Component;
@Component
public class ConsoleNotificationDispatcher implements NotificationDispatcher {
@Override
public void notifyReminder(Reminder reminder) {
final String message = String.format(
"[reminder for user %s] Time to care care for %s, action required: %s",
reminder.getTarget().getOwner().getUsername(),
reminder.getTarget().getInfo().getPersonalName(),
reminder.getAction()
);
System.out.println(message);
}
@Override
public NotificationDispatcherName getName() {
return NotificationDispatcherName.CONSOLE;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public void loadConfig(NotificationDispatcherConfig config) {
}
@Override
public void initConfig() {
}
}

View File

@ -1,17 +0,0 @@
package com.github.mdeluise.plantit.notification.dispatcher;
import com.github.mdeluise.plantit.notification.NotifyException;
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
import com.github.mdeluise.plantit.reminder.Reminder;
public interface NotificationDispatcher {
void notifyReminder(Reminder reminder) throws NotifyException;
NotificationDispatcherName getName();
boolean isEnabled();
void loadConfig(NotificationDispatcherConfig config);
void initConfig();
}

View File

@ -1,90 +0,0 @@
package com.github.mdeluise.plantit.notification.dispatcher;
import java.util.Collection;
import java.util.Set;
import com.github.mdeluise.plantit.common.MessageResponse;
import com.github.mdeluise.plantit.notification.dispatcher.config.AbstractNotificationDispatcherConfig;
import com.github.mdeluise.plantit.notification.gotify.GotifyNotificationDispatcherConfig;
import com.github.mdeluise.plantit.notification.gotify.GotifyNotificationDispatcherConfigDTO;
import com.github.mdeluise.plantit.notification.gotify.GotifyNotificationDispatcherDTOConverter;
import com.github.mdeluise.plantit.notification.ntfy.NtfyNotificationDispatcherConfig;
import com.github.mdeluise.plantit.notification.ntfy.NtfyNotificationDispatcherConfigDTO;
import com.github.mdeluise.plantit.notification.ntfy.NtfyNotificationDispatcherDTOConverter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/notification-dispatcher")
@Tag(name = "Notifications", description = "Endpoints for notifications management")
public class NotificationDispatcherController {
private final NotificationDispatcherService notificationDispatcherService;
private final NtfyNotificationDispatcherDTOConverter ntfyNotificationDispatcherDTOConverter;
private final GotifyNotificationDispatcherDTOConverter gotifyNotificationDispatcherDTOConverter;
@Autowired
public NotificationDispatcherController(NotificationDispatcherService notificationDispatcherService,
NtfyNotificationDispatcherDTOConverter ntfyNotificationDispatcherDTOConverter,
GotifyNotificationDispatcherDTOConverter gotifyNotificationDispatcherDTOConverter) {
this.notificationDispatcherService = notificationDispatcherService;
this.ntfyNotificationDispatcherDTOConverter = ntfyNotificationDispatcherDTOConverter;
this.gotifyNotificationDispatcherDTOConverter = gotifyNotificationDispatcherDTOConverter;
}
@GetMapping
public ResponseEntity<Collection<NotificationDispatcherName>> getUserEnabled() {
final Collection<NotificationDispatcherName> result =
notificationDispatcherService.getNotificationDispatchersForUser();
return ResponseEntity.ok(result);
}
@PutMapping
public ResponseEntity<MessageResponse> setUserEnabled(@RequestBody Set<NotificationDispatcherName> toEnable) {
notificationDispatcherService.setNotificationDispatchersForUser(toEnable);
return ResponseEntity.ok(new MessageResponse("Success"));
}
@GetMapping("/config/ntfy")
public NtfyNotificationDispatcherConfigDTO getNtfyConfig() {
final NtfyNotificationDispatcherConfig result =
(NtfyNotificationDispatcherConfig) notificationDispatcherService.getUserConfig(NotificationDispatcherName.NTFY)
.orElse(new NtfyNotificationDispatcherConfig());
return ntfyNotificationDispatcherDTOConverter.convertToDTO(result);
}
@PostMapping("/config/ntfy")
public ResponseEntity<MessageResponse> setNtfyConfig(@RequestBody NtfyNotificationDispatcherConfigDTO config) {
final AbstractNotificationDispatcherConfig toSave = ntfyNotificationDispatcherDTOConverter.convertFromDTO(config);
notificationDispatcherService.setUserConfig(NotificationDispatcherName.NTFY, toSave);
return ResponseEntity.ok(new MessageResponse("Success"));
}
@GetMapping("/config/gotify")
public GotifyNotificationDispatcherConfigDTO getGotifyConfig() {
final GotifyNotificationDispatcherConfig result =
(GotifyNotificationDispatcherConfig) notificationDispatcherService.getUserConfig(NotificationDispatcherName.GOTIFY)
.orElse(new GotifyNotificationDispatcherConfig());
return gotifyNotificationDispatcherDTOConverter.convertToDTO(result);
}
@PostMapping("/config/gotify")
public ResponseEntity<MessageResponse> setGotifyConfig(@RequestBody GotifyNotificationDispatcherConfigDTO config) {
final AbstractNotificationDispatcherConfig toSave = gotifyNotificationDispatcherDTOConverter.convertFromDTO(config);
notificationDispatcherService.setUserConfig(NotificationDispatcherName.GOTIFY, toSave);
return ResponseEntity.ok(new MessageResponse("Success"));
}
}

View File

@ -1,8 +0,0 @@
package com.github.mdeluise.plantit.notification.dispatcher;
public enum NotificationDispatcherName {
CONSOLE,
EMAIL,
GOTIFY,
NTFY
}

View File

@ -1,78 +0,0 @@
package com.github.mdeluise.plantit.notification.dispatcher;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.authentication.UserService;
import com.github.mdeluise.plantit.common.AuthenticatedUserService;
import com.github.mdeluise.plantit.notification.dispatcher.config.AbstractNotificationDispatcherConfig;
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfigImplRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatcherService {
private final AuthenticatedUserService authenticatedUserService;
private final UserService userService;
private final List<NotificationDispatcher> notificationDispatchers;
private final NotificationDispatcherConfigImplRepository notificationDispatcherConfigImplRepository;
@Autowired
public NotificationDispatcherService(AuthenticatedUserService authenticatedUserService, UserService userService,
List<NotificationDispatcher> listNotificationDispatchers,
NotificationDispatcherConfigImplRepository notificationDispatcherConfigImplRepository) {
this.authenticatedUserService = authenticatedUserService;
this.userService = userService;
this.notificationDispatchers = listNotificationDispatchers;
this.notificationDispatcherConfigImplRepository = notificationDispatcherConfigImplRepository;
}
public void setNotificationDispatchersForUser(Set<NotificationDispatcherName> notificationDispatchers) {
final User authenticatedUser = authenticatedUserService.getAuthenticatedUser();
authenticatedUser.setNotificationDispatchers(notificationDispatchers);
userService.save(authenticatedUser);
}
public Collection<NotificationDispatcherName> getNotificationDispatchersForUser() {
final User authenticatedUser = authenticatedUserService.getAuthenticatedUser();
return authenticatedUser.getNotificationDispatchers();
}
public Collection<NotificationDispatcherName> getAvailableNotificationDispatchers() {
return notificationDispatchers.stream()
.filter(NotificationDispatcher::isEnabled)
.map(NotificationDispatcher::getName)
.collect(Collectors.toSet());
}
public Optional<AbstractNotificationDispatcherConfig> getUserConfig(NotificationDispatcherName name) {
return notificationDispatcherConfigImplRepository.findByServiceAndUser(
name, authenticatedUserService.getAuthenticatedUser());
}
public void setUserConfig(NotificationDispatcherName name, AbstractNotificationDispatcherConfig updated) {
final User authenticatedUser = authenticatedUserService.getAuthenticatedUser();
final Optional<AbstractNotificationDispatcherConfig> existingConfig =
notificationDispatcherConfigImplRepository.findByServiceAndUser(name, authenticatedUser);
AbstractNotificationDispatcherConfig toSave;
if (existingConfig.isPresent()) {
existingConfig.get().update(updated);
toSave = existingConfig.get();
} else {
updated.setUser(authenticatedUser);
updated.setServiceName(name);
toSave = updated;
}
notificationDispatcherConfigImplRepository.save(toSave);
}
}

View File

@ -1,63 +0,0 @@
package com.github.mdeluise.plantit.notification.dispatcher.config;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.validation.constraints.NotNull;
@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class AbstractNotificationDispatcherConfig implements NotificationDispatcherConfig {
@Id
@GeneratedValue
private Long id;
@NotNull
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;
@NotNull
@Enumerated(EnumType.STRING)
private NotificationDispatcherName service;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
@Override
public User getUser() {
return user;
}
@Override
public void setUser(User user) {
this.user = user;
}
@Override
public NotificationDispatcherName getServiceName() {
return service;
}
@Override
public void setServiceName(NotificationDispatcherName name) {
this.service = name;
}
}

View File

@ -1,16 +0,0 @@
package com.github.mdeluise.plantit.notification.dispatcher.config;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
public interface NotificationDispatcherConfig {
User getUser();
void setUser(User user);
NotificationDispatcherName getServiceName();
void setServiceName(NotificationDispatcherName name);
void update(NotificationDispatcherConfig updated);
}

View File

@ -1,13 +0,0 @@
package com.github.mdeluise.plantit.notification.dispatcher.config;
import java.util.Optional;
import com.github.mdeluise.plantit.authentication.User;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface NotificationDispatcherConfigImplRepository extends JpaRepository<AbstractNotificationDispatcherConfig, Long> {
Optional<AbstractNotificationDispatcherConfig> findByServiceAndUser(NotificationDispatcherName service, User user);
}

View File

@ -1,7 +0,0 @@
package com.github.mdeluise.plantit.notification.email;
public class EmailException extends Exception {
public EmailException(Throwable cause) {
super(cause);
}
}

View File

@ -1,243 +0,0 @@
package com.github.mdeluise.plantit.notification.email;
import com.github.mdeluise.plantit.notification.NotifyException;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcher;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
import com.github.mdeluise.plantit.notification.otp.OtpService;
import com.github.mdeluise.plantit.notification.password.TemporaryPasswordService;
import com.github.mdeluise.plantit.reminder.Reminder;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.Transport;
import jakarta.mail.internet.MimeMessage;
import org.apache.logging.log4j.util.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
@Service
public class EmailService implements NotificationDispatcher {
private final JavaMailSender emailSender;
private final SpringTemplateEngine templateEngine;
private final OtpService otpService;
private final TemporaryPasswordService temporaryPasswordService;
private final String contactEmail;
private final boolean enabled;
private final String from;
private final Logger logger = LoggerFactory.getLogger(EmailService.class);
@SuppressWarnings("ParameterNumber") //FIXME
@Autowired
public EmailService(JavaMailSender emailSender, SpringTemplateEngine templateEngine, OtpService otpService,
TemporaryPasswordService temporaryPasswordService,
@Value("${server.owner.contact}") String contactEmail,
@Value("${spring.mail.host}") String smtpHost,
@Value("${spring.mail.username}") String from) throws EmailException {
this.emailSender = emailSender;
this.templateEngine = templateEngine;
this.otpService = otpService;
this.temporaryPasswordService = temporaryPasswordService;
this.contactEmail = contactEmail;
this.enabled = Strings.isNotEmpty(smtpHost);
this.from = from;
if (isEnabled()) {
checkConnection();
}
}
public void sendOtpMessage(String username, String to) throws EmailException {
final MimeMessage message = emailSender.createMimeMessage();
final MimeMessageHelper helper = createMessageHelper(message, to, "Welcome to Plant-It: Confirm Your Account");
final String otpCode = otpService.generateNew(to);
final Context context = new Context();
context.setVariable("supportEmail", contactEmail);
context.setVariable("username", username);
context.setVariable("otpCode", otpCode);
final String emailContent = getEmailContent("signup.html", context);
try {
helper.setText(emailContent, true);
} catch (MessagingException e) {
logger.error("Error while set text of the email", e);
otpService.remove(otpCode);
throw new RuntimeException(e);
}
emailSender.send(message);
}
private String getEmailContent(String templateName, Context context) throws EmailException {
try {
return templateEngine.process(templateName, context);
} catch (Exception e) {
logger.error("Error while processing email template {}", templateName, e);
throw new EmailException(e);
}
}
public void checkConnection() throws EmailException {
final JavaMailSenderImpl emailSenderImpl = (JavaMailSenderImpl) emailSender;
final Session session = emailSenderImpl.getSession();
try {
final Transport transport = session.getTransport("smtp");
transport.connect(emailSenderImpl.getHost(), emailSenderImpl.getUsername(), emailSenderImpl.getPassword());
transport.close();
logger.info("SMTP successfully connected.");
} catch (MessagingException e) {
logger.error("SMTP connection failed.", e);
throw new EmailException(e);
}
}
public void sendTemporaryPasswordMessage(String username, String to) throws EmailException {
final MimeMessage message = emailSender.createMimeMessage();
final MimeMessageHelper helper = createMessageHelper(message, to, "Password reset");
final String temporaryPassword = temporaryPasswordService.generateNew(username);
final Context context = new Context();
context.setVariable("supportEmail", contactEmail);
context.setVariable("username", username);
context.setVariable("temporaryPassword", temporaryPassword);
final String emailContent = getEmailContent("resetPsw.html", context);
try {
helper.setText(emailContent, true);
} catch (MessagingException e) {
logger.error("Error while set text of the email", e);
otpService.remove(temporaryPassword);
throw new RuntimeException(e);
}
emailSender.send(message);
}
public void sendEmailChangeNotification(String username, String newEmail) throws EmailException {
final MimeMessage message = emailSender.createMimeMessage();
final MimeMessageHelper helper = createMessageHelper(message, newEmail, "Email change");
final Context context = new Context();
context.setVariable("supportEmail", contactEmail);
context.setVariable("username", username);
context.setVariable("newEmail", newEmail);
final String emailContent = getEmailContent("emailChange.html", context);
try {
helper.setText(emailContent, true);
} catch (MessagingException e) {
logger.error("Error while set text of the email", e);
throw new RuntimeException(e);
}
emailSender.send(message);
}
public void sendPasswordChangeNotification(String username, String email) throws EmailException {
final MimeMessage message = emailSender.createMimeMessage();
final MimeMessageHelper helper = createMessageHelper(message, email, "Password change");
final Context context = new Context();
context.setVariable("supportEmail", contactEmail);
context.setVariable("username", username);
final String emailContent = getEmailContent("passwordChange.html", context);
try {
helper.setText(emailContent, true);
} catch (MessagingException e) {
logger.error("Error while set text of the email", e);
throw new EmailException(e);
}
emailSender.send(message);
}
@Override
public void notifyReminder(Reminder reminder) throws NotifyException {
final MimeMessage message = emailSender.createMimeMessage();
final String email = reminder.getTarget().getOwner().getEmail();
final String subject = "Reminder: Time to Care for " + reminder.getTarget().getInfo().getPersonalName();
final MimeMessageHelper helper;
try {
helper = createMessageHelper(message, email, subject);
} catch (EmailException e) {
logger.error("Error while notify about the reminder", e);
throw new NotifyException(e);
}
final Context context = new Context();
context.setVariable("username", reminder.getTarget().getOwner().getUsername());
context.setVariable("plantName", reminder.getTarget().getInfo().getPersonalName());
context.setVariable("action", reminder.getAction());
final String emailContent;
try {
emailContent = getEmailContent("reminder.html", context);
} catch (EmailException e) {
logger.error("Error while get the email template content", e);
throw new NotifyException(e);
}
try {
helper.setText(emailContent, true);
} catch (MessagingException e) {
logger.error("Error while set text of the email", e);
throw new NotifyException(e);
}
emailSender.send(message);
}
@Override
public NotificationDispatcherName getName() {
return NotificationDispatcherName.EMAIL;
}
@Override
public boolean isEnabled() {
return enabled;
}
@Override
public void loadConfig(NotificationDispatcherConfig config) {
}
@Override
public void initConfig() {
}
private MimeMessageHelper createMessageHelper(MimeMessage mimeMessage, String to, String subject)
throws EmailException {
final MimeMessageHelper helper;
try {
helper = new MimeMessageHelper(mimeMessage, true);
helper.setTo(to);
helper.setSubject(subject);
helper.setFrom(from);
} catch (MessagingException e) {
logger.error("Error while setting mail to send", e);
throw new EmailException(e);
}
return helper;
}
}

View File

@ -1,98 +0,0 @@
package com.github.mdeluise.plantit.notification.gotify;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import com.github.mdeluise.plantit.notification.NotifyException;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcher;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
import com.github.mdeluise.plantit.reminder.Reminder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
@Component
public class GotifyNotificationDispatcher implements NotificationDispatcher {
private final boolean enabled;
private String url;
private String token;
private final Logger logger = LoggerFactory.getLogger(GotifyNotificationDispatcher.class);
public GotifyNotificationDispatcher(@Value("${server.notification.gotify.enabled}") boolean enabled) {
this.enabled = enabled;
}
public void notifyReminder(Reminder reminder) throws NotifyException {
final HttpClient httpClient = HttpClient.newHttpClient();
final URI uri = URI.create(url).resolve("/message?token=" + token);
final String title = reminder.getTarget().getInfo().getPersonalName();
final String message = "Time to take care of " + title + ", action required: " + reminder.getAction();
final String body = String.format("{\"title\": \"%s\", \"message\": \"%s\"}", title, message);
final HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header(HttpHeaders.CONTENT_TYPE, "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
try {
final HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
final int statusCode = response.statusCode();
if (statusCode < 200 || statusCode >= 300) {
throw new NotifyException("Failed to send notification. Response code: " + statusCode +
" Response body: " + response.body());
}
} catch (Exception e) {
throw new NotifyException(e);
}
}
@Override
public NotificationDispatcherName getName() {
return NotificationDispatcherName.GOTIFY;
}
@Override
public boolean isEnabled() {
return enabled;
}
@Override
public void loadConfig(NotificationDispatcherConfig config) {
if (!(config instanceof GotifyNotificationDispatcherConfig gotifyConfig)) {
throw new UnsupportedOperationException(
"Configuration provided must be of type GotifyNotificationDispatcherConfig");
}
checkConfigParameters(gotifyConfig);
url = gotifyConfig.getUrl();
token = gotifyConfig.getToken();
}
@Override
public void initConfig() {
url = null;
token = null;
}
private void checkConfigParameters(GotifyNotificationDispatcherConfig gotifyConfig) {
if (gotifyConfig.getUrl() == null || gotifyConfig.getToken() == null) {
final String errorMsg = "Gotify url and token must be provided.";
logger.error(errorMsg);
throw new IllegalArgumentException(errorMsg);
}
}
}

View File

@ -1,44 +0,0 @@
package com.github.mdeluise.plantit.notification.gotify;
import com.github.mdeluise.plantit.notification.dispatcher.config.AbstractNotificationDispatcherConfig;
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "gotify_notification_configs")
public class GotifyNotificationDispatcherConfig extends AbstractNotificationDispatcherConfig {
private String url;
private String token;
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
@Override
public void update(NotificationDispatcherConfig updated) {
if (!(updated instanceof GotifyNotificationDispatcherConfig)) {
throw new UnsupportedOperationException("Updated class must be of type GotifyNotificationDispatcherConfig");
}
final GotifyNotificationDispatcherConfig gotifyUpdated = (GotifyNotificationDispatcherConfig) updated;
this.setUrl(gotifyUpdated.getUrl());
this.setToken(gotifyUpdated.getToken());
}
}

View File

@ -1,43 +0,0 @@
package com.github.mdeluise.plantit.notification.gotify;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "Gotify notification config", description = "Represents the gotify notifier configuration")
public class GotifyNotificationDispatcherConfigDTO {
@Schema(description = "id of the config", accessMode = Schema.AccessMode.READ_ONLY)
private String id;
@Schema(description = "url of the gotify server")
private String url;
@Schema(description = "token for auth in the gotify server")
private String token;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
}

View File

@ -1,27 +0,0 @@
package com.github.mdeluise.plantit.notification.gotify;
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Component;
@Component
public class GotifyNotificationDispatcherDTOConverter extends
AbstractDTOConverter<GotifyNotificationDispatcherConfig, GotifyNotificationDispatcherConfigDTO> {
public GotifyNotificationDispatcherDTOConverter(ModelMapper modelMapper) {
super(modelMapper);
}
@Override
public GotifyNotificationDispatcherConfig convertFromDTO(
GotifyNotificationDispatcherConfigDTO dto) {
return modelMapper.map(dto, GotifyNotificationDispatcherConfig.class);
}
@Override
public GotifyNotificationDispatcherConfigDTO convertToDTO(
GotifyNotificationDispatcherConfig data) {
return modelMapper.map(data, GotifyNotificationDispatcherConfigDTO.class);
}
}

View File

@ -1,123 +0,0 @@
package com.github.mdeluise.plantit.notification.ntfy;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import com.github.mdeluise.plantit.notification.NotifyException;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcher;
import com.github.mdeluise.plantit.notification.dispatcher.NotificationDispatcherName;
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
import com.github.mdeluise.plantit.reminder.Reminder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
@Component
public class NtfyNotificationDispatcher implements NotificationDispatcher {
private final boolean enabled;
private String url;
private String topic;
private String username;
private String password;
private String token;
private final Logger logger = LoggerFactory.getLogger(NtfyNotificationDispatcher.class);
public NtfyNotificationDispatcher(@Value("${server.notification.ntfy.enabled}") boolean enabled) {
this.enabled = enabled;
}
public void notifyReminder(Reminder reminder) throws NotifyException {
final HttpClient httpClient = HttpClient.newHttpClient();
final HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(url).resolve(topic))
.header(HttpHeaders.CONTENT_TYPE, "application/json")
.header("X-Title",
reminder.getTarget().getInfo().getPersonalName())
.header("X-Tags", "seedling");
final String requestBody =
"Time to take care of " + reminder.getTarget().getInfo().getPersonalName() + ", action required: " +
reminder.getAction();
final HttpRequest.BodyPublisher bodyPublisher =
HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8);
requestBuilder.method("POST", bodyPublisher);
if (username != null && password != null) {
final String credentials = username + ":" + password;
final String basicAuth = "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes());
requestBuilder.header(HttpHeaders.AUTHORIZATION, basicAuth);
} else if (token != null) {
requestBuilder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token);
}
try {
final HttpRequest httpRequest = requestBuilder.build();
final HttpResponse<String> response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
int statusCode = response.statusCode();
if (statusCode < 200 || statusCode >= 300) {
throw new NotifyException("Failed to send notification. Response code: " + statusCode);
}
} catch (Exception e) {
throw new NotifyException(e);
}
}
@Override
public NotificationDispatcherName getName() {
return NotificationDispatcherName.NTFY;
}
@Override
public boolean isEnabled() {
return enabled;
}
@Override
public void loadConfig(NotificationDispatcherConfig config) {
if (!(config instanceof NtfyNotificationDispatcherConfig ntfyConfig)) {
throw new UnsupportedOperationException(
"Configuration provided must be of type NtfyNotificationDispatcherConfig");
}
checkConfigParameters(ntfyConfig);
url = ntfyConfig.getUrl();
topic = ntfyConfig.getTopic();
username = ntfyConfig.getUsername();
password = ntfyConfig.getPassword();
token = ntfyConfig.getToken();
}
@Override
public void initConfig() {
url = null;
topic = null;
username = null;
password = null;
token = null;
}
private void checkConfigParameters(NtfyNotificationDispatcherConfig ntfyConfig) {
if (ntfyConfig.getUsername() != null ^ ntfyConfig.getPassword() != null) {
final String errorMsg = "Either both username and password must be provided or both left empty.";
logger.error(errorMsg);
throw new IllegalArgumentException(errorMsg);
}
if (ntfyConfig.getUrl() == null || ntfyConfig.getTopic() == null) {
final String errorMsg = "NTFY url and topic must be provided.";
logger.error(errorMsg);
throw new IllegalArgumentException(errorMsg);
}
}
}

View File

@ -1,80 +0,0 @@
package com.github.mdeluise.plantit.notification.ntfy;
import com.github.mdeluise.plantit.notification.dispatcher.config.AbstractNotificationDispatcherConfig;
import com.github.mdeluise.plantit.notification.dispatcher.config.NotificationDispatcherConfig;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "ntfy_notification_configs")
public class NtfyNotificationDispatcherConfig extends AbstractNotificationDispatcherConfig {
private String url;
private String topic;
private String username;
private String password;
private String token;
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getTopic() {
return topic;
}
public void setTopic(String topic) {
this.topic = topic;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
@Override
public void update(NotificationDispatcherConfig updated) {
if (!(updated instanceof NtfyNotificationDispatcherConfig)) {
throw new UnsupportedOperationException("Updated class must be of type NtfyNotificationDispatcherConfig");
}
final NtfyNotificationDispatcherConfig ntfyUpdated = (NtfyNotificationDispatcherConfig) updated;
this.setUrl(ntfyUpdated.getUrl());
this.setTopic(ntfyUpdated.getTopic());
this.setUsername(ntfyUpdated.getUsername());
this.setPassword(ntfyUpdated.getPassword());
this.setToken(ntfyUpdated.getToken());
}
}

View File

@ -1,79 +0,0 @@
package com.github.mdeluise.plantit.notification.ntfy;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "Ntfy notification config", description = "Represents the ntfy notifier configuration")
public class NtfyNotificationDispatcherConfigDTO {
@Schema(description = "id of the config", accessMode = Schema.AccessMode.READ_ONLY)
private String id;
@Schema(description = "url of the ntfy server")
private String url;
@Schema(description = "topic of the ntfy server")
private String topic;
@Schema(description = "username for auth in the ntfy server")
private String username;
@Schema(description = "password for auth in the ntfy server")
private String password;
@Schema(description = "token for auth in the ntfy server")
private String token;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getTopic() {
return topic;
}
public void setTopic(String topic) {
this.topic = topic;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
}

View File

@ -1,25 +0,0 @@
package com.github.mdeluise.plantit.notification.ntfy;
import com.github.mdeluise.plantit.common.AbstractDTOConverter;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Component;
@Component
public class NtfyNotificationDispatcherDTOConverter extends
AbstractDTOConverter<NtfyNotificationDispatcherConfig, NtfyNotificationDispatcherConfigDTO> {
public NtfyNotificationDispatcherDTOConverter(ModelMapper modelMapper) {
super(modelMapper);
}
@Override
public NtfyNotificationDispatcherConfig convertFromDTO(NtfyNotificationDispatcherConfigDTO dto) {
return modelMapper.map(dto, NtfyNotificationDispatcherConfig.class);
}
@Override
public NtfyNotificationDispatcherConfigDTO convertToDTO(NtfyNotificationDispatcherConfig data) {
return modelMapper.map(data, NtfyNotificationDispatcherConfigDTO.class);
}
}

View File

@ -1,74 +0,0 @@
package com.github.mdeluise.plantit.notification.otp;
import java.util.Date;
import java.util.Objects;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.hibernate.validator.constraints.Length;
@Entity
@Table(name = "otp_codes")
public class Otp {
@Id
@Length(min = 5, max = 70)
private String email;
@NotEmpty
@Length(min = 5, max = 10)
private String code;
@NotNull
private Date expiration;
public String getCode() {
return code;
}
public void setCode(String id) {
this.code = id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Date getExpiration() {
return expiration;
}
public void setExpiration(Date expiration) {
this.expiration = expiration;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final Otp otp = (Otp) o;
return Objects.equals(email, otp.email) && Objects.equals(code, otp.code) &&
Objects.equals(expiration, otp.expiration);
}
@Override
public int hashCode() {
return Objects.hash(email, code, expiration);
}
}

View File

@ -1,29 +0,0 @@
package com.github.mdeluise.plantit.notification.otp;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import org.springframework.stereotype.Component;
@Component
public class OtpGenerator {
private final String otpChars = "0123456789";
private final SecureRandom random = new SecureRandom();
public Otp generateOTP(int length, int expireMins, String email) {
final Otp otp = new Otp();
otp.setEmail(email);
final StringBuilder otpCode = new StringBuilder(length);
for (int i = 0; i < length; i++) {
otpCode.append(otpChars.charAt(random.nextInt(otpChars.length())));
}
otp.setCode(otpCode.toString());
final LocalDateTime currentDateTime = LocalDateTime.now();
final LocalDateTime newDateTime = currentDateTime.plusMinutes(expireMins);
otp.setExpiration(Date.from(newDateTime.atZone(ZoneId.systemDefault()).toInstant()));
return otp;
}
}

Some files were not shown because too many files have changed in this diff Show More