From cbd83036461882c5fd0981a4d0e510799568f36d Mon Sep 17 00:00:00 2001 From: UsatovPavel Date: Wed, 20 Aug 2025 00:24:33 +0300 Subject: [PATCH 1/9] Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-compose.yaml и Dockerfile для запуска приложения в контейнере. JWT_SECRET и CHATGPT_API_KEY передаются через файл .env в общей папке. spring.sql.init.mode=never в test-properties и там где properties т.к. в Docker таблицы пересоздавались hibernate Удалил data.sql и sqema.sql, их логика в DataInitializer, теперь hibernate полностью сам генерирует таблицы. Использование: для удаления базы и рестарта: скрипт на shell, для запуска контейнера: через плагин docker отдельная конфигурация в idea. --- Dockerfile | 9 +++++ docker-compose.yaml | 38 +++++++++++++++++++ .../smartcalendar/config/DataInitializer.java | 30 +++++++++++++++ .../resources/application-test.properties | 3 +- src/main/resources/application.properties | 6 ++- src/main/resources/data.sql | 4 -- src/main/resources/schema.sql | 6 --- .../AudioControllerIntegrationTest.java | 4 +- .../ChatGPTControllerIntegrationTest.java | 3 +- 9 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yaml create mode 100644 src/main/java/com/smartcalendar/config/DataInitializer.java delete mode 100644 src/main/resources/data.sql delete mode 100644 src/main/resources/schema.sql diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8fac962 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM eclipse-temurin:21-jdk-alpine +WORKDIR /app + +COPY build/libs/*.jar app.jar +RUN ls -la /app + +ENV JAVA_OPTS="" + +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..a112c49 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,38 @@ +services: + postgres: + image: postgres:15 + container_name: smartcalendar_db + environment: + POSTGRES_DB: smartcalendar + POSTGRES_USER: smartuser + POSTGRES_PASSWORD: smartpass + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - backend + + app: + build: + context: . + dockerfile: Dockerfile + container_name: smartcalendar_app + depends_on: + - postgres + ports: + - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/smartcalendar + SPRING_DATASOURCE_USERNAME: smartuser + SPRING_DATASOURCE_PASSWORD: smartpass + JWT_SECRET: ${JWT_SECRET} + CHATGPT_API_KEY: ${CHATGPT_API_KEY} + networks: + - backend + +volumes: + postgres_data: + +networks: + backend: \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/config/DataInitializer.java b/src/main/java/com/smartcalendar/config/DataInitializer.java new file mode 100644 index 0000000..223c74a --- /dev/null +++ b/src/main/java/com/smartcalendar/config/DataInitializer.java @@ -0,0 +1,30 @@ +package com.smartcalendar.config; + +import com.smartcalendar.model.User; +import com.smartcalendar.repository.UserRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DataInitializer { + + @Bean + CommandLineRunner initUsers(UserRepository userRepository) { + return args -> { + if (userRepository.count() == 0) { + User admin = new User(); + admin.setUsername("admin"); + admin.setEmail("admin@example.com"); + admin.setPassword("encoded_password"); // сюда поставь реальный зашифрованный пароль + userRepository.save(admin); + + User user = new User(); + user.setUsername("user"); + user.setEmail("user@example.com"); + user.setPassword("encoded_password"); + userRepository.save(user); + } + }; + } +} \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index 8797063..f1fab6c 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -3,4 +3,5 @@ spring.jpa.hibernate.ddl-auto=create-drop spring.datasource.url=jdbc:h2:mem:testdb_test chatgpt.api.url=http://dummy-url chatgpt.api.key=dummy-key -JWT_SECRET=your_test_secret \ No newline at end of file +JWT_SECRET=your_test_secret +spring.sql.init.mode=never diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6f4a937..1fd42b6 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,7 +2,7 @@ # DATABASE (H2) # =============================== spring.datasource.url=jdbc:h2:mem:smartcalendar;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -spring.datasource.driver-class-name=org.h2.Driver +# spring.datasource.driver-class-name=org.h2.Driver - по дефолту h2, иначе тот что зададим spring.datasource.username=sa spring.datasource.password= @@ -67,4 +67,6 @@ spring.mail.username=dimarus06122005@gmail.com spring.mail.password=${MAIL_PASSWORD} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true -spring.mail.from=noreply@ttsc.com \ No newline at end of file +spring.mail.from=noreply@ttsc.com + +spring.jpa.hibernate.ddl-auto=update diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql deleted file mode 100644 index 16d763c..0000000 --- a/src/main/resources/data.sql +++ /dev/null @@ -1,4 +0,0 @@ -INSERT INTO users (username, email, password) -VALUES -('admin', 'admin@example.com', 'encoded_password'), -('user', 'user@example.com', 'encoded_password'); \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql deleted file mode 100644 index 0971f93..0000000 --- a/src/main/resources/schema.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL, - password VARCHAR(255) NOT NULL -); \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java index aa8d869..05129c6 100644 --- a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java @@ -12,6 +12,7 @@ import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.util.List; @@ -25,7 +26,8 @@ "JWT_SECRET=test_jwt_secret", "chatgpt.api.url=http://dummy-url", "chatgpt.api.key=dummy-key", - "spring.security.enabled=false" + "spring.security.enabled=false", + "spring.sql.init.mode=never" }) @AutoConfigureMockMvc class AudioControllerIntegrationTest { diff --git a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java index 77fe507..a04d2e9 100644 --- a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java @@ -23,7 +23,8 @@ "JWT_SECRET=test_jwt_secret", "chatgpt.api.url=http://dummy-url", "chatgpt.api.key=dummy-key", - "spring.security.enabled=false" + "spring.security.enabled=false", + "spring.sql.init.mode=never" }) @AutoConfigureMockMvc class ChatGPTControllerIntegrationTest { From 279d9d80f82adc597217fc2a11c118d57b1bb97b Mon Sep 17 00:00:00 2001 From: Usatov Pavel Date: Fri, 23 Jan 2026 22:07:33 +0300 Subject: [PATCH 2/9] Revert "Merge remote-tracking branch 'upstream/main'" This reverts commit ec05ead2e5c3229a1c6b143cf188e42504acfd64, reversing changes made to e3eea41dd754b67982db444462087c99164508e3. --- .github/workflows/ci.yml | 13 +- .gitignore | 3 - LICENSE.txt | 674 ------------------ README.md | 179 +---- build.gradle | 11 +- .../smartcalendar/config/SecurityConfig.java | 5 +- .../controller/AuthController.java | 68 +- .../controller/ChatGPTController.java | 9 +- .../controller/EventController.java | 35 - .../controller/FriendshipController.java | 55 -- .../controller/StatisticsController.java | 31 +- .../controller/UserController.java | 350 +-------- .../dto/AddCollaborativeEventRequest.java | 10 - .../smartcalendar/dto/AverageDayTimeDto.java | 15 - ...rShortDto.java => AverageDayTimeVars.java} | 5 +- .../dto/ContinuesSuccessDaysDto.java | 13 - .../com/smartcalendar/dto/DailyTaskDto.java | 24 - .../java/com/smartcalendar/dto/EventDto.java | 25 - .../dto/RegistrationRequest.java | 11 - .../com/smartcalendar/dto/StatisticsData.java | 19 - .../dto/SubscriptionRequest.java | 14 - .../com/smartcalendar/dto/TodayTimeDto.java | 13 - ...kTypesDto.java => TotalTimeTaskTypes.java} | 8 +- .../exceptions/ConflictException.java | 11 - .../exceptions/ResourceNotFoundException.java | 12 - .../java/com/smartcalendar/model/Event.java | 58 +- .../com/smartcalendar/model/Friendship.java | 38 - .../com/smartcalendar/model/GroupChat.java | 37 - .../com/smartcalendar/model/GroupMessage.java | 37 - .../com/smartcalendar/model/PrivateChat.java | 36 - .../smartcalendar/model/PrivateMessage.java | 37 - .../com/smartcalendar/model/Statistics.java | 38 - .../java/com/smartcalendar/model/Tag.java | 34 - .../java/com/smartcalendar/model/Task.java | 15 +- .../java/com/smartcalendar/model/User.java | 72 +- .../repository/EventRepository.java | 6 - .../repository/FriendshipRepository.java | 14 - .../repository/StatisticsRepository.java | 12 - .../repository/TaskRepository.java | 3 +- .../smartcalendar/service/ChatGPTService.java | 93 +-- .../smartcalendar/service/EventService.java | 65 -- .../service/FriendshipService.java | 60 -- .../service/NotificationService.java | 35 - .../service/StatisticsService.java | 142 +--- .../smartcalendar/service/UserService.java | 335 +-------- src/main/resources/application-h2.properties | 71 -- .../resources/application-test.properties | 12 +- src/main/resources/application.properties | 28 +- src/main/resources/schema.sql | 69 +- .../config/TestSecurityConfig.java | 47 -- .../AudioControllerIntegrationTest.java | 82 --- .../controller/AuthControllerTest.java | 48 +- .../ChatGPTControllerIntegrationTest.java | 82 --- .../StatisticsControllerIntegrationTest.java | 125 ---- .../UserControllerIntegrationTest.java | 545 -------------- .../service/ChatGPTServiceTest.java | 54 -- .../service/StatisticsServiceTest.java | 177 ----- .../service/UserServiceTest.java | 231 +----- 58 files changed, 149 insertions(+), 4202 deletions(-) delete mode 100644 LICENSE.txt delete mode 100644 src/main/java/com/smartcalendar/controller/EventController.java delete mode 100644 src/main/java/com/smartcalendar/controller/FriendshipController.java delete mode 100644 src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java delete mode 100644 src/main/java/com/smartcalendar/dto/AverageDayTimeDto.java rename src/main/java/com/smartcalendar/dto/{UserShortDto.java => AverageDayTimeVars.java} (57%) delete mode 100644 src/main/java/com/smartcalendar/dto/ContinuesSuccessDaysDto.java delete mode 100644 src/main/java/com/smartcalendar/dto/DailyTaskDto.java delete mode 100644 src/main/java/com/smartcalendar/dto/EventDto.java delete mode 100644 src/main/java/com/smartcalendar/dto/RegistrationRequest.java delete mode 100644 src/main/java/com/smartcalendar/dto/StatisticsData.java delete mode 100644 src/main/java/com/smartcalendar/dto/SubscriptionRequest.java delete mode 100644 src/main/java/com/smartcalendar/dto/TodayTimeDto.java rename src/main/java/com/smartcalendar/dto/{TotalTimeTaskTypesDto.java => TotalTimeTaskTypes.java} (70%) delete mode 100644 src/main/java/com/smartcalendar/exceptions/ConflictException.java delete mode 100644 src/main/java/com/smartcalendar/exceptions/ResourceNotFoundException.java delete mode 100644 src/main/java/com/smartcalendar/model/Friendship.java delete mode 100644 src/main/java/com/smartcalendar/model/GroupChat.java delete mode 100644 src/main/java/com/smartcalendar/model/GroupMessage.java delete mode 100644 src/main/java/com/smartcalendar/model/PrivateChat.java delete mode 100644 src/main/java/com/smartcalendar/model/PrivateMessage.java delete mode 100644 src/main/java/com/smartcalendar/model/Statistics.java delete mode 100644 src/main/java/com/smartcalendar/model/Tag.java delete mode 100644 src/main/java/com/smartcalendar/repository/FriendshipRepository.java delete mode 100644 src/main/java/com/smartcalendar/repository/StatisticsRepository.java delete mode 100644 src/main/java/com/smartcalendar/service/EventService.java delete mode 100644 src/main/java/com/smartcalendar/service/FriendshipService.java delete mode 100644 src/main/java/com/smartcalendar/service/NotificationService.java delete mode 100644 src/main/resources/application-h2.properties delete mode 100644 src/test/java/com/smartcalendar/config/TestSecurityConfig.java delete mode 100644 src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java delete mode 100644 src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java delete mode 100644 src/test/java/com/smartcalendar/controller/StatisticsControllerIntegrationTest.java delete mode 100644 src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java delete mode 100644 src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java delete mode 100644 src/test/java/com/smartcalendar/service/StatisticsServiceTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d53356c..2f5aa3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,19 +34,10 @@ jobs: run: chmod +x ./gradlew - name: Build and test - run: ./gradlew clean build --info --console=plain - - - name: Generate license report - run: ./gradlew generateLicenseReport + run: ./gradlew clean build - name: Upload test results uses: actions/upload-artifact@v4 with: name: test-results - path: build/test-results/test - - - name: Upload license report - uses: actions/upload-artifact@v4 - with: - name: license-report - path: build/reports/dependency-license \ No newline at end of file + path: build/test-results/test \ No newline at end of file diff --git a/.gitignore b/.gitignore index ca2bfc8..63d2f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,3 @@ out/ ### VS Code ### .vscode/ - -### Firebase Service Account ### -timetamer-smarcalendar-firebase-adminsdk-fbsvc-8be370036a.json diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index f288702..0000000 --- a/LICENSE.txt +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/README.md b/README.md index 5dd5358..675478f 100644 --- a/README.md +++ b/README.md @@ -1,178 +1 @@ -# "TimeTamer" SmartCalendar Server - -Spring Boot backend for the "TimeTamer" SmartCalendar application. Provides REST API for task/event management, user statistics, and OpenAI integration. - ---- - -## Key Features -- **User Management**: Registration, authentication, and profile updates -- **Task & Event Operations**: Full CRUD functionality with status tracking -- **JWT Authentication**: Secure token-based access control -- **OpenAI Integration**: - - ChatGPT for natural language processing - - Whisper for speech-to-text -- **Automated Documentation**: Swagger UI for interactive API exploration -- **CI/CD Pipeline**: GitHub Actions for automated testing and deployment - ---- - -## Tech Stack -- **Language**: Java 21 -- **Framework**: Spring Boot 3.1+ -- **Database**: - - PostgreSQL (Production) - - H2 (Development/Testing) -- **Build Tool**: Gradle 8+ - ---- - -## Prerequisites -- Java 21 JDK -- Gradle 8+ -- PostgreSQL 15+ (for production) -- OpenAI API key - ---- - -## Quick Start -1. Clone repository: - ```bash - git clone https://github.com/hse-project-Java-2025/server.git - cd smartcalendar-server - ``` -2. Set environment variables (create `.env` file): - ```ini - JWT_SECRET=your_strong_secret_here - CHATGPT_API_KEY=your_openai_api_key - MAIL_PASSWORD=your_smtp_app_password - ``` -3. Build and run: - ```bash - ./gradlew bootRun - ``` -4. Access resources: - - **Swagger UI**: `http://localhost:8080/swagger-ui.html` (complete API documentation) - - H2 Console: `http://localhost:8080/h2-console` (JDBC URL: `jdbc:h2:mem:testdb`) - ---- - -## Configuration - -### Essential Environment Variables -| Variable | Description | Example | -|-------------------|-------------------------------------|-----------------------------| -| `JWT_SECRET` | Secret for JWT token signing | `A$ecretKey!123` | -| `CHATGPT_API_KEY` | OpenAI API key | `sk-...` | -| `MAIL_PASSWORD` | SMTP app password for email sending | `your_app_password` | -| `DB_URL` | Production DB URL (optional) | `jdbc:postgresql://db:5432` | - - -### SMTP Email Notification Setup - -To enable email notifications (for collaborative events, invites, etc.), configure the following SMTP settings in your `application.properties`: - -| Property | Example Value | Description | -|------------------------------------------------|------------------------------|---------------------------------------------------------------------------------------------| -| `spring.mail.host` | `smtp.gmail.com` | SMTP server host (Gmail example) | -| `spring.mail.port` | `587` | SMTP server port (587 for TLS) | -| `spring.mail.username` | `your_email@gmail.com` | Email account used to send notifications | -| `spring.mail.password` | `${MAIL_PASSWORD}` | App password for the email account (set as environment variable) | -| `spring.mail.properties.mail.smtp.auth` | `true` | Enable SMTP authentication | -| `spring.mail.properties.mail.smtp.starttls.enable` | `true` | Enable STARTTLS encryption | -| `spring.mail.from` | `noreply@ttsc.com` | Sender address shown in emails (must match or be an alias for Gmail accounts) | - -**Important notes:** -- For Gmail, you must use an [App Password](https://support.google.com/accounts/answer/185833?hl=en) and have two-factor authentication enabled. -- The value of `spring.mail.from` will only be used if your SMTP provider allows it. Gmail requires this to match your authenticated account or a verified alias. -- For other SMTP providers, adjust the host, port, and credentials accordingly. - ---- - -## API Reference -### Core Endpoints Overview -Below are representative examples of available API endpoints. Complete and always up-to-date definitive API reference is automatically generated at runtime: -```http -http://localhost:8080/swagger-ui.html -``` - - -### User Management -| Endpoint | Method | Description | -|-----------------------------------|--------|------------------------------| -| `/api/users` | GET | List all users | -| `/api/users` | POST | Register new user | -| `/api/users/{id}` | GET | Get user details | -| `/api/users/{id}/email` | PUT | Update user email | -| `/api/users/{userId}/statistics` | GET | Get user statistics | - -### Task Management -| Endpoint | Method | Description | -|---------------------------------------|--------|------------------------------| -| `/api/users/{userId}/tasks` | GET | Get user's tasks | -| `/api/users/{userId}/tasks` | POST | Create new task | -| `/api/users/tasks/{taskId}/status` | PATCH | Update task status | -| `/api/users/tasks/{taskId}` | DELETE | Delete task | - -### Event Management (including Collaborative Events) -| Endpoint | Method | Description | -|-----------------------------------------------|--------|--------------------------------------------------| -| `/api/users/{userId}/events` | GET | Get user's events (including shared/collaborative)| -| `/api/users/{userId}/events` | POST | Create new event | -| `/api/users/events/{eventId}` | PATCH | Update event | -| `/api/users/events/{eventId}` | DELETE | Delete event | -| `/api/users/events/{eventId}/invite` | POST | Invite user to event (collaboration) | -| `/api/users/events/{eventId}/accept-invite` | POST | Accept event invitation | -| `/api/users/events/{eventId}/remove-invite` | POST | Remove invitation for user | -| `/api/users/events/{eventId}/remove-participant` | POST | Remove participant from event | -| `/api/users/me/invites` | GET | Get events you are invited to | - -### OpenAI Integration -| Endpoint | Method | Description | -|-------------------------------|--------|--------------------------------------| -| `/api/chatgpt/ask` | POST | Get ChatGPT response | -| `/api/chatgpt/generate` | POST | Generate calendar events/tasks | -| `/api/chatgpt/generate/entities` | POST | Generate entities from natural language | - ---- - -## Collaborative Events - -The SmartCalendar supports full collaboration on events: -- **Invite users** to your events by username or email. -- **Accept or decline invitations** to shared events. -- **Remove participants** or invitations at any time. -- **Automatic email notifications** are sent for all key actions (invitation, joining, removal, updates, deletion) with detailed event info. -- **All collaborative features are available via REST API** (see Event Management section above). - ---- - -## Testing -Run tests with: -```bash -./gradlew test -``` -- Uses separate in-memory H2 database -- External services (OpenAI) are mocked -- Test coverage reports: `build/reports/tests` - -### Postman Collection - -You can also test all endpoints and collaborative event scenarios using our [Postman collection](https://warped-spaceship-772679.postman.co/workspace/Team-Workspace~558e4b04-2021-4e54-894c-0ad8890eda3d/collection/43149440-fdb46307-d6af-4895-bd4b-5b871c1f6962?action=share&creator=43149440&active-environment=43149440-f5aa59ad-f5b0-484f-923a-2d9403843293) - -## CI/CD Pipeline -GitHub Actions workflow (`.github/workflows/ci.yml`): -1. Build with JDK 21 -2. Run all tests -3. Generate dependency license report -4. Upload test reports as artifacts - ---- - -## License -MIT License - see [LICENSE](LICENSE.txt) file - ---- - -## Contributors -- [Dmitry Rusanov](https://github.com/DimaRus05) -- [Mikhail Minaev](https://github.com/minmise) +# server \ No newline at end of file diff --git a/build.gradle b/build.gradle index c8ca27e..246a245 100644 --- a/build.gradle +++ b/build.gradle @@ -2,12 +2,10 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.3' id 'io.spring.dependency-management' version '1.1.7' - id 'com.github.jk1.dependency-license-report' version '2.8' - id 'com.adarshr.test-logger' version '4.0.0' } group = 'com.smartcalendar' -version = '0.0.3-SNAPSHOT' +version = '0.0.1-SNAPSHOT' java { toolchain { @@ -15,10 +13,6 @@ java { } } -testlogger { - theme 'mocha' -} - repositories { mavenCentral() } @@ -28,9 +22,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-mail' - implementation 'com.google.auth:google-auth-library-oauth2-http:1.19.0' - implementation 'com.google.api-client:google-api-client:2.2.0' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/smartcalendar/config/SecurityConfig.java b/src/main/java/com/smartcalendar/config/SecurityConfig.java index f67a9f0..e924fe2 100644 --- a/src/main/java/com/smartcalendar/config/SecurityConfig.java +++ b/src/main/java/com/smartcalendar/config/SecurityConfig.java @@ -5,7 +5,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; @@ -26,7 +25,6 @@ @Configuration @EnableWebSecurity @RequiredArgsConstructor -@Profile("!test") public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthFilter; private final UserService userService; @@ -62,7 +60,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/static/**", "/api/auth/login", "/api/auth/signup", - "/api/events", "/h2-console/**" ).permitAll() .requestMatchers( @@ -87,7 +84,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of("*")); + configuration.setAllowedOrigins(List.of("*")); //TODO: конкретные домены configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setExposedHeaders(List.of("Authorization")); diff --git a/src/main/java/com/smartcalendar/controller/AuthController.java b/src/main/java/com/smartcalendar/controller/AuthController.java index a1717fc..50306ba 100644 --- a/src/main/java/com/smartcalendar/controller/AuthController.java +++ b/src/main/java/com/smartcalendar/controller/AuthController.java @@ -1,13 +1,9 @@ package com.smartcalendar.controller; -import com.smartcalendar.dto.RegistrationRequest; import com.smartcalendar.model.User; import com.smartcalendar.service.JwtService; -import com.smartcalendar.service.StatisticsService; import com.smartcalendar.service.UserService; import com.smartcalendar.dto.ChangeCredentialsRequest; -import com.smartcalendar.dto.StatisticsData; -import com.smartcalendar.dto.AverageDayTimeDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -25,9 +21,6 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.*; -import java.time.LocalDate; -import java.util.Optional; - @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -37,7 +30,6 @@ public class AuthController { private final AuthenticationManager authenticationManager; private final JwtService jwtService; private final UserService userService; - private final StatisticsService statisticsService; @Operation( summary = "User authentication", @@ -52,16 +44,16 @@ public class AuthController { @PostMapping("/login") public ResponseEntity authenticateUser(@RequestBody User user) { logger.info("Attempting to authenticate user: {}", user.getUsername()); - + if ((user.getUsername() == null && user.getEmail() == null) || user.getPassword() == null) { logger.warn("Username/email or password is null. Username: {}, Email: {}", user.getUsername(), user.getEmail()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Username/email and password are required"); } - + try { logger.debug("Determining if login is by username or email: {}", user.getUsername() != null ? user.getUsername() : user.getEmail()); UserDetails userDetails; - + try { if (user.getUsername() != null) { userDetails = userService.loadUserByUsername(user.getUsername()); @@ -72,26 +64,17 @@ public ResponseEntity authenticateUser(@RequestBody User user) { logger.error("User not found: {}", user.getUsername() != null ? user.getUsername() : user.getEmail()); return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid username or email"); } - + Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(userDetails.getUsername(), user.getPassword()) + new UsernamePasswordAuthenticationToken(userDetails.getUsername(), user.getPassword()) ); - + logger.debug("Authentication successful for user: {}", userDetails.getUsername()); SecurityContextHolder.getContext().setAuthentication(authentication); - - if (user.getDeviceToken() != null && !user.getDeviceToken().isBlank()) { - Optional dbUserOpt = userService.findByUsername(userDetails.getUsername()); - if (dbUserOpt.isPresent()) { - User dbUser = dbUserOpt.get(); - dbUser.setDeviceToken(user.getDeviceToken()); - userService.createUser(dbUser); - } - } - + String jwt = jwtService.generateToken(userDetails.getUsername()); logger.info("JWT token generated for user: {}", userDetails.getUsername()); - + return ResponseEntity.ok(jwt); } catch (BadCredentialsException e) { logger.error("Invalid credentials for user: {}", user.getUsername() != null ? user.getUsername() : user.getEmail()); @@ -100,7 +83,7 @@ public ResponseEntity authenticateUser(@RequestBody User user) { logger.error("Unexpected error during authentication for user: {}", user.getUsername() != null ? user.getUsername() : user.getEmail(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An unexpected error occurred"); } - } +} @Operation( summary = "New user signup", @@ -112,40 +95,29 @@ public ResponseEntity authenticateUser(@RequestBody User user) { @ApiResponse(responseCode = "500", description = "Internal server error") }) @PostMapping("/signup") - public ResponseEntity registerUser(@RequestBody RegistrationRequest request) { - logger.info("Attempting to register user: {}", request.getUsername()); + public ResponseEntity registerUser(@RequestBody User user) { + logger.info("Attempting to register user: {}", user.getUsername()); - if (request.getUsername() == null || request.getPassword() == null || request.getEmail() == null || request.getFirstDay() == null) { - logger.warn("Missing required fields for registration. Username: {}, Email: {}, FirstDay: {}", request.getUsername(), request.getEmail(), request.getFirstDay()); + if (user.getUsername() == null || user.getPassword() == null || user.getEmail() == null) { + logger.warn("Missing required fields for registration. Username: {}, Email: {}", user.getUsername(), user.getEmail()); return ResponseEntity.badRequest().body(null); } - if (userService.existsByUsername(request.getUsername())) { - logger.warn("Username already exists: {}", request.getUsername()); + if (userService.existsByUsername(user.getUsername())) { + logger.warn("Username already exists: {}", user.getUsername()); return ResponseEntity.badRequest().body(null); } - if (userService.existsByEmail(request.getEmail())) { - logger.warn("Email already exists: {}", request.getEmail()); + if (userService.existsByEmail(user.getEmail())) { + logger.warn("Email already exists: {}", user.getEmail()); return ResponseEntity.badRequest().body(null); } try { - User user = new User(); - user.setUsername(request.getUsername()); - user.setEmail(request.getEmail()); - user.setPassword(request.getPassword()); User createdUser = userService.createUser(user); - - StatisticsData statisticsData = new StatisticsData(); - statisticsData.setAverageDayTime( - new AverageDayTimeDto(0, LocalDate.parse(request.getFirstDay())) - ); - statisticsService.updateStatistics(createdUser.getId(), statisticsData); - - logger.info("User registered successfully: {}", request.getUsername()); + logger.info("User registered successfully: {}", user.getUsername()); return ResponseEntity.ok(createdUser); } catch (Exception e) { - logger.error("Error during user registration: {}", request.getUsername(), e); + logger.error("Error during user registration: {}", user.getUsername(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null); } } @@ -158,7 +130,7 @@ public ResponseEntity changeCredentials(@RequestBody ChangeCredentialsRequest request.getNewUsername(), request.getNewPassword() ); - + if (success) { return ResponseEntity.ok("Credentials updated successfully"); } diff --git a/src/main/java/com/smartcalendar/controller/ChatGPTController.java b/src/main/java/com/smartcalendar/controller/ChatGPTController.java index 3dfad32..e1cd83e 100644 --- a/src/main/java/com/smartcalendar/controller/ChatGPTController.java +++ b/src/main/java/com/smartcalendar/controller/ChatGPTController.java @@ -41,12 +41,13 @@ public ResponseEntity generateEntities(@RequestBody Map reque return ResponseEntity.badRequest().body(response); } - List events = response.get("events") instanceof List ? (List) response.get("events") : List.of(); - List tasks = response.get("tasks") instanceof List ? (List) response.get("tasks") : List.of(); + if (!(response.get("events") instanceof List) || !(response.get("tasks") instanceof List)) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid response format from ChatGPT")); + } Map> validResponse = Map.of( - "events", events, - "tasks", tasks + "events", (List) response.get("events"), + "tasks", (List) response.get("tasks") ); List entities = chatGPTService.convertToEntities(validResponse); diff --git a/src/main/java/com/smartcalendar/controller/EventController.java b/src/main/java/com/smartcalendar/controller/EventController.java deleted file mode 100644 index c42a12c..0000000 --- a/src/main/java/com/smartcalendar/controller/EventController.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.smartcalendar.controller; - -import com.smartcalendar.model.Event; -import com.smartcalendar.repository.EventRepository; -import com.smartcalendar.service.EventService; -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.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; - -@RestController -@RequestMapping("/api/events") -public class EventController { - - private final EventService eventService; - - @Autowired - public EventController(EventService eventService) { - this.eventService = eventService; - } - - @GetMapping - public ResponseEntity> getEvents(@RequestParam(name = "location") String location, - @RequestParam(name = "userId") Long userId) { - if (location == null || location.isBlank()) { - return ResponseEntity.badRequest().build(); - } - List events = eventService.getPersonalizedEvents(location.trim(), userId); - return ResponseEntity.ok(events); - } -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/controller/FriendshipController.java b/src/main/java/com/smartcalendar/controller/FriendshipController.java deleted file mode 100644 index 0fd00ef..0000000 --- a/src/main/java/com/smartcalendar/controller/FriendshipController.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.smartcalendar.controller; - -import com.smartcalendar.dto.SubscriptionRequest; -import com.smartcalendar.model.Friendship; -import com.smartcalendar.service.FriendshipService; -import jakarta.validation.Valid; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/api/friendships") -public class FriendshipController { - - private final FriendshipService friendshipService; - - @Autowired - public FriendshipController(FriendshipService friendshipService) { - this.friendshipService = friendshipService; - } - - @GetMapping("/my-subscriptions") - public ResponseEntity> getMySubscriptions( - @AuthenticationPrincipal UserDetails userDetails - ) { - Long currentUserId = Long.parseLong(userDetails.getUsername()); - List subscriptions = friendshipService.getSubscriptions(currentUserId); - return ResponseEntity.ok(subscriptions); - } - - @PostMapping("/subscribe") - public ResponseEntity subscribe( - @AuthenticationPrincipal UserDetails userDetails, - @RequestBody @Valid SubscriptionRequest request - ) { - Long currentUserId = Long.parseLong(userDetails.getUsername()); - Friendship subscription = friendshipService.createSubscription(currentUserId, request.getUser2Id()); - return ResponseEntity.status(HttpStatus.CREATED).body(subscription); - } - - @DeleteMapping("/unsubscribe/{followingId}") - public ResponseEntity unsubscribe( - @AuthenticationPrincipal UserDetails userDetails, - @PathVariable Long user2Id - ) { - Long currentUserId = Long.parseLong(userDetails.getUsername()); - friendshipService.deleteSubscription(currentUserId, user2Id); - return ResponseEntity.noContent().build(); - } -} diff --git a/src/main/java/com/smartcalendar/controller/StatisticsController.java b/src/main/java/com/smartcalendar/controller/StatisticsController.java index 2d10506..b293c17 100644 --- a/src/main/java/com/smartcalendar/controller/StatisticsController.java +++ b/src/main/java/com/smartcalendar/controller/StatisticsController.java @@ -1,13 +1,9 @@ package com.smartcalendar.controller; import com.smartcalendar.dto.*; -import com.smartcalendar.model.User; import com.smartcalendar.service.StatisticsService; -import com.smartcalendar.service.UserService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -18,35 +14,24 @@ public class StatisticsController { private final StatisticsService statisticsService; - private final UserService userService; - - private Long getCurrentUserId(UserDetails userDetails) { - User user = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - return user.getId(); - } @GetMapping("/total-time-task-types") - public ResponseEntity getTotalTimeTaskTypes(@AuthenticationPrincipal UserDetails userDetails) { - Long userId = getCurrentUserId(userDetails); - return ResponseEntity.ok(statisticsService.getTotalTimeTaskTypes(userId)); + public ResponseEntity getTotalTimeTaskTypes() { + return ResponseEntity.ok(statisticsService.getTotalTimeTaskTypes()); } @GetMapping("/today") - public ResponseEntity getTodayTimeDto(@AuthenticationPrincipal UserDetails userDetails) { - Long userId = getCurrentUserId(userDetails); - return ResponseEntity.ok(statisticsService.getTodayTimeDto(userId)); + public ResponseEntity getTodayTimeVars() { + return ResponseEntity.ok(statisticsService.getTodayTimeVars()); } @GetMapping("/continuous-success-days") - public ResponseEntity getContinuesSuccessDaysDto(@AuthenticationPrincipal UserDetails userDetails) { - Long userId = getCurrentUserId(userDetails); - return ResponseEntity.ok(statisticsService.getContinuesSuccessDaysDto(userId)); + public ResponseEntity getContinuousSuccessDaysVars() { + return ResponseEntity.ok(statisticsService.getContinuousSuccessDaysVars()); } @GetMapping("/average-day-time") - public ResponseEntity getAverageDayTimeDto(@AuthenticationPrincipal UserDetails userDetails) { - Long userId = getCurrentUserId(userDetails); - return ResponseEntity.ok(statisticsService.getAverageDayTimeDto(userId)); + public ResponseEntity getAverageDayTimeVars() { + return ResponseEntity.ok(statisticsService.getAverageDayTimeVars()); } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/controller/UserController.java b/src/main/java/com/smartcalendar/controller/UserController.java index fafa0ec..c796f82 100644 --- a/src/main/java/com/smartcalendar/controller/UserController.java +++ b/src/main/java/com/smartcalendar/controller/UserController.java @@ -1,9 +1,5 @@ package com.smartcalendar.controller; -import com.smartcalendar.dto.AddCollaborativeEventRequest; -import com.smartcalendar.dto.DailyTaskDto; -import com.smartcalendar.dto.EventDto; -import com.smartcalendar.dto.StatisticsData; import com.smartcalendar.model.Event; import com.smartcalendar.model.Task; import com.smartcalendar.model.User; @@ -14,7 +10,8 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.*; -import java.util.*; +import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/users") @@ -35,45 +32,20 @@ public ResponseEntity getUserById(@PathVariable Long id) { } @PutMapping("/{id}/email") - public ResponseEntity updateEmail( - @PathVariable Long id, - @RequestBody String newEmail, - @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!currentUser.getId().equals(id)) { - return ResponseEntity.status(403).build(); - } + public ResponseEntity updateEmail(@PathVariable Long id, @RequestBody String newEmail) { User updatedUser = userService.updateEmail(id, newEmail); return ResponseEntity.ok(updatedUser); } @PatchMapping("/tasks/{taskId}/status") - public ResponseEntity updateTaskStatus( - @PathVariable UUID taskId, - @RequestBody Map requestBody, - @AuthenticationPrincipal UserDetails userDetails) { - Task task = userService.getTaskById(taskId); - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!task.getUser().getId().equals(currentUser.getId())) { - return ResponseEntity.status(403).build(); - } + public ResponseEntity updateTaskStatus(@PathVariable Long taskId, @RequestBody Map requestBody) { boolean completed = requestBody.get("completed"); - userService.updateTaskStatus(taskId, completed); - return ResponseEntity.ok().build(); + Task updatedTask = userService.updateTaskStatus(taskId, completed); + return ResponseEntity.ok(updatedTask); } @GetMapping("/tasks/{taskId}/description") - public ResponseEntity getTaskDescription( - @PathVariable UUID taskId, - @AuthenticationPrincipal UserDetails userDetails) { - Task task = userService.getTaskById(taskId); - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!task.getUser().getId().equals(currentUser.getId())) { - return ResponseEntity.status(403).build(); - } + public ResponseEntity getTaskDescription(@PathVariable Long taskId) { String description = userService.getTaskDescription(taskId); return ResponseEntity.ok(description); } @@ -91,121 +63,39 @@ public ResponseEntity createUser(@RequestBody User user) { } @GetMapping("/{userId}/tasks") - public ResponseEntity> getTasksByUserId( - @PathVariable Long userId, - @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!currentUser.getId().equals(userId)) { - return ResponseEntity.status(403).build(); - } + public ResponseEntity> getTasksByUserId(@PathVariable Long userId) { List tasks = userService.findTasksByUserId(userId); return ResponseEntity.ok(tasks); } @GetMapping("/{userId}/events") - public ResponseEntity> getEventsByUserId( - @PathVariable Long userId, - @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!currentUser.getId().equals(userId)) { - return ResponseEntity.status(403).build(); - } + public ResponseEntity> getEventsByUserId(@PathVariable Long userId) { List events = userService.findEventsByUserId(userId); - List eventDtos = events.stream() - .map(userService::toEventDto) - .toList(); - return ResponseEntity.ok(eventDtos); + return ResponseEntity.ok(events); } @PostMapping("/{userId}/events") - public ResponseEntity> createEvent( - @PathVariable Long userId, - @RequestBody Event event, - @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!currentUser.getId().equals(userId)) { - return ResponseEntity.status(403).build(); - } - event.setOrganizer(currentUser); - event.setShared(false); - event.setInvitees(new ArrayList<>()); - event.setParticipants(List.of(currentUser)); - - try { - Event createdEvent = userService.createEventWithCustomId(event); - return ResponseEntity.ok(Map.of("id", createdEvent.getId())); - } catch (IllegalArgumentException ex) { - return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage())); - } - } - - @PatchMapping("/events/{eventId}") - public ResponseEntity updateEvent( - @PathVariable UUID eventId, - @RequestBody Event event, - @AuthenticationPrincipal UserDetails userDetails) { - Event existingEvent = userService.getEventById(eventId); - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!existingEvent.getOrganizer().getId().equals(currentUser.getId())) { - return ResponseEntity.status(403).build(); - } - - userService.editEvent(eventId, event); - - userService.notifyEventUpdated(existingEvent, event); - - return ResponseEntity.ok().build(); + public ResponseEntity createEvent(@PathVariable Long userId, @RequestBody Event event) { + User user = userService.findUserById(userId); + event.setOrganizer(user); + Event createdEvent = userService.createEvent(event); + return ResponseEntity.ok(createdEvent); } - @PostMapping("/{userId}/tasks") - public ResponseEntity> createTask( - @PathVariable Long userId, - @RequestBody Task task, - @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!currentUser.getId().equals(userId)) { - return ResponseEntity.status(403).build(); - } - task.setUser(currentUser); - Task createdTask = userService.createTaskWithCustomId(task); - return ResponseEntity.ok(Map.of("id", createdTask.getId())); + public ResponseEntity createTask(@PathVariable Long userId, @RequestBody Task task) { + User user = userService.findUserById(userId); + task.setUser(user); + Task createdTask = userService.createTask(task); + return ResponseEntity.ok(createdTask); } @DeleteMapping("/tasks/{taskId}") - public ResponseEntity deleteTask( - @PathVariable UUID taskId, - @AuthenticationPrincipal UserDetails userDetails) { - Task task = userService.getTaskById(taskId); - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!task.getUser().getId().equals(currentUser.getId())) { - return ResponseEntity.status(403).build(); - } + public ResponseEntity deleteTask(@PathVariable Long taskId) { userService.deleteTask(taskId); return ResponseEntity.noContent().build(); } - @PatchMapping("/tasks/{taskId}") - public ResponseEntity editTask( - @PathVariable UUID taskId, - @RequestBody Task task, - @AuthenticationPrincipal UserDetails userDetails) { - Task existingTask = userService.getTaskById(taskId); - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!existingTask.getUser().getId().equals(currentUser.getId())) { - return ResponseEntity.status(403).build(); - } - userService.editTask(taskId, task); - return ResponseEntity.ok().build(); - } - @GetMapping("/me") public ResponseEntity> getCurrentUserInfo(@AuthenticationPrincipal UserDetails userDetails) { User user = userService.findByUsername(userDetails.getUsername()) @@ -218,202 +108,4 @@ public ResponseEntity> getCurrentUserInfo(@AuthenticationPri return ResponseEntity.ok(result); } - @GetMapping("/{userId}/events/dailytasks") - public ResponseEntity> getAllEventsAsDailyTasks( - @PathVariable Long userId, - @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!currentUser.getId().equals(userId)) { - return ResponseEntity.status(403).build(); - } - List dailyTasks = userService.findAllEventsAsDailyTaskDto(userId); - return ResponseEntity.ok(dailyTasks); - } - - @PatchMapping("/events/{eventId}/status") - public ResponseEntity updateEventStatus( - @PathVariable UUID eventId, - @RequestBody Map requestBody, - @AuthenticationPrincipal UserDetails userDetails) { - Event event = userService.getEventById(eventId); - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!event.getOrganizer().getId().equals(currentUser.getId())) { - return ResponseEntity.status(403).build(); - } - boolean completed = requestBody.get("completed"); - Event updatedEvent = userService.updateEventStatus(eventId, completed); - - userService.notifyEventUpdated(event, updatedEvent); - - EventDto updatedEventDto = userService.toEventDto(updatedEvent); - return ResponseEntity.ok(updatedEventDto); - } - - @DeleteMapping("/events/{eventId}") - public ResponseEntity> deleteEventById( - @PathVariable UUID eventId, - @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - Event event = userService.getEventById(eventId); - - if (!event.getOrganizer().getId().equals(currentUser.getId())) { - return ResponseEntity.status(403).body(Map.of()); - } - - userService.notifyEventDeleted(event); - - UUID deletedId = userService.deleteEventById(eventId); - return ResponseEntity.ok(Map.of("id", deletedId)); - } - - @GetMapping("/{userId}/statistics") - public ResponseEntity getStatistics( - @PathVariable Long userId, - @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!currentUser.getId().equals(userId)) { - return ResponseEntity.status(403).build(); - } - StatisticsData statistics = userService.getStatistics(userId); - return ResponseEntity.ok(statistics); - } - - @PutMapping("/{userId}/statistics") - public ResponseEntity updateStatistics( - @PathVariable Long userId, - @RequestBody StatisticsData statisticsData, - @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - if (!currentUser.getId().equals(userId)) { - return ResponseEntity.status(403).build(); - } - userService.updateStatistics(userId, statisticsData); - return ResponseEntity.ok().build(); - } - - @PostMapping("/events/{eventId}/invite") - public ResponseEntity inviteUserToEvent( - @PathVariable UUID eventId, - @RequestBody Map requestBody, - @AuthenticationPrincipal UserDetails userDetails) { - String loginOrEmail = requestBody.get("loginOrEmail"); - Event event = userService.getEventById(eventId); - - Optional userOpt = userService.findByLoginOrEmail(loginOrEmail); - if (userOpt.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("error", "User not found")); - } - User user = userOpt.get(); - - if (event.getParticipants() != null && event.getParticipants().contains(user)) { - return ResponseEntity.badRequest().body(Map.of("error", "User is already a participant")); - } - if (event.getInvitees() != null && event.getInvitees().contains(user.getEmail())) { - return ResponseEntity.badRequest().body(Map.of("error", "User is already invited")); - } - if (event.getInvitees() == null) { - event.setInvitees(new ArrayList<>()); - } - event.getInvitees().add(user.getEmail()); - event.setShared(true); - - userService.saveEvent(event); - userService.notifyInvitees(event); - return ResponseEntity.ok(Map.of("invited", user.getUsername())); - } - - @PostMapping("/events/{eventId}/remove-invite") - public ResponseEntity removeInviteFromEvent( - @PathVariable UUID eventId, - @RequestBody Map requestBody, - @AuthenticationPrincipal UserDetails userDetails) { - String loginOrEmail = requestBody.get("loginOrEmail"); - Event event = userService.getEventById(eventId); - - Optional userOpt = userService.findByLoginOrEmail(loginOrEmail); - if (userOpt.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("error", "User not found")); - } - User user = userOpt.get(); - - if (event.getInvitees() != null) { - event.getInvitees().remove(user.getEmail()); - userService.saveEvent(event); - } - - return ResponseEntity.ok(Map.of("removedInvite", user.getUsername())); - } - - @GetMapping("/me/invites") - public ResponseEntity> getMyInvites(@AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - List invites = userService.findEventsByInvitee(currentUser.getEmail()); - List inviteDtos = invites.stream() - .map(userService::toEventDto) - .toList(); - return ResponseEntity.ok(inviteDtos); - } - - - @PostMapping("/events/{eventId}/accept-invite") - public ResponseEntity acceptInvite( - @PathVariable UUID eventId, - @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - Event event = userService.getEventById(eventId); - - if (event.getInvitees() == null || !event.getInvitees().contains(currentUser.getEmail())) { - return ResponseEntity.badRequest().body(Map.of("error", "No invite found for this user")); - } - - event.getInvitees().remove(currentUser.getEmail()); - if (!event.getParticipants().contains(currentUser)) { - event.getParticipants().add(currentUser); - } - userService.saveEvent(event); - userService.notifyUserAddedToEvent(currentUser, event, currentUser.getDeviceToken()); - - return ResponseEntity.ok(Map.of("accepted", true)); - } - - @PostMapping("/events/{eventId}/remove-participant") - public ResponseEntity removeParticipantFromEvent( - @PathVariable UUID eventId, - @RequestBody Map requestBody, - @AuthenticationPrincipal UserDetails userDetails) { - User currentUser = userService.findByUsername(userDetails.getUsername()) - .orElseThrow(() -> new RuntimeException("User not found")); - Event event = userService.getEventById(eventId); - - if (!event.getOrganizer().getId().equals(currentUser.getId())) { - return ResponseEntity.status(403).body(Map.of("error", "Only organizer can remove participants")); - } - - String loginOrEmail = requestBody.get("loginOrEmail"); - Optional userOpt = userService.findByLoginOrEmail(loginOrEmail); - if (userOpt.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("error", "User not found")); - } - User user = userOpt.get(); - - if (user.getId().equals(currentUser.getId())) { - return ResponseEntity.badRequest().body(Map.of("error", "Organizer cannot be removed")); - } - - boolean removed = event.getParticipants() != null && event.getParticipants().remove(user); - if (removed) { - userService.saveEvent(event); - userService.notifyUserRemovedFromEvent(user, event, user.getDeviceToken()); - return ResponseEntity.ok(Map.of("removedParticipant", user.getUsername())); - } else { - return ResponseEntity.badRequest().body(Map.of("error", "User is not a participant")); - } - } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java b/src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java deleted file mode 100644 index d093bbc..0000000 --- a/src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.smartcalendar.dto; -import com.smartcalendar.model.Event; -import lombok.Data; - -@Data -public class AddCollaborativeEventRequest { - private String loginOrEmail; - private String deviceToken; - private Event event; -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/AverageDayTimeDto.java b/src/main/java/com/smartcalendar/dto/AverageDayTimeDto.java deleted file mode 100644 index aa0f861..0000000 --- a/src/main/java/com/smartcalendar/dto/AverageDayTimeDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.smartcalendar.dto; - -import lombok.Data; -import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; - -import java.time.LocalDate; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class AverageDayTimeDto { - private long totalWorkMinutes; - private LocalDate firstDay; -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/UserShortDto.java b/src/main/java/com/smartcalendar/dto/AverageDayTimeVars.java similarity index 57% rename from src/main/java/com/smartcalendar/dto/UserShortDto.java rename to src/main/java/com/smartcalendar/dto/AverageDayTimeVars.java index f3d4825..bc5df5f 100644 --- a/src/main/java/com/smartcalendar/dto/UserShortDto.java +++ b/src/main/java/com/smartcalendar/dto/AverageDayTimeVars.java @@ -5,7 +5,6 @@ @Data @AllArgsConstructor -public class UserShortDto { - private String username; - private String email; +public class AverageDayTimeVars { + private long averageMinutesPerDay; } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/ContinuesSuccessDaysDto.java b/src/main/java/com/smartcalendar/dto/ContinuesSuccessDaysDto.java deleted file mode 100644 index 9e0188f..0000000 --- a/src/main/java/com/smartcalendar/dto/ContinuesSuccessDaysDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.smartcalendar.dto; - -import lombok.Data; -import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class ContinuesSuccessDaysDto { - private int record; - private int now; -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/DailyTaskDto.java b/src/main/java/com/smartcalendar/dto/DailyTaskDto.java deleted file mode 100644 index 183f43b..0000000 --- a/src/main/java/com/smartcalendar/dto/DailyTaskDto.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.smartcalendar.dto; - -import com.smartcalendar.model.EventType; -import lombok.AllArgsConstructor; -import lombok.Data; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.UUID; - -@Data -@AllArgsConstructor -public class DailyTaskDto { - private UUID id; - private String title; - private boolean isComplete; - private EventType type; - private LocalDateTime creationTime; - private String description; - private LocalTime start; - private LocalTime end; - private LocalDate date; -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/EventDto.java b/src/main/java/com/smartcalendar/dto/EventDto.java deleted file mode 100644 index ad89fda..0000000 --- a/src/main/java/com/smartcalendar/dto/EventDto.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.smartcalendar.dto; - -import com.smartcalendar.model.EventType; -import lombok.Data; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -@Data -public class EventDto { - private UUID id; - private String title; - private String description; - private LocalDateTime start; - private LocalDateTime end; - private String location; - private EventType type; - private LocalDateTime creationTime; - private UserShortDto organizer; - private boolean completed; - private boolean isShared; - private List invitees; - private List participants; -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/RegistrationRequest.java b/src/main/java/com/smartcalendar/dto/RegistrationRequest.java deleted file mode 100644 index 381ce76..0000000 --- a/src/main/java/com/smartcalendar/dto/RegistrationRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.smartcalendar.dto; - -import lombok.Data; - -@Data -public class RegistrationRequest { - private String username; - private String email; - private String password; - private String firstDay; -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/StatisticsData.java b/src/main/java/com/smartcalendar/dto/StatisticsData.java deleted file mode 100644 index 97f0b86..0000000 --- a/src/main/java/com/smartcalendar/dto/StatisticsData.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.smartcalendar.dto; - -import lombok.Data; -import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; - -import java.util.Date; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class StatisticsData { - private TotalTimeTaskTypesDto totalTime = new TotalTimeTaskTypesDto(0, 0, 0, 0); - private long weekTime = 0; - private TodayTimeDto todayTime = new TodayTimeDto(0, 0); - private ContinuesSuccessDaysDto continuesSuccessDays = new ContinuesSuccessDaysDto(0, 0); - private AverageDayTimeDto averageDayTime = new AverageDayTimeDto(0, null); - private Date jsonDate = new Date(); -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/SubscriptionRequest.java b/src/main/java/com/smartcalendar/dto/SubscriptionRequest.java deleted file mode 100644 index 8d35615..0000000 --- a/src/main/java/com/smartcalendar/dto/SubscriptionRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.smartcalendar.dto; - -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -@Data -public class SubscriptionRequest { - private Long user2Id; - - public Long getUser2Id() { - return user2Id; - } -} - diff --git a/src/main/java/com/smartcalendar/dto/TodayTimeDto.java b/src/main/java/com/smartcalendar/dto/TodayTimeDto.java deleted file mode 100644 index 88d3f15..0000000 --- a/src/main/java/com/smartcalendar/dto/TodayTimeDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.smartcalendar.dto; - -import lombok.Data; -import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class TodayTimeDto { - private long planned; - private long completed; -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/TotalTimeTaskTypesDto.java b/src/main/java/com/smartcalendar/dto/TotalTimeTaskTypes.java similarity index 70% rename from src/main/java/com/smartcalendar/dto/TotalTimeTaskTypesDto.java rename to src/main/java/com/smartcalendar/dto/TotalTimeTaskTypes.java index 9493d19..46fd111 100644 --- a/src/main/java/com/smartcalendar/dto/TotalTimeTaskTypesDto.java +++ b/src/main/java/com/smartcalendar/dto/TotalTimeTaskTypes.java @@ -1,15 +1,13 @@ package com.smartcalendar.dto; -import lombok.Data; import lombok.AllArgsConstructor; -import lombok.NoArgsConstructor; +import lombok.Data; @Data -@NoArgsConstructor @AllArgsConstructor -public class TotalTimeTaskTypesDto { +public class TotalTimeTaskTypes { private long common; private long work; private long study; private long fitness; -} \ No newline at end of file +} diff --git a/src/main/java/com/smartcalendar/exceptions/ConflictException.java b/src/main/java/com/smartcalendar/exceptions/ConflictException.java deleted file mode 100644 index 3925333..0000000 --- a/src/main/java/com/smartcalendar/exceptions/ConflictException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.smartcalendar.exceptions; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ResponseStatus(HttpStatus.CONFLICT) -public class ConflictException extends RuntimeException { - public ConflictException(String message) { - super(message); - } -} diff --git a/src/main/java/com/smartcalendar/exceptions/ResourceNotFoundException.java b/src/main/java/com/smartcalendar/exceptions/ResourceNotFoundException.java deleted file mode 100644 index 8f2549b..0000000 --- a/src/main/java/com/smartcalendar/exceptions/ResourceNotFoundException.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.smartcalendar.exceptions; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ResponseStatus(HttpStatus.NOT_FOUND) -public class ResourceNotFoundException extends RuntimeException { - public ResourceNotFoundException(String message) { - super(message); - } -} - diff --git a/src/main/java/com/smartcalendar/model/Event.java b/src/main/java/com/smartcalendar/model/Event.java index 9cf3d4e..cd68fc9 100644 --- a/src/main/java/com/smartcalendar/model/Event.java +++ b/src/main/java/com/smartcalendar/model/Event.java @@ -1,19 +1,11 @@ package com.smartcalendar.model; -import com.fasterxml.jackson.annotation.JsonBackReference; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonManagedReference; -import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import java.time.Instant; import java.time.LocalDateTime; -import java.util.List; -import java.util.ArrayList; -import java.util.Map; import java.util.UUID; @Entity @@ -23,13 +15,11 @@ @AllArgsConstructor public class Event { @Id - //@GeneratedValue(strategy = GenerationType.UUID) + @GeneratedValue(strategy = GenerationType.AUTO) private UUID id; - @Column private String title; - @Column private String description; @Column(name = "start_time") @@ -38,58 +28,14 @@ public class Event { @Column(name = "end_time") private LocalDateTime end; - @Column(name = "event_location") private String location; @Enumerated(EnumType.STRING) - private EventType type; + private EventType type; // Аналог DailyTaskType private LocalDateTime creationTime = LocalDateTime.now(); @ManyToOne @JoinColumn(name = "organizer_id") - @JsonBackReference(value = "organized_events") private User organizer; - - @ManyToMany(fetch = FetchType.LAZY) - @JoinTable( - name = "events_tags", - joinColumns = @JoinColumn(name = "event_id"), - inverseJoinColumns = @JoinColumn(name = "tag_id") - ) - //@JsonManagedReference(value = "event_tags") - @JsonProperty("tags") - private List tags; - - @Column - private boolean completed = false; - - @Column - private boolean isShared = false; - - @ElementCollection - @CollectionTable(name = "event_invitees", joinColumns = @JoinColumn(name = "event_id")) - @Column(name = "invitee") - private List invitees = new ArrayList<>(); - - @ManyToMany - @JoinTable( - name = "event_participants", - joinColumns = @JoinColumn(name = "event_id"), - inverseJoinColumns = @JoinColumn(name = "user_id") - ) - @JsonIgnore - private List participants = new ArrayList<>(); - - public LocalDateTime getEnd() { - return end; - } - - public LocalDateTime getStart() { - return start; - } - - public List getTags() { - return tags; - } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/model/Friendship.java b/src/main/java/com/smartcalendar/model/Friendship.java deleted file mode 100644 index c9dd657..0000000 --- a/src/main/java/com/smartcalendar/model/Friendship.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.smartcalendar.model; - -import com.fasterxml.jackson.annotation.JsonBackReference; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -@Entity -@Data -@Table(name = "friendships") -@NoArgsConstructor -@AllArgsConstructor -public class Friendship { - @Id - //@GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - @JoinColumn(name = "user_id1") - @JsonBackReference(value = "friends1") - private User user1; - - @ManyToOne - @JoinColumn(name = "user_id2") - @JsonBackReference(value = "friends2") - private User user2; - - public void setUser1(User user1) { - this.user1 = user1; - } - - public void setUser2(User user2) { - this.user2 = user2; - } -} diff --git a/src/main/java/com/smartcalendar/model/GroupChat.java b/src/main/java/com/smartcalendar/model/GroupChat.java deleted file mode 100644 index 0b25f86..0000000 --- a/src/main/java/com/smartcalendar/model/GroupChat.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.smartcalendar.model; - -import com.fasterxml.jackson.annotation.JsonBackReference; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonManagedReference; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; -import java.util.UUID; - -@Entity -@Data -@Table(name = "group_chats") -@NoArgsConstructor -@AllArgsConstructor -public class GroupChat { - @Id - //@GeneratedValue(strategy = GenerationType.IDENTITY) - private UUID id; - - @ManyToOne - @JoinColumn(name = "admin_id") - @JsonBackReference(value = "admin_chats") - private User admin; - - @OneToMany(mappedBy = "chat", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference(value = "messages_in_group_chat") - private List messages; - - @ManyToMany(mappedBy = "groupChats", fetch = FetchType.LAZY) - //@JsonBackReference(value = "common_chats") - @JsonIgnore - private List users; -} diff --git a/src/main/java/com/smartcalendar/model/GroupMessage.java b/src/main/java/com/smartcalendar/model/GroupMessage.java deleted file mode 100644 index 4cfd81b..0000000 --- a/src/main/java/com/smartcalendar/model/GroupMessage.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.smartcalendar.model; - -import com.fasterxml.jackson.annotation.JsonBackReference; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.UUID; - -@Entity -@Data -@Table(name = "group_messages") -@NoArgsConstructor -@AllArgsConstructor -public class GroupMessage { - @Id - //@GeneratedValue(strategy = GenerationType.IDENTITY) - private UUID id; - - @Column - private String messageText; - - @Column(name = "time_sent") - private LocalDateTime timeWhenSent; - - @ManyToOne - @JoinColumn(name = "chat_id") - @JsonBackReference(value = "messages_in_group_chat") - private GroupChat chat; - - @ManyToOne - @JoinColumn(name = "user_id") - @JsonBackReference(value = "group_message_author") - private User user; -} diff --git a/src/main/java/com/smartcalendar/model/PrivateChat.java b/src/main/java/com/smartcalendar/model/PrivateChat.java deleted file mode 100644 index 29c9df7..0000000 --- a/src/main/java/com/smartcalendar/model/PrivateChat.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.smartcalendar.model; - -import com.fasterxml.jackson.annotation.JsonBackReference; -import com.fasterxml.jackson.annotation.JsonManagedReference; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; -import java.util.UUID; - -@Entity -@Data -@Table(name = "private_chats") -@NoArgsConstructor -@AllArgsConstructor -public class PrivateChat { - @Id - //@GeneratedValue(strategy = GenerationType.IDENTITY) - private UUID id; - - @ManyToOne - @JoinColumn(name = "user_id1") - @JsonBackReference(value = "chats1") - private User user1; - - @ManyToOne - @JoinColumn(name = "user_id2") - @JsonBackReference(value = "chats2") - private User user2; - - @OneToMany(mappedBy = "chat", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference(value = "messages_in_private_chat") - private List messages; -} diff --git a/src/main/java/com/smartcalendar/model/PrivateMessage.java b/src/main/java/com/smartcalendar/model/PrivateMessage.java deleted file mode 100644 index 55933ca..0000000 --- a/src/main/java/com/smartcalendar/model/PrivateMessage.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.smartcalendar.model; - -import com.fasterxml.jackson.annotation.JsonBackReference; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.UUID; - -@Entity -@Data -@Table(name = "private_messages") -@NoArgsConstructor -@AllArgsConstructor -public class PrivateMessage { - @Id - //@GeneratedValue(strategy = GenerationType.IDENTITY) - private UUID id; - - @Column - private String messageText; - - @Column(name = "time_sent") - private LocalDateTime timeWhenSent; - - @ManyToOne - @JoinColumn(name = "chat_id") - @JsonBackReference(value = "messages_in_private_chat") - private PrivateChat chat; - - @ManyToOne - @JoinColumn(name = "user_id") - @JsonBackReference(value = "private_message_author") - private User user; -} diff --git a/src/main/java/com/smartcalendar/model/Statistics.java b/src/main/java/com/smartcalendar/model/Statistics.java deleted file mode 100644 index d2dd986..0000000 --- a/src/main/java/com/smartcalendar/model/Statistics.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.smartcalendar.model; - -import jakarta.persistence.*; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDate; - -@Entity -@Table(name = "statistics") -@Data -@NoArgsConstructor -public class Statistics { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @OneToOne - @JoinColumn(name = "user_id", unique = true) - private User user; - - private long totalCommon; - private long totalWork; - private long totalStudy; - private long totalFitness; - - private long weekTime; - - private long todayPlanned; - private long todayCompleted; - - private int continuesRecord; - private int continuesNow; - - private long averageWorkMinutes; - private LocalDate firstDay; -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/model/Tag.java b/src/main/java/com/smartcalendar/model/Tag.java deleted file mode 100644 index 757a45d..0000000 --- a/src/main/java/com/smartcalendar/model/Tag.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.smartcalendar.model; - -import com.fasterxml.jackson.annotation.JsonBackReference; -import com.fasterxml.jackson.annotation.JsonIgnore; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; -import java.util.UUID; - -@Entity -@Data -@Table(name = "tags") -@NoArgsConstructor -@AllArgsConstructor -public class Tag { - @Id - //@GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column - private String title; - - @ManyToMany(mappedBy = "tags", fetch = FetchType.LAZY) - //@JsonBackReference(value = "event_tags") - @JsonIgnore - private List events; - - public Long getId() { - return id; - } -} diff --git a/src/main/java/com/smartcalendar/model/Task.java b/src/main/java/com/smartcalendar/model/Task.java index 17c1f77..20aa0ec 100644 --- a/src/main/java/com/smartcalendar/model/Task.java +++ b/src/main/java/com/smartcalendar/model/Task.java @@ -1,7 +1,6 @@ package com.smartcalendar.model; import com.fasterxml.jackson.annotation.JsonBackReference; -import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Data; @@ -13,7 +12,6 @@ @Entity @Data -@Table(name = "tasks") @NoArgsConstructor @AllArgsConstructor public class Task { @@ -21,20 +19,19 @@ public class Task { @GeneratedValue(strategy = GenerationType.AUTO) private UUID id; - @Column private String title; - @Column + private String description; - @Column - private boolean isCompleted; - private LocalDateTime dueDateTime; - private Boolean allDay = false; + private boolean completed; + + private LocalDateTime dueDateTime; // дедлайн с точностью до времени + private Boolean allDay = false; // если true — задача только на день (игнорировать время) private LocalDateTime creationTime = LocalDateTime.now(); @ManyToOne @JoinColumn(name = "user_id") - @JsonBackReference(value = "user_tasks") + @JsonBackReference private User user; } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/model/User.java b/src/main/java/com/smartcalendar/model/User.java index ea94434..c6cd1d7 100644 --- a/src/main/java/com/smartcalendar/model/User.java +++ b/src/main/java/com/smartcalendar/model/User.java @@ -1,7 +1,6 @@ package com.smartcalendar.model; import com.fasterxml.jackson.annotation.JsonManagedReference; -import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.persistence.*; import jakarta.validation.constraints.Email; import lombok.Data; @@ -10,8 +9,9 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.time.LocalDateTime; -import java.util.*; +import java.util.Collection; +import java.util.Collections; +import java.util.List; @Entity @Data @@ -34,64 +34,9 @@ public class User implements UserDetails { private String password; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference(value = "user_tasks") + @JsonManagedReference private List tasks; - @Column(name = "device_token") - private String deviceToken; - - @OneToMany(mappedBy = "organizer", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference(value = "organized_events") - private List organized_events; - - @OneToMany(mappedBy = "admin", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference(value = "admin_chats") - private List chatsWithAdminRights; - - @OneToMany(mappedBy = "user1", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference(value = "chats1") - private List privateChats1; - - @OneToMany(mappedBy = "user2", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference(value = "chats2") - private List privateChats2; - - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference(value = "group_message_author") - private List groupMessages; - - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference(value = "private_message_author") - private List privateMessages; - - @OneToMany(mappedBy = "user1", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference(value = "friends1") - private List friendships1; - - @OneToMany(mappedBy = "user2", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference(value = "friends2") - private List friendships2; - - @ManyToMany(fetch = FetchType.LAZY) - @JoinTable( - name = "users_events", - joinColumns = @JoinColumn(name = "user_id"), - inverseJoinColumns = @JoinColumn(name = "event_id") - ) - //@JsonManagedReference(value = "personal_events") - @JsonProperty("events") - private List events = new ArrayList<>(); - - @ManyToMany(fetch = FetchType.LAZY) - @JoinTable( - name = "users_group_chats", - joinColumns = @JoinColumn(name = "user_id"), - inverseJoinColumns = @JoinColumn(name = "chat_id") - ) - //@JsonManagedReference(value = "common_chats") - @JsonProperty("group_chats") - private List groupChats; - @Override public Collection getAuthorities() { return Collections.emptyList(); @@ -126,13 +71,4 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return true; } - - public Long getId() { - return id; - } - - public List getVisitedEvents() { - return events.stream().filter(event -> event.getStart().isBefore(LocalDateTime.now())).toList(); - } - } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/repository/EventRepository.java b/src/main/java/com/smartcalendar/repository/EventRepository.java index 312c2d5..7cc86b7 100644 --- a/src/main/java/com/smartcalendar/repository/EventRepository.java +++ b/src/main/java/com/smartcalendar/repository/EventRepository.java @@ -2,7 +2,6 @@ import com.smartcalendar.model.Event; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; @@ -11,9 +10,4 @@ @Repository public interface EventRepository extends JpaRepository { List findByOrganizerId(Long organizerId); - @Query("SELECT e FROM Event e WHERE LOWER(e.location) = LOWER(:location) ORDER BY e.end ASC") - List findByLocationIgnoreCase(String location); - @Query("SELECT e FROM Event e WHERE LOWER(e.location) = LOWER(:location) AND :userId IS NOT NULL " + - "AND (e NOT IN (SELECT v FROM User u JOIN u.events v WHERE u.id = :userId))") - List findByLocationForUser(String location, Long userId); } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/repository/FriendshipRepository.java b/src/main/java/com/smartcalendar/repository/FriendshipRepository.java deleted file mode 100644 index 873e877..0000000 --- a/src/main/java/com/smartcalendar/repository/FriendshipRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.smartcalendar.repository; - -import com.smartcalendar.model.Friendship; -import com.smartcalendar.model.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; -import java.util.Optional; - -public interface FriendshipRepository extends JpaRepository { - List findByUser1(User user1); - Optional findByUser1AndUser2(User user1, User user2); - boolean existsByUser1AndUser2(User user1, User user2); -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/repository/StatisticsRepository.java b/src/main/java/com/smartcalendar/repository/StatisticsRepository.java deleted file mode 100644 index d325880..0000000 --- a/src/main/java/com/smartcalendar/repository/StatisticsRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.smartcalendar.repository; - -import com.smartcalendar.model.Statistics; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface StatisticsRepository extends JpaRepository { - Optional findByUserId(Long userId); -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/repository/TaskRepository.java b/src/main/java/com/smartcalendar/repository/TaskRepository.java index 9f4eafd..7424fb5 100644 --- a/src/main/java/com/smartcalendar/repository/TaskRepository.java +++ b/src/main/java/com/smartcalendar/repository/TaskRepository.java @@ -5,9 +5,8 @@ import org.springframework.stereotype.Repository; import java.util.List; -import java.util.UUID; @Repository -public interface TaskRepository extends JpaRepository { +public interface TaskRepository extends JpaRepository { List findByUserId(Long userId); } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/ChatGPTService.java b/src/main/java/com/smartcalendar/service/ChatGPTService.java index 89a61f8..0adaa9d 100644 --- a/src/main/java/com/smartcalendar/service/ChatGPTService.java +++ b/src/main/java/com/smartcalendar/service/ChatGPTService.java @@ -5,7 +5,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.smartcalendar.model.Event; -import com.smartcalendar.model.EventType; import com.smartcalendar.model.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,9 +14,9 @@ import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; @Service public class ChatGPTService { @@ -88,25 +87,9 @@ public Map> generateEventsAndTasks(String userQuery) { logger.info("Generating events and tasks for query: {}", userQuery); String prompt = "Based on the user's query: \"" + userQuery + "\", generate a list of events and tasks. " + - "If the user mentions a note, description, or additional information related to an event, include it in the 'description' field of the corresponding event, " + - "unless it is clearly a separate task. " + - "Respond strictly in JSON format with the following structure: " + - "{ \"events\": [{ " + - "\"title\": \"string\", " + - "\"description\": \"string\", " + - "\"start\": \"ISO 8601 datetime\", " + - "\"end\": \"ISO 8601 datetime\", " + - "\"location\": \"string\", " + - "\"type\": \"COMMON|FITNESS|STUDIES|WORK\" " + - "}], " + - "\"tasks\": [{ " + - "\"title\": \"string\", " + - "\"description\": \"string\", " + - "\"completed\": false, " + - "\"dueDateTime\": \"ISO 8601 datetime\", " + - "\"allDay\": false " + - "}] } " + - "Do not include any additional text or explanation."; + "Respond in JSON format with the following structure: " + + "{ \"events\": [{ \"title\": \"string\", \"start\": \"ISO 8601 datetime\", \"end\": \"ISO 8601 datetime\", \"location\": \"string\" }], " + + "\"tasks\": [{ \"title\": \"string\", \"description\": \"string\", \"completed\": false }] }"; String response = askChatGPT(prompt, "gpt-3.5-turbo"); @@ -129,24 +112,6 @@ public List convertToEntities(Map> data) { if (events != null) { for (Map eventData : events) { Event event = objectMapper.convertValue(eventData, Event.class); - - if (event.getId() == null) { - event.setId(UUID.randomUUID()); - } - if (event.getCreationTime() == null) { - event.setCreationTime(LocalDateTime.now()); - } - if (event.getType() == null && eventData.get("type") != null) { - try { - event.setType(EventType.valueOf(eventData.get("type").toString())); - } catch (Exception ignored) {} - } - if (!event.isCompleted() && eventData.get("completed") != null) { - event.setCompleted(Boolean.parseBoolean(eventData.get("completed").toString())); - } - event.setShared(false); - event.setInvitees(new ArrayList<>()); - event.setParticipants(new ArrayList<>()); entities.add(event); } } @@ -154,22 +119,6 @@ public List convertToEntities(Map> data) { if (tasks != null) { for (Map taskData : tasks) { Task task = objectMapper.convertValue(taskData, Task.class); - - if (task.getId() == null) { - task.setId(UUID.randomUUID()); - } - if (task.getCreationTime() == null) { - task.setCreationTime(LocalDateTime.now()); - } - if (task.getAllDay() == null && taskData.get("allDay") != null) { - task.setAllDay(Boolean.parseBoolean(taskData.get("allDay").toString())); - } - if (task.getDueDateTime() == null && taskData.get("dueDate") != null) { - try { - LocalDate date = LocalDate.parse(taskData.get("dueDate").toString()); - task.setDueDateTime(date.atStartOfDay()); - } catch (Exception ignored) {} - } entities.add(task); } } @@ -178,26 +127,10 @@ public List convertToEntities(Map> data) { } public Map processTranscript(String transcript) { - String today = LocalDate.now().toString(); - String prompt = "Today is " + today + ". Based on the following transcript: \"" + transcript + "\", determine if it is related to creating events or tasks. " + + String prompt = "Based on the following transcript: \"" + transcript + "\", determine if it is related to creating events or tasks. " + "If it is, generate a list of events and tasks strictly in JSON format with the following structure: " + - "{ \"events\": [{ " + - "\"title\": \"string\", " + - "\"description\": \"string\", " + - "\"start\": \"ISO 8601 datetime\", " + - "\"end\": \"ISO 8601 datetime\", " + - "\"location\": \"string\", " + - "\"type\": \"COMMON|FITNESS|STUDIES|WORK\" " + - "}], " + - "\"tasks\": [{ " + - "\"title\": \"string\", " + - "\"description\": \"string\", " + - "\"completed\": false, " + - "\"dueDateTime\": \"ISO 8601 datetime\", " + - "\"allDay\": false " + - "}] } " + - "If the transcript contains a note, description, or additional information about an event, include it in the 'description' field of the event, " + - "unless it is clearly a separate task. " + + "{ \"events\": [{ \"title\": \"string\", \"start\": \"ISO 8601 datetime\", \"end\": \"ISO 8601 datetime\", \"location\": \"string\", \"description\": \"string\", \"type\": \"string\" }], " + + "\"tasks\": [{ \"title\": \"string\", \"description\": \"string\", \"completed\": false, \"dueDate\": \"ISO 8601 date\" }] }. " + "If the transcript is not related to events or tasks, respond with: { \"error\": \"Unrelated request\" }. " + "Do not include any additional text or explanation."; @@ -208,15 +141,9 @@ public Map processTranscript(String transcript) { if (result.containsKey("error")) { logger.warn("ChatGPT returned an error: {}", result); } - if (!result.containsKey("events")) { - result.put("events", List.of()); - } - if (!result.containsKey("tasks")) { - result.put("tasks", List.of()); - } return result; } catch (Exception e) { throw new RuntimeException("Failed to process ChatGPT response: " + e.getMessage()); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/EventService.java b/src/main/java/com/smartcalendar/service/EventService.java deleted file mode 100644 index c23f5d9..0000000 --- a/src/main/java/com/smartcalendar/service/EventService.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.smartcalendar.service; - -import com.smartcalendar.model.Event; -import com.smartcalendar.model.Tag; -import com.smartcalendar.model.User; -import com.smartcalendar.repository.EventRepository; -import com.smartcalendar.repository.UserRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.*; -import java.util.stream.Collectors; - -@Service -public class EventService { - - private final UserRepository userRepository; - private final EventRepository eventRepository; - - @Autowired - public EventService(UserRepository userRepository, EventRepository eventRepository) { - this.userRepository = userRepository; - this.eventRepository = eventRepository; - } - - public List getPersonalizedEvents(String location, Long userId) { - List events = new ArrayList<>(); - if (userId != null) { - Optional userOpt = userRepository.findById(userId); - if (userOpt.isPresent()) { - User user = userOpt.get(); - events = calculateRelevance(location, user); - } - } - if (events.isEmpty()) { - events = eventRepository.findByLocationIgnoreCase(location); - } - return filterEventsByTime(events); - } - - private List filterEventsByTime(List events) { - return events.stream().filter(event -> event.getEnd().isAfter(LocalDateTime.now())).toList(); - } - - private List calculateRelevance(String location, User user) { - List locationEvents = eventRepository.findByLocationForUser(location, user.getId()); - Map tagFrequency = new HashMap<>(); - user.getVisitedEvents().forEach(event -> event.getTags().forEach(tag -> - tagFrequency.put(tag.getId(), tagFrequency.getOrDefault(tag.getId(), 0) + 1))); - Map relevanceMap = new HashMap<>(); - for (Event event : locationEvents) { - int score = 0; - for (Long tagId : event.getTags().stream().map(Tag::getId).toList()) { - score += tagFrequency.getOrDefault(tagId, 0); - } - relevanceMap.put(event, score); - } - return locationEvents.stream().sorted((lhs, rhs) -> { - int relevanceCompare = Integer.compare(relevanceMap.get(rhs), relevanceMap.get(lhs)); - if (relevanceCompare != 0) return relevanceCompare; - return lhs.getStart().compareTo(rhs.getStart()); - }).collect(Collectors.toList()); - } -} diff --git a/src/main/java/com/smartcalendar/service/FriendshipService.java b/src/main/java/com/smartcalendar/service/FriendshipService.java deleted file mode 100644 index 7c6ef76..0000000 --- a/src/main/java/com/smartcalendar/service/FriendshipService.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.smartcalendar.service; - -import com.smartcalendar.exceptions.ConflictException; -import com.smartcalendar.exceptions.ResourceNotFoundException; -import com.smartcalendar.model.Friendship; -import com.smartcalendar.model.User; -import com.smartcalendar.repository.FriendshipRepository; -import com.smartcalendar.repository.UserRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -public class FriendshipService { - - private final FriendshipRepository friendshipRepository; - private final UserRepository userRepository; - - @Autowired - public FriendshipService(FriendshipRepository friendshipRepository, UserRepository userRepository) { - this.friendshipRepository = friendshipRepository; - this.userRepository = userRepository; - } - - public List getSubscriptions(Long user1Id) { - userRepository.findById(user1Id).orElseThrow(() -> - new ResourceNotFoundException("User not found with id: " + user1Id)); - return friendshipRepository.findByUser1(userRepository.findById(user1Id).get()); - } - - public Friendship createSubscription(Long user1Id, Long user2Id) { - userRepository.findById(user1Id).orElseThrow(() -> - new ResourceNotFoundException("User not found with id: " + user1Id)); - - userRepository.findById(user2Id).orElseThrow(() -> - new ResourceNotFoundException("User not found with id: " + user2Id)); - - User user1 = userRepository.findById(user1Id).get(); - User user2 = userRepository.findById(user2Id).get(); - - if (friendshipRepository.existsByUser1AndUser2(user1, user2)) { - throw new ConflictException("Subscription already exists"); - } - - Friendship subscription = new Friendship(); - subscription.setUser1(user1); - subscription.setUser2(user2); - - return friendshipRepository.save(subscription); - } - - public void deleteSubscription(Long user1Id, Long user2Id) { - Friendship subscription = friendshipRepository - .findByUser1AndUser2(userRepository.findById(user1Id).get(), userRepository.findById(user2Id).get()) - .orElseThrow(() -> new ResourceNotFoundException("Subscription not found")); - - friendshipRepository.delete(subscription); - } -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/NotificationService.java b/src/main/java/com/smartcalendar/service/NotificationService.java deleted file mode 100644 index 3b43d23..0000000 --- a/src/main/java/com/smartcalendar/service/NotificationService.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.smartcalendar.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.mail.SimpleMailMessage; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NotificationService { - private final JavaMailSender mailSender; - - @Value("${spring.mail.from:}") - private String fromAddress; - - public void sendEmail(String to, String subject, String text) { - try { - SimpleMailMessage message = new SimpleMailMessage(); - message.setTo(to); - message.setSubject(subject); - message.setText(text); - if (fromAddress != null && !fromAddress.isBlank()) { - message.setFrom(fromAddress); - } - mailSender.send(message); - } catch (Exception e) { - System.err.println("Failed to send email to " + to + ": " + e.getMessage()); - } - } - - public void sendPush(String deviceToken, String title, String body) { - System.out.println("[STUB] Push to: " + deviceToken + ", title: " + title + ", body: " + body); - } -} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/StatisticsService.java b/src/main/java/com/smartcalendar/service/StatisticsService.java index 562d2fc..669dc89 100644 --- a/src/main/java/com/smartcalendar/service/StatisticsService.java +++ b/src/main/java/com/smartcalendar/service/StatisticsService.java @@ -1,140 +1,34 @@ package com.smartcalendar.service; import com.smartcalendar.dto.*; -import com.smartcalendar.model.Statistics; -import com.smartcalendar.model.User; -import com.smartcalendar.repository.StatisticsRepository; -import com.smartcalendar.repository.UserRepository; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Date; @Service -@RequiredArgsConstructor public class StatisticsService { - private final StatisticsRepository statisticsRepository; - private final UserRepository userRepository; - - @Transactional(readOnly = true) - public StatisticsData getStatistics(Long userId) { - Statistics stats = statisticsRepository.findByUserId(userId) - .orElse(null); - - Date now = new Date(); - - if (stats == null) { - return new StatisticsData( - new TotalTimeTaskTypesDto(0, 0, 0, 0), - 0L, - new TodayTimeDto(0, 0), - new ContinuesSuccessDaysDto(0, 0), - new AverageDayTimeDto(0, null), - now - ); - } - - return new StatisticsData( - new TotalTimeTaskTypesDto(stats.getTotalCommon(), stats.getTotalWork(), stats.getTotalStudy(), stats.getTotalFitness()), - stats.getWeekTime(), - new TodayTimeDto(stats.getTodayPlanned(), stats.getTodayCompleted()), - new ContinuesSuccessDaysDto(stats.getContinuesRecord(), stats.getContinuesNow()), - new AverageDayTimeDto(stats.getAverageWorkMinutes(), stats.getFirstDay()), - now - ); + public TotalTimeTaskTypes getTotalTimeTaskTypes() { + long common = 120; + long work = 300; + long study = 180; + long fitness = 60; + return new TotalTimeTaskTypes(common, work, study, fitness); } - @Transactional - public void updateStatistics(Long userId, StatisticsData statisticsData) { - Statistics stats = statisticsRepository.findByUserId(userId) - .orElseGet(() -> { - Statistics s = new Statistics(); - User user = userRepository.findById(userId) - .orElseThrow(() -> new RuntimeException("User not found")); - s.setUser(user); - return s; - }); - - stats.setTotalCommon(statisticsData.getTotalTime().getCommon()); - stats.setTotalWork(statisticsData.getTotalTime().getWork()); - stats.setTotalStudy(statisticsData.getTotalTime().getStudy()); - stats.setTotalFitness(statisticsData.getTotalTime().getFitness()); - - stats.setWeekTime(statisticsData.getWeekTime()); - - stats.setTodayPlanned(statisticsData.getTodayTime().getPlanned()); - stats.setTodayCompleted(statisticsData.getTodayTime().getCompleted()); - - stats.setContinuesRecord(statisticsData.getContinuesSuccessDays().getRecord()); - stats.setContinuesNow(statisticsData.getContinuesSuccessDays().getNow()); - - stats.setAverageWorkMinutes(statisticsData.getAverageDayTime().getTotalWorkMinutes()); - stats.setFirstDay(statisticsData.getAverageDayTime().getFirstDay()); - - statisticsRepository.save(stats); + public TodayTimeVars getTodayTimeVars() { + long planned = 480; + long completed = 300; + return new TodayTimeVars(planned, completed); } - @Transactional(readOnly = true) - public TotalTimeTaskTypesDto getTotalTimeTaskTypes(Long userId) { - Statistics stats = statisticsRepository.findByUserId(userId) - .orElse(null); - - if (stats == null) { - return new TotalTimeTaskTypesDto(0, 0, 0, 0); - } - - return new TotalTimeTaskTypesDto( - stats.getTotalCommon(), - stats.getTotalWork(), - stats.getTotalStudy(), - stats.getTotalFitness() - ); - } - - @Transactional(readOnly = true) - public TodayTimeDto getTodayTimeDto(Long userId) { - Statistics stats = statisticsRepository.findByUserId(userId) - .orElse(null); - - if (stats == null) { - return new TodayTimeDto(0, 0); - } - - return new TodayTimeDto( - stats.getTodayPlanned(), - stats.getTodayCompleted() - ); + public ContinuousSuccessDaysVars getContinuousSuccessDaysVars() { + int record = 10; + int now = 5; + return new ContinuousSuccessDaysVars(record, now); } - @Transactional(readOnly = true) - public ContinuesSuccessDaysDto getContinuesSuccessDaysDto(Long userId) { - Statistics stats = statisticsRepository.findByUserId(userId) - .orElse(null); - - if (stats == null) { - return new ContinuesSuccessDaysDto(0, 0); - } - - return new ContinuesSuccessDaysDto( - stats.getContinuesRecord(), - stats.getContinuesNow() - ); - } - - @Transactional(readOnly = true) - public AverageDayTimeDto getAverageDayTimeDto(Long userId) { - Statistics stats = statisticsRepository.findByUserId(userId) - .orElse(null); - - if (stats == null) { - return new AverageDayTimeDto(0, null); - } - - return new AverageDayTimeDto( - stats.getAverageWorkMinutes(), - stats.getFirstDay() - ); + public AverageDayTimeVars getAverageDayTimeVars() { + long totalWorkMinutes = 14400; + long totalDays = 30; + return new AverageDayTimeVars(totalWorkMinutes / totalDays); } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/UserService.java b/src/main/java/com/smartcalendar/service/UserService.java index 0362e4f..0a75db3 100644 --- a/src/main/java/com/smartcalendar/service/UserService.java +++ b/src/main/java/com/smartcalendar/service/UserService.java @@ -1,14 +1,9 @@ package com.smartcalendar.service; -import com.smartcalendar.dto.DailyTaskDto; -import com.smartcalendar.dto.EventDto; -import com.smartcalendar.dto.StatisticsData; -import com.smartcalendar.dto.UserShortDto; import com.smartcalendar.model.Event; import com.smartcalendar.model.Task; import com.smartcalendar.model.User; import com.smartcalendar.repository.EventRepository; -import com.smartcalendar.repository.StatisticsRepository; import com.smartcalendar.repository.TaskRepository; import com.smartcalendar.repository.UserRepository; import jakarta.validation.constraints.Email; @@ -20,9 +15,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.*; -import java.util.stream.Collectors; -import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -31,9 +26,6 @@ public class UserService { private final TaskRepository taskRepository; private final EventRepository eventRepository; private final PasswordEncoder passwordEncoder; - private final StatisticsService statisticsService; - private final StatisticsRepository statisticsRepository; - private final NotificationService notificationService; public User createUser(User user) { if (userRepository.existsByUsername(user.getUsername())) { @@ -66,16 +58,10 @@ public Task createTask(Task task) { return taskRepository.save(task); } - @Transactional - public void deleteTask(UUID taskId) { + public void deleteTask(Long taskId) { taskRepository.deleteById(taskId); } - @Transactional - public void deleteEvent(UUID eventId) { - eventRepository.deleteById(eventId); - } - public boolean existsByUsername(String username) { return userRepository.existsByUsername(username); } @@ -88,7 +74,7 @@ public boolean existsByEmail(String email) { public boolean changeCredentials(String currentUsername, String currentPassword, String newUsername, String newPassword) { User user = userRepository.findByUsername(currentUsername) .orElseThrow(() -> new RuntimeException("User not found")); - + if (passwordEncoder.matches(currentPassword, user.getPassword())) { if (newUsername != null && !newUsername.isEmpty()) { user.setUsername(newUsername); @@ -131,334 +117,29 @@ public User updateEmail(Long id, String newEmail) { } @Transactional - public Task updateTaskStatus(UUID taskId, boolean completed) { + public Task updateTaskStatus(Long taskId, boolean completed) { Task task = taskRepository.findById(taskId) .orElseThrow(() -> new RuntimeException("Task not found")); task.setCompleted(completed); return taskRepository.save(task); } - @Transactional - public void updateEvent(UUID eventId, Event event) { - Event existingEvent = eventRepository.findById(eventId) - .orElseThrow(() -> new RuntimeException("Event not found")); - existingEvent.setTitle(event.getTitle()); - existingEvent.setDescription(event.getDescription()); - existingEvent.setStart(event.getStart()); - existingEvent.setEnd(event.getEnd()); - existingEvent.setLocation(event.getLocation()); - existingEvent.setType(event.getType()); - existingEvent.setCreationTime(event.getCreationTime()); - eventRepository.save(existingEvent); - } - @Transactional(readOnly = true) - public String getTaskDescription(UUID taskId) { + public String getTaskDescription(Long taskId) { Task task = taskRepository.findById(taskId) .orElseThrow(() -> new RuntimeException("Task not found")); return task.getDescription(); } public List findEventsByUserId(Long userId) { - List asOrganizer = eventRepository.findByOrganizerId(userId); - List asParticipant = eventRepository.findAll().stream() - .filter(e -> e.getParticipants() != null && e.getParticipants().stream().anyMatch(u -> u.getId().equals(userId))) - .collect(Collectors.toList()); - Set all = new HashSet<>(asOrganizer); - all.addAll(asParticipant); - return new ArrayList<>(all); + return eventRepository.findByOrganizerId(userId); } public Event createEvent(Event event) { - event.setId(null); return eventRepository.save(event); } public Optional findByUsername(String username) { return userRepository.findByUsername(username); } - - public List findAllEventsAsDailyTaskDto(Long userId) { - List events = findEventsByUserId(userId); - return events.stream().map(event -> new DailyTaskDto( - event.getId(), - event.getTitle(), - event.isCompleted(), - event.getType(), - event.getCreationTime(), - event.getDescription(), - event.getStart() != null ? event.getStart().toLocalTime() : null, - event.getEnd() != null ? event.getEnd().toLocalTime() : null, - event.getStart() != null ? event.getStart().toLocalDate() : null - )).collect(Collectors.toList()); - } - - public Task getTaskById(UUID taskId) { - return taskRepository.findById(taskId) - .orElseThrow(() -> new RuntimeException("Task not found")); - } - - public Event getEventById(UUID eventId) { - return eventRepository.findById(eventId) - .orElseThrow(() -> new RuntimeException("Event not found")); - } - - @Transactional - public Event updateEventStatus(UUID eventId, boolean completed) { - Event event = eventRepository.findById(eventId) - .orElseThrow(() -> new RuntimeException("Event not found")); - event.setCompleted(completed); - return eventRepository.save(event); - } - - @Transactional - public UUID deleteEventById(UUID eventId) { - eventRepository.deleteById(eventId); - return eventId; - } - - public StatisticsData getStatistics(Long userId) { - return statisticsService.getStatistics(userId); - } - - @Transactional - public void updateStatistics(Long userId, StatisticsData statisticsData) { - statisticsService.updateStatistics(userId, statisticsData); - } - - @Transactional - public Task createTaskWithCustomId(Task task) { - if (task.getId() != null && taskRepository.existsById(task.getId())) { - throw new IllegalArgumentException("Task with this id already exists"); - } - return taskRepository.save(task); - } - - @Transactional - public void editTask(UUID taskId, Task task) { - Task existingTask = taskRepository.findById(taskId) - .orElseThrow(() -> new RuntimeException("Task not found")); - existingTask.setTitle(task.getTitle()); - existingTask.setDescription(task.getDescription()); - existingTask.setCompleted(task.isCompleted()); - existingTask.setDueDateTime(task.getDueDateTime()); - existingTask.setAllDay(task.getAllDay()); - existingTask.setCreationTime(task.getCreationTime()); - taskRepository.save(existingTask); - } - - @Transactional - public Event createEventWithCustomId(Event event) { - if (event.getId() != null && eventRepository.existsById(event.getId())) { - throw new IllegalArgumentException("Event with this id already exists"); - } - return eventRepository.save(event); - } - - @Transactional - public void editEvent(UUID eventId, Event event) { - Event existingEvent = eventRepository.findById(eventId) - .orElseThrow(() -> new RuntimeException("Event not found")); - existingEvent.setTitle(event.getTitle()); - existingEvent.setDescription(event.getDescription()); - existingEvent.setStart(event.getStart()); - existingEvent.setEnd(event.getEnd()); - existingEvent.setLocation(event.getLocation()); - existingEvent.setType(event.getType()); - existingEvent.setCreationTime(event.getCreationTime()); - existingEvent.setCompleted(event.isCompleted()); - eventRepository.save(existingEvent); - } - - @Transactional - public void deleteAllUsersAndStatistics() { - statisticsRepository.deleteAll(); - userRepository.deleteAll(); - } - - @Transactional - public void deleteUser(Long userId) { - userRepository.deleteById(userId); - } - - public Optional findByEmail(String email) { - return userRepository.findByEmail(email); - } - - public void notifyUserAddedToEvent(User user, Event event, String deviceToken) { - if (user.getEmail() != null && !user.getEmail().isBlank()) { - String subject = "[TimeTamer SmartCalendar] You have been added to event: " + event.getTitle(); - String text = buildEventNotificationText( - "You have been added to the event:", - event, - event.getOrganizer() - ); - notificationService.sendEmail(user.getEmail(), subject, text); - } - if (deviceToken != null && !deviceToken.isBlank()) { - notificationService.sendPush( - deviceToken, - "Added to event", - "You have been added to event: " + event.getTitle() - ); - } - } - - public void notifyUserRemovedFromEvent(User user, Event event, String deviceToken) { - if (user.getEmail() != null && !user.getEmail().isBlank()) { - String subject = "[TimeTamer SmartCalendar] You have been removed from event: " + event.getTitle(); - String text = buildEventNotificationText( - "You have been removed from the event:", - event, - event.getOrganizer() - ); - notificationService.sendEmail(user.getEmail(), subject, text); - } - if (deviceToken != null && !deviceToken.isBlank()) { - notificationService.sendPush(deviceToken, - "Removed from event", - "You have been removed from event: " + event.getTitle()); - } - } - - private String buildEventNotificationText(String action, Event event, User organizer) { - DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); - String organizerName = organizer != null - ? organizer.getUsername() + " (" + organizer.getEmail() + ")" - : "a user"; - String eventType = event.getType() != null ? event.getType().name() : "COMMON"; - String start = event.getStart() != null ? event.getStart().format(dtf) : "unspecified"; - String end = event.getEnd() != null ? event.getEnd().format(dtf) : "unspecified"; - String location = event.getLocation() != null ? event.getLocation() : "unspecified"; - - return String.format( - "Hello!\n\n" + - "%s\n\n" + - "Title: %s\n" + - "Type: %s\n" + - "Start: %s\n" + - "End: %s\n" + - "Location: %s\n\n" + - "Description: %s\n\n" + - "Organizer: %s\n\n" + - "This is an automatic notification from TimeTamer SmartCalendar.\n", - action, - event.getTitle(), - eventType, - start, - end, - location, - event.getDescription() != null ? event.getDescription() : "No description", - organizerName - ); - } - - public List findEventsByInvitee(String email) { - return eventRepository.findAll().stream() - .filter(event -> event.getInvitees() != null && event.getInvitees().contains(email)) - .collect(Collectors.toList()); - } - - - public void notifyInvitees(Event event) { - if (event.getInvitees() == null || event.getInvitees().isEmpty()) return; - - String subject = "[TimeTamer SmartCalendar] Event invitation: " + event.getTitle(); - String text = buildEventNotificationText( - "You have been invited to the event:", - event, - event.getOrganizer() - ); - - for (String email : event.getInvitees()) { - Optional userOpt = findByEmail(email); - if (userOpt.isPresent()) { - User user = userOpt.get(); - notificationService.sendEmail(user.getEmail(), subject, text); - if (user.getDeviceToken() != null && !user.getDeviceToken().isBlank()) { - notificationService.sendPush(user.getDeviceToken(), "Invitation", text); - } - } else { - notificationService.sendEmail(email, subject, text); - } - } - } - - public Optional findByLoginOrEmail(String loginOrEmail) { - Optional userOpt = userRepository.findByUsername(loginOrEmail); - if (userOpt.isEmpty()) { - userOpt = userRepository.findByEmail(loginOrEmail); - } - return userOpt; - } - - @Transactional - public Event saveEvent(Event event) { - return eventRepository.save(event); - } - - public void notifyEventDeleted(Event event) { - String subject = "[TimeTamer SmartCalendar] Event \"" + event.getTitle() + "\" has been deleted"; - String text = buildEventNotificationText( - "The event has been deleted:", - event, - event.getOrganizer() - ); - notifyAllEventUsers(event, subject, text); - } - - public void notifyEventUpdated(Event oldEvent, Event newEvent) { - String subject = "[TimeTamer SmartCalendar] Event \"" + oldEvent.getTitle() + "\" has been updated"; - String text = buildEventNotificationText( - "The event has been updated:", - newEvent, - oldEvent.getOrganizer() - ); - notifyAllEventUsers(oldEvent, subject, text); - } - - private void notifyAllEventUsers(Event event, String subject, String text) { - if (event.getParticipants() != null) { - for (User user : event.getParticipants()) { - if (!user.getId().equals(event.getOrganizer().getId())) { - notificationService.sendEmail(user.getEmail(), subject, text); - if (user.getDeviceToken() != null && !user.getDeviceToken().isBlank()) { - notificationService.sendPush(user.getDeviceToken(), subject, text); - } - } - } - } - if (event.getInvitees() != null) { - for (String email : event.getInvitees()) { - notificationService.sendEmail(email, subject, text); - } - } - } - - public EventDto toEventDto(Event event) { - EventDto dto = new EventDto(); - dto.setId(event.getId()); - dto.setTitle(event.getTitle()); - dto.setDescription(event.getDescription()); - dto.setStart(event.getStart()); - dto.setEnd(event.getEnd()); - dto.setLocation(event.getLocation()); - dto.setType(event.getType()); - dto.setCreationTime(event.getCreationTime()); - dto.setCompleted(event.isCompleted()); - dto.setShared(event.isShared()); - dto.setInvitees(event.getInvitees()); - - if (event.getOrganizer() != null) { - dto.setOrganizer(new UserShortDto(event.getOrganizer().getUsername(), event.getOrganizer().getEmail())); - } - if (event.getParticipants() != null) { - dto.setParticipants( - event.getParticipants().stream() - .map(u -> new UserShortDto(u.getUsername(), u.getEmail())) - .toList() - ); - } - return dto; - } } \ No newline at end of file diff --git a/src/main/resources/application-h2.properties b/src/main/resources/application-h2.properties deleted file mode 100644 index 8d25e7c..0000000 --- a/src/main/resources/application-h2.properties +++ /dev/null @@ -1,71 +0,0 @@ -# =============================== -# DATABASE (H2) -# =============================== -spring.datasource.url=jdbc:h2:mem:smartcalendar;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= - -# =============================== -# H2 CONSOLE -# =============================== -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console -spring.h2.console.settings.trace=true -spring.h2.console.settings.web-allow-others=true - -# =============================== -# DATA INITIALIZATION -# =============================== -spring.sql.init.mode=always -spring.jpa.defer-datasource-initialization=true -spring.jpa.properties.hibernate.dialect= - -# =============================== -# LOGGING -# =============================== -logging.level.org.springframework=INFO -logging.level.com.smartcalendar=DEBUG -logging.level.org.hibernate.SQL=DEBUG -logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE -logging.level.org.springframework.security=DEBUG - -# =============================== -# JWT -# =============================== -app.jwt.secret=${JWT_SECRET} -app.jwt.expiration-ms=86400000 - -# =============================== -# SWAGGER -# =============================== -springdoc.swagger-ui.enabled=true -springdoc.api-docs.enabled=true -springdoc.api-docs.path=/v3/api-docs -springdoc.swagger-ui.path=/swagger-ui.html -springdoc.swagger-ui.tags-sorter=alpha -springdoc.swagger-ui.operations-sorter=alpha -springdoc.swagger-ui.oauth.client-id=your-client-id -springdoc.swagger-ui.oauth.use-pkce-with-authorization-code-grant=true - -# =============================== -# OTHER SETTINGS -# =============================== -server.port=8080 -server.address=0.0.0.0 -spring.main.banner-mode=off - -chatgpt.api.url=https://api.openai.com/v1/chat/completions -whisper.api.url=https://api.openai.com/v1/audio/transcriptions -chatgpt.api.key=${CHATGPT_API_KEY} - -# =============================== -# SMTP -# =============================== -spring.mail.host=smtp.gmail.com -spring.mail.port=587 -spring.mail.username=${MAIL_ADDRESS} -spring.mail.password=${MAIL_PASSWORD} -spring.mail.properties.mail.smtp.auth=true -spring.mail.properties.mail.smtp.starttls.enable=true -spring.mail.from=noreply@ttsc.com \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index 04ff572..8797063 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -3,14 +3,4 @@ spring.jpa.hibernate.ddl-auto=create-drop spring.datasource.url=jdbc:h2:mem:testdb_test chatgpt.api.url=http://dummy-url chatgpt.api.key=dummy-key -JWT_SECRET=your_test_secret -spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= -spring.h2.console.enabled=true -spring.h2.console.path=/h2-console -spring.h2.console.settings.trace=true -spring.h2.console.settings.web-allow-others=true -spring.sql.init.mode=always -spring.jpa.defer-datasource-initialization=true -spring.jpa.properties.hibernate.dialect= \ No newline at end of file +JWT_SECRET=your_test_secret \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9acbb95..42b6d97 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,12 +1,10 @@ # =============================== -# DATABASE (PostgreSQL) +# DATABASE (H2) # =============================== -spring.datasource.url=jdbc:postgresql://localhost:5432/smartcalendar -spring.datasource.driver-class-name=org.postgresql.Driver -spring.datasource.username=${DB_USERNAME} -spring.datasource.password=${DB_PASSWORD} -spring.jpa.show-sql=true -spring.jpa.database=postgresql +spring.datasource.url=jdbc:h2:mem:smartcalendar;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= # =============================== # H2 CONSOLE @@ -21,9 +19,6 @@ spring.h2.console.settings.web-allow-others=true # =============================== spring.sql.init.mode=always spring.jpa.defer-datasource-initialization=true -spring.jpa.hibernate.ddl-auto=create-drop -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect -spring.jpa.generate-ddl=true # =============================== # LOGGING @@ -61,15 +56,4 @@ spring.main.banner-mode=off chatgpt.api.url=https://api.openai.com/v1/chat/completions whisper.api.url=https://api.openai.com/v1/audio/transcriptions -chatgpt.api.key=${CHATGPT_API_KEY} - -# =============================== -# SMTP -# =============================== -spring.mail.host=smtp.gmail.com -spring.mail.port=587 -spring.mail.username=${MAIL_ADDRESS} -spring.mail.password=${MAIL_PASSWORD} -spring.mail.properties.mail.smtp.auth=true -spring.mail.properties.mail.smtp.starttls.enable=true -spring.mail.from=noreply@ttsc.com \ No newline at end of file +chatgpt.api.key=${CHATGPT_API_KEY} \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 5818795..0971f93 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,71 +1,6 @@ CREATE TABLE IF NOT EXISTS users ( - id BIGSERIAL PRIMARY KEY, + id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, - password VARCHAR(255) NOT NULL, - device_token VARCHAR(255) -); - -CREATE TABLE IF NOT EXISTS events ( - id UUID PRIMARY KEY, - title VARCHAR(255) NOT NULL, - description VARCHAR(255), - start_time TIMESTAMP NOT NULL, - end_time TIMESTAMP NOT NULL, - event_location VARCHAR(255) NOT NULL, - completed BOOLEAN NOT NULL, - is_shared BOOLEAN NOT NULL, - organizer_id BIGSERIAL NOT NULL, - FOREIGN KEY (organizer_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS tasks ( - id BIGSERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL, - description VARCHAR(255) NOT NULL, - is_completed BOOLEAN, - FOREIGN KEY (user_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS tags ( - id BIGSERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL -); - -CREATE TABLE IF NOT EXISTS group_chats ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (admin_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS group_messages ( - id BIGSERIAL PRIMARY KEY, - message_text VARCHAR(255) NOT NULL, - time_sent TIMESTAMP NOT NULL, - FOREIGN KEY (chat_id) REFERENCES group_chats (id), - FOREIGN KEY (user_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS private_chats ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (user_id1) REFERENCES users (id), - FOREIGN KEY (user_id2) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS private_messages ( - id BIGSERIAL PRIMARY KEY, - message_text VARCHAR(255) NOT NULL, - time_sent TIMESTAMP NOT NULL, - FOREIGN KEY (chat_id) REFERENCES private_chats (id), - FOREIGN KEY (user_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS friendships ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (user_id1) REFERENCES users (id), - FOREIGN KEY (user_id2) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS statistics ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (user_id) REFERENCES users (id) + password VARCHAR(255) NOT NULL ); \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/config/TestSecurityConfig.java b/src/test/java/com/smartcalendar/config/TestSecurityConfig.java deleted file mode 100644 index 6be38ce..0000000 --- a/src/test/java/com/smartcalendar/config/TestSecurityConfig.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.smartcalendar.config; - -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.authentication.ProviderManager; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.SecurityFilterChain; - -import java.util.Collections; - -@TestConfiguration -public class TestSecurityConfig { - @Bean - @Primary - public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { - http.csrf(csrf -> csrf.disable()) - .authorizeHttpRequests((authz) -> authz.anyRequest().permitAll()); - return http.build(); - } - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { - return authenticationConfiguration.getAuthenticationManager(); - } - - @Bean - public AuthenticationProvider testAuthenticationProvider() { - return new AuthenticationProvider() { - @Override - public Authentication authenticate(Authentication authentication) throws AuthenticationException { - authentication.setAuthenticated(true); - return authentication; - } - - @Override - public boolean supports(Class authentication) { - return true; - } - }; - } -} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java deleted file mode 100644 index 99282cd..0000000 --- a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.smartcalendar.controller; - -import com.smartcalendar.service.AudioProcessingService; -import com.smartcalendar.service.ChatGPTService; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.List; -import java.util.Map; - -import static org.mockito.ArgumentMatchers.any; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@SpringBootTest(properties = { - "JWT_SECRET=test_jwt_secret", - "chatgpt.api.url=http://dummy-url", - "chatgpt.api.key=dummy-key", - "spring.security.enabled=false" -}) -@ActiveProfiles("h2") -@AutoConfigureMockMvc -class AudioControllerIntegrationTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private AudioProcessingService audioProcessingService; - - @Autowired - private ChatGPTService chatGPTService; - - @TestConfiguration - static class MockConfig { - @Bean - public AudioProcessingService audioProcessingService() { - return Mockito.mock(AudioProcessingService.class); - } - @Bean - public ChatGPTService chatGPTService() { - return Mockito.mock(ChatGPTService.class); - } - } - - @Test - @WithMockUser - void testProcessAudio_Success() throws Exception { - MockMultipartFile file = new MockMultipartFile("file", "audio.wav", "audio/wav", "dummy".getBytes()); - - Mockito.when(audioProcessingService.transcribeAudio(any())).thenReturn("test transcript"); - Mockito.when(chatGPTService.processTranscript(any())).thenReturn( - Map.of("events", List.of(Map.of("title", "Event1")), "tasks", List.of()) - ); - Mockito.when(chatGPTService.convertToEntities(any())).thenReturn(List.of()); - - mockMvc.perform(multipart("/api/audio/process").file(file)) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser - void testProcessAudio_Error() throws Exception { - MockMultipartFile file = new MockMultipartFile("file", "audio.wav", "audio/wav", "dummy".getBytes()); - - Mockito.when(audioProcessingService.transcribeAudio(any())).thenThrow(new RuntimeException("Transcribe error")); - - mockMvc.perform(multipart("/api/audio/process").file(file)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").exists()); - } -} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/AuthControllerTest.java b/src/test/java/com/smartcalendar/controller/AuthControllerTest.java index b10d96d..2220bd5 100644 --- a/src/test/java/com/smartcalendar/controller/AuthControllerTest.java +++ b/src/test/java/com/smartcalendar/controller/AuthControllerTest.java @@ -11,7 +11,6 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; @@ -22,8 +21,6 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.web.servlet.MockMvc; -import java.util.Map; - import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -33,7 +30,6 @@ @SpringBootTest @AutoConfigureMockMvc -@Import(com.smartcalendar.config.TestSecurityConfig.class) @ActiveProfiles("test") @SpringJUnitConfig(AuthControllerTest.TestConfig.class) public class AuthControllerTest { @@ -71,18 +67,10 @@ public JwtService jwtService() { private User testUser; - private String registrationRequestJson(String username, String email, String password, String firstDay) throws Exception { - return objectMapper.writeValueAsString(Map.of( - "username", username, - "email", email, - "password", password, - "firstDay", firstDay - )); - } - @BeforeEach void setUp() { - userService.deleteAllUsersAndStatistics(); + // Cleanup and setup test user + userService.deleteAllUsers(); testUser = new User(); testUser.setUsername("testuser"); @@ -93,29 +81,22 @@ void setUp() { @Test void testRegisterUser_Success() throws Exception { + User newUser = new User(); + newUser.setUsername("newuser"); + newUser.setEmail("new@example.com"); + newUser.setPassword("Password123!"); + mockMvc.perform(post("/api/auth/signup") .contentType(MediaType.APPLICATION_JSON) - .content(registrationRequestJson("newuser", "new@example.com", "Password123!", "2025-06-07"))) + .content(objectMapper.writeValueAsString(newUser))) .andExpect(status().isOk()) .andExpect(jsonPath("$.username").value("newuser")) .andExpect(jsonPath("$.email").value("new@example.com")); } - @Test - void testRegisterUser_MissingFirstDay() throws Exception { - String json = objectMapper.writeValueAsString(Map.of( - "username", "user2", - "email", "user2@example.com", - "password", "Password123!" - )); - mockMvc.perform(post("/api/auth/signup") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isBadRequest()); - } - @Test void testLogin_Success() throws Exception { + // Mock authentication Authentication auth = new UsernamePasswordAuthenticationToken( testUser.getUsername(), testUser.getPassword() @@ -126,6 +107,7 @@ void testLogin_Success() throws Exception { when(jwtService.generateToken(testUser.getUsername())) .thenReturn("mocked.jwt.token"); + // Test request mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(testUser))) @@ -150,11 +132,13 @@ void testLogin_InvalidCredentials() throws Exception { @Test void testLogin_MissingFields() throws Exception { + // Missing username mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"password123\"}")) .andExpect(status().isBadRequest()); + // Missing password mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"testuser\"}")) @@ -163,9 +147,14 @@ void testLogin_MissingFields() throws Exception { @Test void testRegisterUser_DuplicateUsername() throws Exception { + User duplicateUser = new User(); + duplicateUser.setUsername("testuser"); + duplicateUser.setEmail("duplicate@example.com"); + duplicateUser.setPassword("password123"); + mockMvc.perform(post("/api/auth/signup") .contentType(MediaType.APPLICATION_JSON) - .content(registrationRequestJson("testuser", "duplicate@example.com", "password123", "2025-06-07"))) + .content(objectMapper.writeValueAsString(duplicateUser))) .andExpect(status().isBadRequest()); } @@ -179,6 +168,7 @@ void testCreateUser_PasswordIsHashed() { User savedUser = userService.createUser(user); assertNotEquals("rawPassword123", savedUser.getPassword()); + assertTrue(passwordEncoder.matches("rawPassword123", savedUser.getPassword())); } } \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java deleted file mode 100644 index 081bc73..0000000 --- a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.smartcalendar.controller; - -import com.smartcalendar.service.ChatGPTService; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.List; -import java.util.Map; - -import static org.mockito.ArgumentMatchers.any; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@SpringBootTest(properties = { - "JWT_SECRET=test_jwt_secret", - "chatgpt.api.url=http://dummy-url", - "chatgpt.api.key=dummy-key", - "spring.security.enabled=false" -}) -@ActiveProfiles("h2") -@AutoConfigureMockMvc -class ChatGPTControllerIntegrationTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ChatGPTService chatGPTService; - - @TestConfiguration - static class MockConfig { - @Bean - public ChatGPTService chatGPTService() { - return Mockito.mock(ChatGPTService.class); - } - } - - @Test - @WithMockUser - void testAskChatGPT() throws Exception { - Mockito.when(chatGPTService.askChatGPT(any(), any())).thenReturn("response"); - mockMvc.perform(post("/api/chatgpt/ask") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"question\":\"test?\"}")) - .andExpect(status().isOk()) - .andExpect(content().string("response")); - } - - @Test - @WithMockUser - void testGenerateEventsAndTasks() throws Exception { - Mockito.when(chatGPTService.generateEventsAndTasks(any())).thenReturn( - Map.of("events", List.of(), "tasks", List.of()) - ); - mockMvc.perform(post("/api/chatgpt/generate") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"query\":\"test\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.events").exists()) - .andExpect(jsonPath("$.tasks").exists()); - } - - @Test - @WithMockUser - void testGenerateEntities_Error() throws Exception { - Mockito.when(chatGPTService.processTranscript(any())).thenReturn(Map.of("error", "Unrelated request")); - mockMvc.perform(post("/api/chatgpt/generate/entities") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"query\":\"test\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").exists()); - } -} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/StatisticsControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/StatisticsControllerIntegrationTest.java deleted file mode 100644 index dc80247..0000000 --- a/src/test/java/com/smartcalendar/controller/StatisticsControllerIntegrationTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.smartcalendar.controller; - -import com.smartcalendar.dto.*; -import com.smartcalendar.model.User; -import com.smartcalendar.service.StatisticsService; -import com.smartcalendar.service.UserService; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; -import org.springframework.http.MediaType; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.Date; -import java.util.Optional; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@SpringBootTest(properties = { - "JWT_SECRET=test_jwt_secret" -}) -@ActiveProfiles("test") -@AutoConfigureMockMvc -@Import(com.smartcalendar.config.TestSecurityConfig.class) -class StatisticsControllerIntegrationTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private StatisticsService statisticsService; - - @Autowired - private UserService userService; - - @TestConfiguration - static class MockConfig { - @Bean - public StatisticsService statisticsService() { - return Mockito.mock(StatisticsService.class); - } - @Bean - public UserService userService() { - return Mockito.mock(UserService.class); - } - } - - private void mockAuth() { - User user = new User(); - user.setId(1L); - user.setUsername("testuser"); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - } - - @Test - @WithMockUser - void testGetTotalTimeTaskTypes() throws Exception { - mockAuth(); - Mockito.when(statisticsService.getTotalTimeTaskTypes(any())).thenReturn(new TotalTimeTaskTypesDto(1,2,3,4)); - mockMvc.perform(get("/api/statistics/total-time-task-types")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.common").value(1)); - } - - @Test - @WithMockUser - void testGetTodayTimeDto() throws Exception { - mockAuth(); - Mockito.when(statisticsService.getTodayTimeDto(any())).thenReturn(new TodayTimeDto(5,6)); - mockMvc.perform(get("/api/statistics/today")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.planned").value(5)); - } - - @Test - @WithMockUser - void testGetContinuesSuccessDaysDto() throws Exception { - mockAuth(); - Mockito.when(statisticsService.getContinuesSuccessDaysDto(any())).thenReturn(new ContinuesSuccessDaysDto(7,8)); - mockMvc.perform(get("/api/statistics/continuous-success-days")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.record").value(7)); - } - - @Test - @WithMockUser - void testGetAverageDayTimeDto() throws Exception { - mockAuth(); - Mockito.when(statisticsService.getAverageDayTimeDto(any())).thenReturn(new AverageDayTimeDto(9, null)); - mockMvc.perform(get("/api/statistics/average-day-time")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.totalWorkMinutes").value(9)); - } - - @Test - @WithMockUser - void testGetStatisticsWithJsonDate() throws Exception { - mockAuth(); - StatisticsData statisticsData = new StatisticsData( - new TotalTimeTaskTypesDto(1, 2, 3, 4), - 5L, - new TodayTimeDto(6, 7), - new ContinuesSuccessDaysDto(8, 9), - new AverageDayTimeDto(10, null), - new Date() - ); - Mockito.when(userService.getStatistics(any())).thenReturn(statisticsData); - - mockMvc.perform(get("/api/users/1/statistics")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.totalTime.common").value(1)) - .andExpect(jsonPath("$.jsonDate").exists()); - } -} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java deleted file mode 100644 index 748c9b0..0000000 --- a/src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java +++ /dev/null @@ -1,545 +0,0 @@ -package com.smartcalendar.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.smartcalendar.dto.DailyTaskDto; -import com.smartcalendar.dto.EventDto; -import com.smartcalendar.dto.StatisticsData; -import com.smartcalendar.dto.UserShortDto; -import com.smartcalendar.model.Event; -import com.smartcalendar.model.EventType; -import com.smartcalendar.model.Task; -import com.smartcalendar.model.User; -import com.smartcalendar.service.UserService; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.*; - -import static org.mockito.ArgumentMatchers.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@SpringBootTest(properties = { - "JWT_SECRET=test_jwt_secret", - "spring.security.enabled=false" -}) -@ActiveProfiles("test") -@AutoConfigureMockMvc -@Import(com.smartcalendar.config.TestSecurityConfig.class) -class UserControllerIntegrationTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private UserService userService; - - @Autowired - private ObjectMapper objectMapper; - - @TestConfiguration - static class MockConfig { - @Bean - public UserService userService() { - return Mockito.mock(UserService.class); - } - } - - private User mockUser(Long id, String username) { - User user = new User(); - user.setId(id); - user.setUsername(username); - user.setEmail(username + "@example.com"); - return user; - } - - // --- USERS --- - - @Test - @WithMockUser - void testGetAllUsers() throws Exception { - Mockito.when(userService.findAllUsers()).thenReturn(Collections.emptyList()); - mockMvc.perform(get("/api/users")) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser - void testGetUserById() throws Exception { - User user = mockUser(1L, "testuser"); - Mockito.when(userService.findUserById(1L)).thenReturn(user); - mockMvc.perform(get("/api/users/1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.username").value("testuser")); - } - - @Test - @WithMockUser - void testCreateUser() throws Exception { - User user = mockUser(2L, "newuser"); - Mockito.when(userService.createUser(any(User.class))).thenReturn(user); - - String json = """ - { - "username": "newuser", - "email": "newuser@example.com", - "password": "Password123!" - } - """; - mockMvc.perform(post("/api/users") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.username").value("newuser")); - } - - @Test - @WithMockUser - void testCreateUserWithExistingUsername() throws Exception { - Mockito.when(userService.createUser(any(User.class))) - .thenThrow(new IllegalArgumentException("Username already exists")); - - String json = """ - { - "username": "existinguser", - "email": "newuser@example.com", - "password": "Password123!" - } - """; - mockMvc.perform(post("/api/users") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("Username already exists")); - } - - @Test - @WithMockUser - void testCreateUserWithExistingEmail() throws Exception { - Mockito.when(userService.createUser(any(User.class))) - .thenThrow(new IllegalArgumentException("Email already exists")); - - String json = """ - { - "username": "newuser", - "email": "existing@example.com", - "password": "Password123!" - } - """; - mockMvc.perform(post("/api/users") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("Email already exists")); - } - - // --- EMAIL --- - - @Test - @WithMockUser - void testUpdateEmail() throws Exception { - User user = mockUser(1L, "testuser"); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.updateEmail(eq(1L), anyString())).thenReturn(user); - - mockMvc.perform(put("/api/users/1/email") - .contentType(MediaType.APPLICATION_JSON) - .content("\"newemail@example.com\"")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.username").value("testuser")); - } - - @Test - @WithMockUser - void testUpdateEmail_Forbidden() throws Exception { - User user = mockUser(1L, "testuser"); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - mockMvc.perform(put("/api/users/2/email") - .contentType(MediaType.APPLICATION_JSON) - .content("\"newemail@example.com\"")) - .andExpect(status().isForbidden()); - } - - // --- TASKS --- - - @Test - @WithMockUser - void testGetTasksByUserId() throws Exception { - User user = mockUser(1L, "testuser"); - Task task = new Task(); - task.setId(UUID.randomUUID()); - task.setUser(user); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.findTasksByUserId(1L)).thenReturn(List.of(task)); - - mockMvc.perform(get("/api/users/1/tasks")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").exists()); - } - - @Test - @WithMockUser - void testGetTasksByUserId_Forbidden() throws Exception { - User user = mockUser(1L, "testuser"); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - mockMvc.perform(get("/api/users/2/tasks")) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser - void testCreateTask() throws Exception { - User user = mockUser(1L, "testuser"); - Task task = new Task(); - task.setId(UUID.randomUUID()); - task.setUser(user); - - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.createTaskWithCustomId(any(Task.class))).thenReturn(task); - - String json = objectMapper.writeValueAsString(task); - mockMvc.perform(post("/api/users/1/tasks") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").exists()); - } - - @Test - @WithMockUser - void testCreateTask_Forbidden() throws Exception { - User user = mockUser(1L, "testuser"); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Task task = new Task(); - String json = objectMapper.writeValueAsString(task); - mockMvc.perform(post("/api/users/2/tasks") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser - void testGetTaskDescription_Forbidden() throws Exception { - User user = mockUser(1L, "testuser"); - Task task = new Task(); - task.setId(UUID.randomUUID()); - User otherUser = mockUser(2L, "otheruser"); - task.setUser(otherUser); - - Mockito.when(userService.getTaskById(any(UUID.class))).thenReturn(task); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - - mockMvc.perform(get("/api/users/tasks/" + task.getId() + "/description")) - .andExpect(status().isForbidden()); - } - - // --- EVENTS --- - - @Test - @WithMockUser - void testGetEventsByUserId() throws Exception { - User user = mockUser(1L, "testuser"); - Event event = new Event(); - event.setId(UUID.randomUUID()); - event.setOrganizer(user); - - EventDto eventDto = new EventDto(); - eventDto.setId(event.getId()); - eventDto.setTitle("Test Event"); - eventDto.setOrganizer(new UserShortDto(user.getUsername(), user.getEmail())); - - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.findEventsByUserId(1L)).thenReturn(List.of(event)); - Mockito.when(userService.toEventDto(event)).thenReturn(eventDto); - - mockMvc.perform(get("/api/users/1/events")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(event.getId().toString())) - .andExpect(jsonPath("$[0].organizer.username").value("testuser")); - } - - @Test - @WithMockUser - void testGetEventsByUserId_Forbidden() throws Exception { - User user = mockUser(1L, "testuser"); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - mockMvc.perform(get("/api/users/2/events")) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser - void testCreateEvent() throws Exception { - User user = mockUser(1L, "testuser"); - Event event = new Event(); - event.setId(UUID.randomUUID()); - event.setOrganizer(user); - - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.createEventWithCustomId(any(Event.class))).thenReturn(event); - - String json = objectMapper.writeValueAsString(event); - mockMvc.perform(post("/api/users/1/events") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").exists()); - } - - @Test - @WithMockUser - void testCreateEvent_Forbidden() throws Exception { - User user = mockUser(1L, "testuser"); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Event event = new Event(); - String json = objectMapper.writeValueAsString(event); - mockMvc.perform(post("/api/users/2/events") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isForbidden()); - } - - // --- COLLABORATIVE EVENTS --- - - @Test - @WithMockUser - void testInviteUserToEvent_UserAlreadyParticipant() throws Exception { - User user = mockUser(1L, "testuser"); - Event event = new Event(); - event.setId(UUID.randomUUID()); - event.setParticipants(List.of(user)); - Mockito.when(userService.getEventById(event.getId())).thenReturn(event); - Mockito.when(userService.findByLoginOrEmail("testuser")).thenReturn(Optional.of(user)); - - mockMvc.perform(post("/api/users/events/" + event.getId() + "/invite") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"loginOrEmail\":\"testuser\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("User is already a participant")); - } - - @Test - @WithMockUser - void testInviteUserToEvent_UserAlreadyInvited() throws Exception { - User user = mockUser(1L, "testuser"); - Event event = new Event(); - event.setId(UUID.randomUUID()); - event.setInvitees(new ArrayList<>(List.of(user.getEmail()))); - Mockito.when(userService.getEventById(event.getId())).thenReturn(event); - Mockito.when(userService.findByLoginOrEmail("testuser")).thenReturn(Optional.of(user)); - - mockMvc.perform(post("/api/users/events/" + event.getId() + "/invite") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"loginOrEmail\":\"testuser\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("User is already invited")); - } - - @Test - @WithMockUser - void testInviteUserToEvent_UserNotFound() throws Exception { - Event event = new Event(); - event.setId(UUID.randomUUID()); - Mockito.when(userService.getEventById(event.getId())).thenReturn(event); - Mockito.when(userService.findByLoginOrEmail("nouser")).thenReturn(Optional.empty()); - - mockMvc.perform(post("/api/users/events/" + event.getId() + "/invite") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"loginOrEmail\":\"nouser\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("User not found")); - } - - @Test - @WithMockUser - void testAcceptInvite_NoInvite() throws Exception { - User user = mockUser(1L, "testuser"); - Event event = new Event(); - event.setId(UUID.randomUUID()); - event.setInvitees(new ArrayList<>()); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.getEventById(event.getId())).thenReturn(event); - - mockMvc.perform(post("/api/users/events/" + event.getId() + "/accept-invite")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("No invite found for this user")); - } - - @Test - @WithMockUser - void testRemoveParticipant_NotOrganizer() throws Exception { - User user = mockUser(1L, "testuser"); - User other = mockUser(2L, "other"); - Event event = new Event(); - event.setId(UUID.randomUUID()); - event.setOrganizer(other); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.getEventById(event.getId())).thenReturn(event); - - mockMvc.perform(post("/api/users/events/" + event.getId() + "/remove-participant") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"loginOrEmail\":\"other\"}")) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.error").value("Only organizer can remove participants")); - } - - @Test - @WithMockUser - void testRemoveParticipant_OrganizerCannotBeRemoved() throws Exception { - User user = mockUser(1L, "testuser"); - Event event = new Event(); - event.setId(UUID.randomUUID()); - event.setOrganizer(user); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.getEventById(event.getId())).thenReturn(event); - Mockito.when(userService.findByLoginOrEmail("testuser")).thenReturn(Optional.of(user)); - - mockMvc.perform(post("/api/users/events/" + event.getId() + "/remove-participant") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"loginOrEmail\":\"testuser\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("Organizer cannot be removed")); - } - - @Test - @WithMockUser - void testRemoveParticipant_UserNotFound() throws Exception { - User user = mockUser(1L, "testuser"); - Event event = new Event(); - event.setId(UUID.randomUUID()); - event.setOrganizer(user); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.getEventById(event.getId())).thenReturn(event); - Mockito.when(userService.findByLoginOrEmail("nouser")).thenReturn(Optional.empty()); - - mockMvc.perform(post("/api/users/events/" + event.getId() + "/remove-participant") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"loginOrEmail\":\"nouser\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("User not found")); - } - - @Test - @WithMockUser - void testRemoveParticipant_UserNotParticipant() throws Exception { - User user = mockUser(1L, "testuser"); - User other = mockUser(2L, "other"); - Event event = new Event(); - event.setId(UUID.randomUUID()); - event.setOrganizer(user); - event.setParticipants(new ArrayList<>()); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.getEventById(event.getId())).thenReturn(event); - Mockito.when(userService.findByLoginOrEmail("other")).thenReturn(Optional.of(other)); - - mockMvc.perform(post("/api/users/events/" + event.getId() + "/remove-participant") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"loginOrEmail\":\"other\"}")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error").value("User is not a participant")); - } - - // --- STATISTICS --- - - @Test - @WithMockUser - void testGetStatistics() throws Exception { - User user = mockUser(1L, "testuser"); - StatisticsData statisticsData = new StatisticsData(); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.getStatistics(1L)).thenReturn(statisticsData); - - mockMvc.perform(get("/api/users/1/statistics")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.totalTime").exists()); - } - - @Test - @WithMockUser - void testGetStatistics_Forbidden() throws Exception { - User user = mockUser(1L, "testuser"); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - mockMvc.perform(get("/api/users/2/statistics")) - .andExpect(status().isForbidden()); - } - - @Test - @WithMockUser - void testUpdateStatistics() throws Exception { - User user = mockUser(1L, "testuser"); - StatisticsData statisticsData = new StatisticsData(); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - - String json = objectMapper.writeValueAsString(statisticsData); - mockMvc.perform(put("/api/users/1/statistics") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isOk()); - } - - @Test - @WithMockUser - void testUpdateStatistics_Forbidden() throws Exception { - User user = mockUser(1L, "testuser"); - StatisticsData statisticsData = new StatisticsData(); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - - String json = objectMapper.writeValueAsString(statisticsData); - mockMvc.perform(put("/api/users/2/statistics") - .contentType(MediaType.APPLICATION_JSON) - .content(json)) - .andExpect(status().isForbidden()); - } - - // --- EDGE CASES --- - - @Test - @WithMockUser - void testGetAllEventsAsDailyTasks_Empty() throws Exception { - User user = mockUser(1L, "testuser"); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.findAllEventsAsDailyTaskDto(1L)).thenReturn(Collections.emptyList()); - - mockMvc.perform(get("/api/users/1/events/dailytasks")) - .andExpect(status().isOk()) - .andExpect(content().json("[]")); - } - - @Test - @WithMockUser - void testGetEventsByUserId_Empty() throws Exception { - User user = mockUser(1L, "testuser"); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.findEventsByUserId(1L)).thenReturn(Collections.emptyList()); - - mockMvc.perform(get("/api/users/1/events")) - .andExpect(status().isOk()) - .andExpect(content().json("[]")); - } - - @Test - @WithMockUser - void testGetTasksByUserId_Empty() throws Exception { - User user = mockUser(1L, "testuser"); - Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); - Mockito.when(userService.findTasksByUserId(1L)).thenReturn(Collections.emptyList()); - - mockMvc.perform(get("/api/users/1/tasks")) - .andExpect(status().isOk()) - .andExpect(content().json("[]")); - } -} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java b/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java deleted file mode 100644 index 28f339e..0000000 --- a/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.smartcalendar.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.*; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.web.reactive.function.client.WebClient; - -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; -@ActiveProfiles("h2") -class ChatGPTServiceTest { - - @InjectMocks - private ChatGPTService chatGPTService; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - } - - @Test - void testConvertToEntities() { - Map> data = Map.of( - "events", List.of(Map.of("title", "Event1")), - "tasks", List.of(Map.of("title", "Task1", "completed", false)) - ); - var entities = chatGPTService.convertToEntities(data); - assertEquals(2, entities.size()); - assertTrue(entities.stream().anyMatch(e -> e.getClass().getSimpleName().equals("Event"))); - assertTrue(entities.stream().anyMatch(e -> e.getClass().getSimpleName().equals("Task"))); - } - - @Test - void testProcessTranscript_Error() { - ChatGPTService spyService = spy(chatGPTService); - doReturn("{\"error\": \"Unrelated request\"}").when(spyService).askChatGPT(anyString(), anyString()); - Map result = spyService.processTranscript("some unrelated text"); - assertTrue(result.containsKey("error")); - } - - @Test - void testGenerateEventsAndTasks_ValidJson() { - ChatGPTService spyService = spy(chatGPTService); - doReturn("{\"events\":[],\"tasks\":[]}").when(spyService).askChatGPT(anyString(), anyString()); - Map> result = spyService.generateEventsAndTasks("test"); - assertTrue(result.containsKey("events")); - assertTrue(result.containsKey("tasks")); - } -} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/service/StatisticsServiceTest.java b/src/test/java/com/smartcalendar/service/StatisticsServiceTest.java deleted file mode 100644 index 020a23f..0000000 --- a/src/test/java/com/smartcalendar/service/StatisticsServiceTest.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.smartcalendar.service; - -import com.smartcalendar.dto.*; -import com.smartcalendar.model.Statistics; -import com.smartcalendar.model.User; -import com.smartcalendar.repository.StatisticsRepository; -import com.smartcalendar.repository.UserRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.*; -import org.springframework.test.context.ActiveProfiles; - -import java.time.LocalDate; -import java.util.Date; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; -@ActiveProfiles("h2") -class StatisticsServiceTest { - - @Mock - private StatisticsRepository statisticsRepository; - - @Mock - private UserRepository userRepository; - - @InjectMocks - private StatisticsService statisticsService; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - } - - @Test - void testGetStatistics_WhenStatsExist() { - Statistics stats = new Statistics(); - stats.setTotalCommon(1); - stats.setTotalWork(2); - stats.setTotalStudy(3); - stats.setTotalFitness(4); - stats.setWeekTime(5); - stats.setTodayPlanned(6); - stats.setTodayCompleted(7); - stats.setContinuesRecord(8); - stats.setContinuesNow(9); - stats.setAverageWorkMinutes(10); - stats.setFirstDay(LocalDate.of(2024, 1, 1)); - - when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); - - StatisticsData data = statisticsService.getStatistics(1L); - - assertEquals(1, data.getTotalTime().getCommon()); - assertEquals(2, data.getTotalTime().getWork()); - assertEquals(3, data.getTotalTime().getStudy()); - assertEquals(4, data.getTotalTime().getFitness()); - assertEquals(5, data.getWeekTime()); - assertEquals(6, data.getTodayTime().getPlanned()); - assertEquals(7, data.getTodayTime().getCompleted()); - assertEquals(8, data.getContinuesSuccessDays().getRecord()); - assertEquals(9, data.getContinuesSuccessDays().getNow()); - assertEquals(10, data.getAverageDayTime().getTotalWorkMinutes()); - assertEquals(LocalDate.of(2024, 1, 1), data.getAverageDayTime().getFirstDay()); - assertNotNull(data.getJsonDate()); // Новая проверка - assertTrue(data.getJsonDate().getTime() <= new Date().getTime()); // jsonDate не в будущем - } - - @Test - void testGetStatistics_WhenStatsNotExist() { - when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.empty()); - StatisticsData data = statisticsService.getStatistics(1L); - assertNotNull(data); - assertEquals(0, data.getTotalTime().getCommon()); - assertNull(data.getAverageDayTime().getFirstDay()); - assertNotNull(data.getJsonDate()); // Новая проверка - assertTrue(data.getJsonDate().getTime() <= new Date().getTime()); - } - - @Test - void testUpdateStatistics_NewStats() { - User user = new User(); - user.setId(1L); - - StatisticsData dto = new StatisticsData( - new TotalTimeTaskTypesDto(1, 2, 3, 4), - 5L, - new TodayTimeDto(6, 7), - new ContinuesSuccessDaysDto(8, 9), - new AverageDayTimeDto(10, LocalDate.of(2024, 1, 1)), - new Date() - ); - - when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.empty()); - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(statisticsRepository.save(any(Statistics.class))).thenAnswer(i -> i.getArgument(0)); - - assertDoesNotThrow(() -> statisticsService.updateStatistics(1L, dto)); - verify(statisticsRepository).save(any(Statistics.class)); - } - - @Test - void testUpdateStatistics_ExistingStats() { - User user = new User(); - user.setId(1L); - Statistics stats = new Statistics(); - stats.setUser(user); - - StatisticsData dto = new StatisticsData( - new TotalTimeTaskTypesDto(1, 2, 3, 4), - 5L, - new TodayTimeDto(6, 7), - new ContinuesSuccessDaysDto(8, 9), - new AverageDayTimeDto(10, LocalDate.of(2024, 1, 1)), - new Date() - ); - - when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); - when(statisticsRepository.save(any(Statistics.class))).thenAnswer(i -> i.getArgument(0)); - - assertDoesNotThrow(() -> statisticsService.updateStatistics(1L, dto)); - verify(statisticsRepository).save(any(Statistics.class)); - } - - @Test - void testGetTotalTimeTaskTypes() { - Statistics stats = new Statistics(); - stats.setTotalCommon(1); - stats.setTotalWork(2); - stats.setTotalStudy(3); - stats.setTotalFitness(4); - - when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); - TotalTimeTaskTypesDto dto = statisticsService.getTotalTimeTaskTypes(1L); - assertEquals(1, dto.getCommon()); - assertEquals(2, dto.getWork()); - assertEquals(3, dto.getStudy()); - assertEquals(4, dto.getFitness()); - } - - @Test - void testGetTodayTimeDto() { - Statistics stats = new Statistics(); - stats.setTodayPlanned(5); - stats.setTodayCompleted(6); - - when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); - TodayTimeDto dto = statisticsService.getTodayTimeDto(1L); - assertEquals(5, dto.getPlanned()); - assertEquals(6, dto.getCompleted()); - } - - @Test - void testGetContinuesSuccessDaysDto() { - Statistics stats = new Statistics(); - stats.setContinuesRecord(7); - stats.setContinuesNow(8); - - when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); - ContinuesSuccessDaysDto dto = statisticsService.getContinuesSuccessDaysDto(1L); - assertEquals(7, dto.getRecord()); - assertEquals(8, dto.getNow()); - } - - @Test - void testGetAverageDayTimeDto() { - Statistics stats = new Statistics(); - stats.setAverageWorkMinutes(9); - stats.setFirstDay(LocalDate.of(2024, 2, 2)); - - when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); - AverageDayTimeDto dto = statisticsService.getAverageDayTimeDto(1L); - assertEquals(9, dto.getTotalWorkMinutes()); - assertEquals(LocalDate.of(2024, 2, 2), dto.getFirstDay()); - } -} diff --git a/src/test/java/com/smartcalendar/service/UserServiceTest.java b/src/test/java/com/smartcalendar/service/UserServiceTest.java index 4df42ce..baee1ba 100644 --- a/src/test/java/com/smartcalendar/service/UserServiceTest.java +++ b/src/test/java/com/smartcalendar/service/UserServiceTest.java @@ -2,24 +2,18 @@ import com.smartcalendar.model.User; import com.smartcalendar.repository.UserRepository; -import com.smartcalendar.repository.StatisticsRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.context.ActiveProfiles; import java.util.Optional; -import java.util.Collections; -import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -@ActiveProfiles("h2") + class UserServiceTest { @Mock @@ -28,9 +22,6 @@ class UserServiceTest { @Mock private PasswordEncoder passwordEncoder; - @Mock - private StatisticsRepository statisticsRepository; - @InjectMocks private UserService userService; @@ -44,10 +35,7 @@ void testCreateUser() { User user = new User(); user.setUsername("testuser"); user.setPassword("password"); - user.setEmail("test@example.com"); - when(userRepository.existsByUsername("testuser")).thenReturn(false); - when(userRepository.existsByEmail("test@example.com")).thenReturn(false); when(passwordEncoder.encode("password")).thenReturn("encodedPassword"); when(userRepository.save(any(User.class))).thenReturn(user); @@ -59,31 +47,6 @@ void testCreateUser() { verify(userRepository).save(user); } - @Test - void testCreateUser_UsernameExists() { - User user = new User(); - user.setUsername("testuser"); - user.setEmail("test@example.com"); - - when(userRepository.existsByUsername("testuser")).thenReturn(true); - - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> userService.createUser(user)); - assertEquals("Username already exists", ex.getMessage()); - } - - @Test - void testCreateUser_EmailExists() { - User user = new User(); - user.setUsername("testuser"); - user.setEmail("test@example.com"); - - when(userRepository.existsByUsername("testuser")).thenReturn(false); - when(userRepository.existsByEmail("test@example.com")).thenReturn(true); - - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> userService.createUser(user)); - assertEquals("Email already exists", ex.getMessage()); - } - @Test void testFindUserById() { User user = new User(); @@ -101,198 +64,20 @@ void testFindUserById() { } @Test - void testFindUserById_NotFound() { - when(userRepository.findById(99L)).thenReturn(Optional.empty()); - assertThrows(RuntimeException.class, () -> userService.findUserById(99L)); - } - - @Test - void testFindByUsername() { - User user = new User(); - user.setUsername("testuser"); - - when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user)); - - Optional found = userService.findByUsername("testuser"); - assertTrue(found.isPresent()); - assertEquals("testuser", found.get().getUsername()); - } - - @Test - void testFindByUsername_NotFound() { - when(userRepository.findByUsername("nouser")).thenReturn(Optional.empty()); - Optional found = userService.findByUsername("nouser"); - assertFalse(found.isPresent()); - } - - @Test - void testFindByEmail() { - User user = new User(); - user.setEmail("test@example.com"); - - when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user)); - - Optional found = userService.findByEmail("test@example.com"); - assertTrue(found.isPresent()); - assertEquals("test@example.com", found.get().getEmail()); - } - - @Test - void testFindByEmail_NotFound() { - when(userRepository.findByEmail("no@mail.com")).thenReturn(Optional.empty()); - Optional found = userService.findByEmail("no@mail.com"); - assertFalse(found.isPresent()); - } - - @Test - void testExistsByUsername() { - when(userRepository.existsByUsername("testuser")).thenReturn(true); - assertTrue(userService.existsByUsername("testuser")); - } - - @Test - void testExistsByEmail() { - when(userRepository.existsByEmail("test@example.com")).thenReturn(true); - assertTrue(userService.existsByEmail("test@example.com")); - } - - @Test - void testUpdateEmail() { + void testGetCurrentUserId() { User user = new User(); user.setId(1L); - user.setEmail("old@example.com"); - - when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - when(userRepository.save(any(User.class))).thenReturn(user); - - User updated = userService.updateEmail(1L, "new@example.com"); - assertEquals("new@example.com", updated.getEmail()); - } - - @Test - void testUpdateEmail_NotFound() { - when(userRepository.findById(2L)).thenReturn(Optional.empty()); - assertThrows(RuntimeException.class, () -> userService.updateEmail(2L, "new@example.com")); - } - - @Test - void testFindAllUsers() { - User user = new User(); user.setUsername("testuser"); - when(userRepository.findAll()).thenReturn(Collections.singletonList(user)); - - List users = userService.findAllUsers(); - assertEquals(1, users.size()); - assertEquals("testuser", users.get(0).getUsername()); - } - - @Test - void testDeleteUser() { - doNothing().when(userRepository).deleteById(1L); - userService.deleteUser(1L); - verify(userRepository).deleteById(1L); - } - - @Test - void testDeleteAllUsersAndStatistics() { - doNothing().when(statisticsRepository).deleteAll(); - doNothing().when(userRepository).deleteAll(); - userService.deleteAllUsersAndStatistics(); - verify(statisticsRepository).deleteAll(); - verify(userRepository).deleteAll(); - } - @Test - void testUserDetailsService() { - User user = new User(); - user.setUsername("testuser"); - user.setPassword("pass"); when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user)); - UserDetails details = userService.userDetailsService().loadUserByUsername("testuser"); - assertEquals("testuser", details.getUsername()); - } - - @Test - void testLoadUserByUsername_NotFound() { - when(userRepository.findByUsername("nouser")).thenReturn(Optional.empty()); - assertThrows(UsernameNotFoundException.class, () -> userService.loadUserByUsername("nouser")); - } - - @Test - void testLoadUserByEmail() { - User user = new User(); - user.setUsername("testuser"); - user.setPassword("pass"); - user.setEmail("test@example.com"); - when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user)); - UserDetails details = userService.loadUserByEmail("test@example.com"); - assertEquals("testuser", details.getUsername()); - } - - @Test - void testLoadUserByEmail_NotFound() { - when(userRepository.findByEmail("no@mail.com")).thenReturn(Optional.empty()); - assertThrows(UsernameNotFoundException.class, () -> userService.loadUserByEmail("no@mail.com")); - } - - @Test - void testChangeCredentials_Success() { - User user = new User(); - user.setUsername("olduser"); - user.setPassword("oldpass"); - when(userRepository.findByUsername("olduser")).thenReturn(Optional.of(user)); - when(passwordEncoder.matches("oldpass", "oldpass")).thenReturn(true); - when(userRepository.save(any(User.class))).thenReturn(user); - - boolean result = userService.changeCredentials("olduser", "oldpass", "newuser", "newpass"); - assertTrue(result); - assertEquals("newuser", user.getUsername()); - } - - @Test - void testChangeCredentials_Fail_WrongPassword() { - User user = new User(); - user.setUsername("olduser"); - user.setPassword("oldpass"); - when(userRepository.findByUsername("olduser")).thenReturn(Optional.of(user)); - when(passwordEncoder.matches("wrong", "oldpass")).thenReturn(false); - - boolean result = userService.changeCredentials("olduser", "wrong", "newuser", "newpass"); - assertFalse(result); - } - @Test - void testChangeCredentials_UserNotFound() { - when(userRepository.findByUsername("nouser")).thenReturn(Optional.empty()); - assertThrows(RuntimeException.class, () -> userService.changeCredentials("nouser", "pass", "new", "new")); - } - - @Test - void testFindByLoginOrEmail_ByUsername() { - User user = new User(); - user.setUsername("testuser"); - when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user)); - Optional found = userService.findByLoginOrEmail("testuser"); - assertTrue(found.isPresent()); - assertEquals("testuser", found.get().getUsername()); - } + String username = "testuser"; + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); - @Test - void testFindByLoginOrEmail_ByEmail() { - User user = new User(); - user.setEmail("test@example.com"); - when(userRepository.findByUsername("test@example.com")).thenReturn(Optional.empty()); - when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user)); - Optional found = userService.findByLoginOrEmail("test@example.com"); - assertTrue(found.isPresent()); - assertEquals("test@example.com", found.get().getEmail()); - } + User foundUser = userService.findUserById(1L); - @Test - void testFindByLoginOrEmail_NotFound() { - when(userRepository.findByUsername("nouser")).thenReturn(Optional.empty()); - when(userRepository.findByEmail("nouser")).thenReturn(Optional.empty()); - Optional found = userService.findByLoginOrEmail("nouser"); - assertFalse(found.isPresent()); + assertNotNull(foundUser); + assertEquals(1L, foundUser.getId()); + assertEquals("testuser", foundUser.getUsername()); } } \ No newline at end of file From ea5779ca4538748eade77582151de54acf353be9 Mon Sep 17 00:00:00 2001 From: Usatov Pavel Date: Fri, 23 Jan 2026 22:29:17 +0300 Subject: [PATCH 3/9] Reapply "Merge remote-tracking branch 'upstream/main'" This reverts commit 279d9d80f82adc597217fc2a11c118d57b1bb97b. --- .github/workflows/ci.yml | 13 +- .gitignore | 3 + LICENSE.txt | 674 ++++++++++++++++++ README.md | 179 ++++- build.gradle | 11 +- .../smartcalendar/config/SecurityConfig.java | 5 +- .../controller/AuthController.java | 68 +- .../controller/ChatGPTController.java | 9 +- .../controller/EventController.java | 35 + .../controller/FriendshipController.java | 55 ++ .../controller/StatisticsController.java | 31 +- .../controller/UserController.java | 350 ++++++++- .../dto/AddCollaborativeEventRequest.java | 10 + .../smartcalendar/dto/AverageDayTimeDto.java | 15 + .../dto/ContinuesSuccessDaysDto.java | 13 + .../com/smartcalendar/dto/DailyTaskDto.java | 24 + .../java/com/smartcalendar/dto/EventDto.java | 25 + .../dto/RegistrationRequest.java | 11 + .../com/smartcalendar/dto/StatisticsData.java | 19 + .../dto/SubscriptionRequest.java | 14 + .../com/smartcalendar/dto/TodayTimeDto.java | 13 + ...kTypes.java => TotalTimeTaskTypesDto.java} | 8 +- ...rageDayTimeVars.java => UserShortDto.java} | 5 +- .../exceptions/ConflictException.java | 11 + .../exceptions/ResourceNotFoundException.java | 12 + .../java/com/smartcalendar/model/Event.java | 58 +- .../com/smartcalendar/model/Friendship.java | 38 + .../com/smartcalendar/model/GroupChat.java | 37 + .../com/smartcalendar/model/GroupMessage.java | 37 + .../com/smartcalendar/model/PrivateChat.java | 36 + .../smartcalendar/model/PrivateMessage.java | 37 + .../com/smartcalendar/model/Statistics.java | 38 + .../java/com/smartcalendar/model/Tag.java | 34 + .../java/com/smartcalendar/model/Task.java | 15 +- .../java/com/smartcalendar/model/User.java | 72 +- .../repository/EventRepository.java | 6 + .../repository/FriendshipRepository.java | 14 + .../repository/StatisticsRepository.java | 12 + .../repository/TaskRepository.java | 3 +- .../smartcalendar/service/ChatGPTService.java | 93 ++- .../smartcalendar/service/EventService.java | 65 ++ .../service/FriendshipService.java | 60 ++ .../service/NotificationService.java | 35 + .../service/StatisticsService.java | 142 +++- .../smartcalendar/service/UserService.java | 335 ++++++++- src/main/resources/application-h2.properties | 71 ++ .../resources/application-test.properties | 11 +- src/main/resources/application.properties | 21 +- src/main/resources/schema.sql | 71 ++ .../config/TestSecurityConfig.java | 47 ++ .../AudioControllerIntegrationTest.java | 4 +- .../controller/AuthControllerTest.java | 48 +- .../ChatGPTControllerIntegrationTest.java | 5 +- .../StatisticsControllerIntegrationTest.java | 125 ++++ .../UserControllerIntegrationTest.java | 545 ++++++++++++++ .../service/ChatGPTServiceTest.java | 54 ++ .../service/StatisticsServiceTest.java | 177 +++++ .../service/UserServiceTest.java | 231 +++++- 58 files changed, 4036 insertions(+), 154 deletions(-) create mode 100644 LICENSE.txt create mode 100644 src/main/java/com/smartcalendar/controller/EventController.java create mode 100644 src/main/java/com/smartcalendar/controller/FriendshipController.java create mode 100644 src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java create mode 100644 src/main/java/com/smartcalendar/dto/AverageDayTimeDto.java create mode 100644 src/main/java/com/smartcalendar/dto/ContinuesSuccessDaysDto.java create mode 100644 src/main/java/com/smartcalendar/dto/DailyTaskDto.java create mode 100644 src/main/java/com/smartcalendar/dto/EventDto.java create mode 100644 src/main/java/com/smartcalendar/dto/RegistrationRequest.java create mode 100644 src/main/java/com/smartcalendar/dto/StatisticsData.java create mode 100644 src/main/java/com/smartcalendar/dto/SubscriptionRequest.java create mode 100644 src/main/java/com/smartcalendar/dto/TodayTimeDto.java rename src/main/java/com/smartcalendar/dto/{TotalTimeTaskTypes.java => TotalTimeTaskTypesDto.java} (70%) rename src/main/java/com/smartcalendar/dto/{AverageDayTimeVars.java => UserShortDto.java} (57%) create mode 100644 src/main/java/com/smartcalendar/exceptions/ConflictException.java create mode 100644 src/main/java/com/smartcalendar/exceptions/ResourceNotFoundException.java create mode 100644 src/main/java/com/smartcalendar/model/Friendship.java create mode 100644 src/main/java/com/smartcalendar/model/GroupChat.java create mode 100644 src/main/java/com/smartcalendar/model/GroupMessage.java create mode 100644 src/main/java/com/smartcalendar/model/PrivateChat.java create mode 100644 src/main/java/com/smartcalendar/model/PrivateMessage.java create mode 100644 src/main/java/com/smartcalendar/model/Statistics.java create mode 100644 src/main/java/com/smartcalendar/model/Tag.java create mode 100644 src/main/java/com/smartcalendar/repository/FriendshipRepository.java create mode 100644 src/main/java/com/smartcalendar/repository/StatisticsRepository.java create mode 100644 src/main/java/com/smartcalendar/service/EventService.java create mode 100644 src/main/java/com/smartcalendar/service/FriendshipService.java create mode 100644 src/main/java/com/smartcalendar/service/NotificationService.java create mode 100644 src/main/resources/application-h2.properties create mode 100644 src/main/resources/schema.sql create mode 100644 src/test/java/com/smartcalendar/config/TestSecurityConfig.java create mode 100644 src/test/java/com/smartcalendar/controller/StatisticsControllerIntegrationTest.java create mode 100644 src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java create mode 100644 src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java create mode 100644 src/test/java/com/smartcalendar/service/StatisticsServiceTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f5aa3b..d53356c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,10 +34,19 @@ jobs: run: chmod +x ./gradlew - name: Build and test - run: ./gradlew clean build + run: ./gradlew clean build --info --console=plain + + - name: Generate license report + run: ./gradlew generateLicenseReport - name: Upload test results uses: actions/upload-artifact@v4 with: name: test-results - path: build/test-results/test \ No newline at end of file + path: build/test-results/test + + - name: Upload license report + uses: actions/upload-artifact@v4 + with: + name: license-report + path: build/reports/dependency-license \ No newline at end of file diff --git a/.gitignore b/.gitignore index 63d2f1a..ca2bfc8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ out/ ### VS Code ### .vscode/ + +### Firebase Service Account ### +timetamer-smarcalendar-firebase-adminsdk-fbsvc-8be370036a.json diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 675478f..5dd5358 100644 --- a/README.md +++ b/README.md @@ -1 +1,178 @@ -# server \ No newline at end of file +# "TimeTamer" SmartCalendar Server + +Spring Boot backend for the "TimeTamer" SmartCalendar application. Provides REST API for task/event management, user statistics, and OpenAI integration. + +--- + +## Key Features +- **User Management**: Registration, authentication, and profile updates +- **Task & Event Operations**: Full CRUD functionality with status tracking +- **JWT Authentication**: Secure token-based access control +- **OpenAI Integration**: + - ChatGPT for natural language processing + - Whisper for speech-to-text +- **Automated Documentation**: Swagger UI for interactive API exploration +- **CI/CD Pipeline**: GitHub Actions for automated testing and deployment + +--- + +## Tech Stack +- **Language**: Java 21 +- **Framework**: Spring Boot 3.1+ +- **Database**: + - PostgreSQL (Production) + - H2 (Development/Testing) +- **Build Tool**: Gradle 8+ + +--- + +## Prerequisites +- Java 21 JDK +- Gradle 8+ +- PostgreSQL 15+ (for production) +- OpenAI API key + +--- + +## Quick Start +1. Clone repository: + ```bash + git clone https://github.com/hse-project-Java-2025/server.git + cd smartcalendar-server + ``` +2. Set environment variables (create `.env` file): + ```ini + JWT_SECRET=your_strong_secret_here + CHATGPT_API_KEY=your_openai_api_key + MAIL_PASSWORD=your_smtp_app_password + ``` +3. Build and run: + ```bash + ./gradlew bootRun + ``` +4. Access resources: + - **Swagger UI**: `http://localhost:8080/swagger-ui.html` (complete API documentation) + - H2 Console: `http://localhost:8080/h2-console` (JDBC URL: `jdbc:h2:mem:testdb`) + +--- + +## Configuration + +### Essential Environment Variables +| Variable | Description | Example | +|-------------------|-------------------------------------|-----------------------------| +| `JWT_SECRET` | Secret for JWT token signing | `A$ecretKey!123` | +| `CHATGPT_API_KEY` | OpenAI API key | `sk-...` | +| `MAIL_PASSWORD` | SMTP app password for email sending | `your_app_password` | +| `DB_URL` | Production DB URL (optional) | `jdbc:postgresql://db:5432` | + + +### SMTP Email Notification Setup + +To enable email notifications (for collaborative events, invites, etc.), configure the following SMTP settings in your `application.properties`: + +| Property | Example Value | Description | +|------------------------------------------------|------------------------------|---------------------------------------------------------------------------------------------| +| `spring.mail.host` | `smtp.gmail.com` | SMTP server host (Gmail example) | +| `spring.mail.port` | `587` | SMTP server port (587 for TLS) | +| `spring.mail.username` | `your_email@gmail.com` | Email account used to send notifications | +| `spring.mail.password` | `${MAIL_PASSWORD}` | App password for the email account (set as environment variable) | +| `spring.mail.properties.mail.smtp.auth` | `true` | Enable SMTP authentication | +| `spring.mail.properties.mail.smtp.starttls.enable` | `true` | Enable STARTTLS encryption | +| `spring.mail.from` | `noreply@ttsc.com` | Sender address shown in emails (must match or be an alias for Gmail accounts) | + +**Important notes:** +- For Gmail, you must use an [App Password](https://support.google.com/accounts/answer/185833?hl=en) and have two-factor authentication enabled. +- The value of `spring.mail.from` will only be used if your SMTP provider allows it. Gmail requires this to match your authenticated account or a verified alias. +- For other SMTP providers, adjust the host, port, and credentials accordingly. + +--- + +## API Reference +### Core Endpoints Overview +Below are representative examples of available API endpoints. Complete and always up-to-date definitive API reference is automatically generated at runtime: +```http +http://localhost:8080/swagger-ui.html +``` + + +### User Management +| Endpoint | Method | Description | +|-----------------------------------|--------|------------------------------| +| `/api/users` | GET | List all users | +| `/api/users` | POST | Register new user | +| `/api/users/{id}` | GET | Get user details | +| `/api/users/{id}/email` | PUT | Update user email | +| `/api/users/{userId}/statistics` | GET | Get user statistics | + +### Task Management +| Endpoint | Method | Description | +|---------------------------------------|--------|------------------------------| +| `/api/users/{userId}/tasks` | GET | Get user's tasks | +| `/api/users/{userId}/tasks` | POST | Create new task | +| `/api/users/tasks/{taskId}/status` | PATCH | Update task status | +| `/api/users/tasks/{taskId}` | DELETE | Delete task | + +### Event Management (including Collaborative Events) +| Endpoint | Method | Description | +|-----------------------------------------------|--------|--------------------------------------------------| +| `/api/users/{userId}/events` | GET | Get user's events (including shared/collaborative)| +| `/api/users/{userId}/events` | POST | Create new event | +| `/api/users/events/{eventId}` | PATCH | Update event | +| `/api/users/events/{eventId}` | DELETE | Delete event | +| `/api/users/events/{eventId}/invite` | POST | Invite user to event (collaboration) | +| `/api/users/events/{eventId}/accept-invite` | POST | Accept event invitation | +| `/api/users/events/{eventId}/remove-invite` | POST | Remove invitation for user | +| `/api/users/events/{eventId}/remove-participant` | POST | Remove participant from event | +| `/api/users/me/invites` | GET | Get events you are invited to | + +### OpenAI Integration +| Endpoint | Method | Description | +|-------------------------------|--------|--------------------------------------| +| `/api/chatgpt/ask` | POST | Get ChatGPT response | +| `/api/chatgpt/generate` | POST | Generate calendar events/tasks | +| `/api/chatgpt/generate/entities` | POST | Generate entities from natural language | + +--- + +## Collaborative Events + +The SmartCalendar supports full collaboration on events: +- **Invite users** to your events by username or email. +- **Accept or decline invitations** to shared events. +- **Remove participants** or invitations at any time. +- **Automatic email notifications** are sent for all key actions (invitation, joining, removal, updates, deletion) with detailed event info. +- **All collaborative features are available via REST API** (see Event Management section above). + +--- + +## Testing +Run tests with: +```bash +./gradlew test +``` +- Uses separate in-memory H2 database +- External services (OpenAI) are mocked +- Test coverage reports: `build/reports/tests` + +### Postman Collection + +You can also test all endpoints and collaborative event scenarios using our [Postman collection](https://warped-spaceship-772679.postman.co/workspace/Team-Workspace~558e4b04-2021-4e54-894c-0ad8890eda3d/collection/43149440-fdb46307-d6af-4895-bd4b-5b871c1f6962?action=share&creator=43149440&active-environment=43149440-f5aa59ad-f5b0-484f-923a-2d9403843293) + +## CI/CD Pipeline +GitHub Actions workflow (`.github/workflows/ci.yml`): +1. Build with JDK 21 +2. Run all tests +3. Generate dependency license report +4. Upload test reports as artifacts + +--- + +## License +MIT License - see [LICENSE](LICENSE.txt) file + +--- + +## Contributors +- [Dmitry Rusanov](https://github.com/DimaRus05) +- [Mikhail Minaev](https://github.com/minmise) diff --git a/build.gradle b/build.gradle index 246a245..c8ca27e 100644 --- a/build.gradle +++ b/build.gradle @@ -2,10 +2,12 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.3' id 'io.spring.dependency-management' version '1.1.7' + id 'com.github.jk1.dependency-license-report' version '2.8' + id 'com.adarshr.test-logger' version '4.0.0' } group = 'com.smartcalendar' -version = '0.0.1-SNAPSHOT' +version = '0.0.3-SNAPSHOT' java { toolchain { @@ -13,6 +15,10 @@ java { } } +testlogger { + theme 'mocha' +} + repositories { mavenCentral() } @@ -22,6 +28,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'com.google.auth:google-auth-library-oauth2-http:1.19.0' + implementation 'com.google.api-client:google-api-client:2.2.0' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/smartcalendar/config/SecurityConfig.java b/src/main/java/com/smartcalendar/config/SecurityConfig.java index e924fe2..f67a9f0 100644 --- a/src/main/java/com/smartcalendar/config/SecurityConfig.java +++ b/src/main/java/com/smartcalendar/config/SecurityConfig.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; @@ -25,6 +26,7 @@ @Configuration @EnableWebSecurity @RequiredArgsConstructor +@Profile("!test") public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthFilter; private final UserService userService; @@ -60,6 +62,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/static/**", "/api/auth/login", "/api/auth/signup", + "/api/events", "/h2-console/**" ).permitAll() .requestMatchers( @@ -84,7 +87,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of("*")); //TODO: конкретные домены + configuration.setAllowedOrigins(List.of("*")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setExposedHeaders(List.of("Authorization")); diff --git a/src/main/java/com/smartcalendar/controller/AuthController.java b/src/main/java/com/smartcalendar/controller/AuthController.java index 50306ba..a1717fc 100644 --- a/src/main/java/com/smartcalendar/controller/AuthController.java +++ b/src/main/java/com/smartcalendar/controller/AuthController.java @@ -1,9 +1,13 @@ package com.smartcalendar.controller; +import com.smartcalendar.dto.RegistrationRequest; import com.smartcalendar.model.User; import com.smartcalendar.service.JwtService; +import com.smartcalendar.service.StatisticsService; import com.smartcalendar.service.UserService; import com.smartcalendar.dto.ChangeCredentialsRequest; +import com.smartcalendar.dto.StatisticsData; +import com.smartcalendar.dto.AverageDayTimeDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -21,6 +25,9 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.util.Optional; + @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -30,6 +37,7 @@ public class AuthController { private final AuthenticationManager authenticationManager; private final JwtService jwtService; private final UserService userService; + private final StatisticsService statisticsService; @Operation( summary = "User authentication", @@ -44,16 +52,16 @@ public class AuthController { @PostMapping("/login") public ResponseEntity authenticateUser(@RequestBody User user) { logger.info("Attempting to authenticate user: {}", user.getUsername()); - + if ((user.getUsername() == null && user.getEmail() == null) || user.getPassword() == null) { logger.warn("Username/email or password is null. Username: {}, Email: {}", user.getUsername(), user.getEmail()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Username/email and password are required"); } - + try { logger.debug("Determining if login is by username or email: {}", user.getUsername() != null ? user.getUsername() : user.getEmail()); UserDetails userDetails; - + try { if (user.getUsername() != null) { userDetails = userService.loadUserByUsername(user.getUsername()); @@ -64,17 +72,26 @@ public ResponseEntity authenticateUser(@RequestBody User user) { logger.error("User not found: {}", user.getUsername() != null ? user.getUsername() : user.getEmail()); return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid username or email"); } - + Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(userDetails.getUsername(), user.getPassword()) + new UsernamePasswordAuthenticationToken(userDetails.getUsername(), user.getPassword()) ); - + logger.debug("Authentication successful for user: {}", userDetails.getUsername()); SecurityContextHolder.getContext().setAuthentication(authentication); - + + if (user.getDeviceToken() != null && !user.getDeviceToken().isBlank()) { + Optional dbUserOpt = userService.findByUsername(userDetails.getUsername()); + if (dbUserOpt.isPresent()) { + User dbUser = dbUserOpt.get(); + dbUser.setDeviceToken(user.getDeviceToken()); + userService.createUser(dbUser); + } + } + String jwt = jwtService.generateToken(userDetails.getUsername()); logger.info("JWT token generated for user: {}", userDetails.getUsername()); - + return ResponseEntity.ok(jwt); } catch (BadCredentialsException e) { logger.error("Invalid credentials for user: {}", user.getUsername() != null ? user.getUsername() : user.getEmail()); @@ -83,7 +100,7 @@ public ResponseEntity authenticateUser(@RequestBody User user) { logger.error("Unexpected error during authentication for user: {}", user.getUsername() != null ? user.getUsername() : user.getEmail(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An unexpected error occurred"); } -} + } @Operation( summary = "New user signup", @@ -95,29 +112,40 @@ public ResponseEntity authenticateUser(@RequestBody User user) { @ApiResponse(responseCode = "500", description = "Internal server error") }) @PostMapping("/signup") - public ResponseEntity registerUser(@RequestBody User user) { - logger.info("Attempting to register user: {}", user.getUsername()); + public ResponseEntity registerUser(@RequestBody RegistrationRequest request) { + logger.info("Attempting to register user: {}", request.getUsername()); - if (user.getUsername() == null || user.getPassword() == null || user.getEmail() == null) { - logger.warn("Missing required fields for registration. Username: {}, Email: {}", user.getUsername(), user.getEmail()); + if (request.getUsername() == null || request.getPassword() == null || request.getEmail() == null || request.getFirstDay() == null) { + logger.warn("Missing required fields for registration. Username: {}, Email: {}, FirstDay: {}", request.getUsername(), request.getEmail(), request.getFirstDay()); return ResponseEntity.badRequest().body(null); } - if (userService.existsByUsername(user.getUsername())) { - logger.warn("Username already exists: {}", user.getUsername()); + if (userService.existsByUsername(request.getUsername())) { + logger.warn("Username already exists: {}", request.getUsername()); return ResponseEntity.badRequest().body(null); } - if (userService.existsByEmail(user.getEmail())) { - logger.warn("Email already exists: {}", user.getEmail()); + if (userService.existsByEmail(request.getEmail())) { + logger.warn("Email already exists: {}", request.getEmail()); return ResponseEntity.badRequest().body(null); } try { + User user = new User(); + user.setUsername(request.getUsername()); + user.setEmail(request.getEmail()); + user.setPassword(request.getPassword()); User createdUser = userService.createUser(user); - logger.info("User registered successfully: {}", user.getUsername()); + + StatisticsData statisticsData = new StatisticsData(); + statisticsData.setAverageDayTime( + new AverageDayTimeDto(0, LocalDate.parse(request.getFirstDay())) + ); + statisticsService.updateStatistics(createdUser.getId(), statisticsData); + + logger.info("User registered successfully: {}", request.getUsername()); return ResponseEntity.ok(createdUser); } catch (Exception e) { - logger.error("Error during user registration: {}", user.getUsername(), e); + logger.error("Error during user registration: {}", request.getUsername(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null); } } @@ -130,7 +158,7 @@ public ResponseEntity changeCredentials(@RequestBody ChangeCredentialsRequest request.getNewUsername(), request.getNewPassword() ); - + if (success) { return ResponseEntity.ok("Credentials updated successfully"); } diff --git a/src/main/java/com/smartcalendar/controller/ChatGPTController.java b/src/main/java/com/smartcalendar/controller/ChatGPTController.java index e1cd83e..3dfad32 100644 --- a/src/main/java/com/smartcalendar/controller/ChatGPTController.java +++ b/src/main/java/com/smartcalendar/controller/ChatGPTController.java @@ -41,13 +41,12 @@ public ResponseEntity generateEntities(@RequestBody Map reque return ResponseEntity.badRequest().body(response); } - if (!(response.get("events") instanceof List) || !(response.get("tasks") instanceof List)) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid response format from ChatGPT")); - } + List events = response.get("events") instanceof List ? (List) response.get("events") : List.of(); + List tasks = response.get("tasks") instanceof List ? (List) response.get("tasks") : List.of(); Map> validResponse = Map.of( - "events", (List) response.get("events"), - "tasks", (List) response.get("tasks") + "events", events, + "tasks", tasks ); List entities = chatGPTService.convertToEntities(validResponse); diff --git a/src/main/java/com/smartcalendar/controller/EventController.java b/src/main/java/com/smartcalendar/controller/EventController.java new file mode 100644 index 0000000..c42a12c --- /dev/null +++ b/src/main/java/com/smartcalendar/controller/EventController.java @@ -0,0 +1,35 @@ +package com.smartcalendar.controller; + +import com.smartcalendar.model.Event; +import com.smartcalendar.repository.EventRepository; +import com.smartcalendar.service.EventService; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/events") +public class EventController { + + private final EventService eventService; + + @Autowired + public EventController(EventService eventService) { + this.eventService = eventService; + } + + @GetMapping + public ResponseEntity> getEvents(@RequestParam(name = "location") String location, + @RequestParam(name = "userId") Long userId) { + if (location == null || location.isBlank()) { + return ResponseEntity.badRequest().build(); + } + List events = eventService.getPersonalizedEvents(location.trim(), userId); + return ResponseEntity.ok(events); + } +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/controller/FriendshipController.java b/src/main/java/com/smartcalendar/controller/FriendshipController.java new file mode 100644 index 0000000..0fd00ef --- /dev/null +++ b/src/main/java/com/smartcalendar/controller/FriendshipController.java @@ -0,0 +1,55 @@ +package com.smartcalendar.controller; + +import com.smartcalendar.dto.SubscriptionRequest; +import com.smartcalendar.model.Friendship; +import com.smartcalendar.service.FriendshipService; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/friendships") +public class FriendshipController { + + private final FriendshipService friendshipService; + + @Autowired + public FriendshipController(FriendshipService friendshipService) { + this.friendshipService = friendshipService; + } + + @GetMapping("/my-subscriptions") + public ResponseEntity> getMySubscriptions( + @AuthenticationPrincipal UserDetails userDetails + ) { + Long currentUserId = Long.parseLong(userDetails.getUsername()); + List subscriptions = friendshipService.getSubscriptions(currentUserId); + return ResponseEntity.ok(subscriptions); + } + + @PostMapping("/subscribe") + public ResponseEntity subscribe( + @AuthenticationPrincipal UserDetails userDetails, + @RequestBody @Valid SubscriptionRequest request + ) { + Long currentUserId = Long.parseLong(userDetails.getUsername()); + Friendship subscription = friendshipService.createSubscription(currentUserId, request.getUser2Id()); + return ResponseEntity.status(HttpStatus.CREATED).body(subscription); + } + + @DeleteMapping("/unsubscribe/{followingId}") + public ResponseEntity unsubscribe( + @AuthenticationPrincipal UserDetails userDetails, + @PathVariable Long user2Id + ) { + Long currentUserId = Long.parseLong(userDetails.getUsername()); + friendshipService.deleteSubscription(currentUserId, user2Id); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/smartcalendar/controller/StatisticsController.java b/src/main/java/com/smartcalendar/controller/StatisticsController.java index b293c17..2d10506 100644 --- a/src/main/java/com/smartcalendar/controller/StatisticsController.java +++ b/src/main/java/com/smartcalendar/controller/StatisticsController.java @@ -1,9 +1,13 @@ package com.smartcalendar.controller; import com.smartcalendar.dto.*; +import com.smartcalendar.model.User; import com.smartcalendar.service.StatisticsService; +import com.smartcalendar.service.UserService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -14,24 +18,35 @@ public class StatisticsController { private final StatisticsService statisticsService; + private final UserService userService; + + private Long getCurrentUserId(UserDetails userDetails) { + User user = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + return user.getId(); + } @GetMapping("/total-time-task-types") - public ResponseEntity getTotalTimeTaskTypes() { - return ResponseEntity.ok(statisticsService.getTotalTimeTaskTypes()); + public ResponseEntity getTotalTimeTaskTypes(@AuthenticationPrincipal UserDetails userDetails) { + Long userId = getCurrentUserId(userDetails); + return ResponseEntity.ok(statisticsService.getTotalTimeTaskTypes(userId)); } @GetMapping("/today") - public ResponseEntity getTodayTimeVars() { - return ResponseEntity.ok(statisticsService.getTodayTimeVars()); + public ResponseEntity getTodayTimeDto(@AuthenticationPrincipal UserDetails userDetails) { + Long userId = getCurrentUserId(userDetails); + return ResponseEntity.ok(statisticsService.getTodayTimeDto(userId)); } @GetMapping("/continuous-success-days") - public ResponseEntity getContinuousSuccessDaysVars() { - return ResponseEntity.ok(statisticsService.getContinuousSuccessDaysVars()); + public ResponseEntity getContinuesSuccessDaysDto(@AuthenticationPrincipal UserDetails userDetails) { + Long userId = getCurrentUserId(userDetails); + return ResponseEntity.ok(statisticsService.getContinuesSuccessDaysDto(userId)); } @GetMapping("/average-day-time") - public ResponseEntity getAverageDayTimeVars() { - return ResponseEntity.ok(statisticsService.getAverageDayTimeVars()); + public ResponseEntity getAverageDayTimeDto(@AuthenticationPrincipal UserDetails userDetails) { + Long userId = getCurrentUserId(userDetails); + return ResponseEntity.ok(statisticsService.getAverageDayTimeDto(userId)); } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/controller/UserController.java b/src/main/java/com/smartcalendar/controller/UserController.java index c796f82..fafa0ec 100644 --- a/src/main/java/com/smartcalendar/controller/UserController.java +++ b/src/main/java/com/smartcalendar/controller/UserController.java @@ -1,5 +1,9 @@ package com.smartcalendar.controller; +import com.smartcalendar.dto.AddCollaborativeEventRequest; +import com.smartcalendar.dto.DailyTaskDto; +import com.smartcalendar.dto.EventDto; +import com.smartcalendar.dto.StatisticsData; import com.smartcalendar.model.Event; import com.smartcalendar.model.Task; import com.smartcalendar.model.User; @@ -10,8 +14,7 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.*; -import java.util.List; -import java.util.Map; +import java.util.*; @RestController @RequestMapping("/api/users") @@ -32,20 +35,45 @@ public ResponseEntity getUserById(@PathVariable Long id) { } @PutMapping("/{id}/email") - public ResponseEntity updateEmail(@PathVariable Long id, @RequestBody String newEmail) { + public ResponseEntity updateEmail( + @PathVariable Long id, + @RequestBody String newEmail, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!currentUser.getId().equals(id)) { + return ResponseEntity.status(403).build(); + } User updatedUser = userService.updateEmail(id, newEmail); return ResponseEntity.ok(updatedUser); } @PatchMapping("/tasks/{taskId}/status") - public ResponseEntity updateTaskStatus(@PathVariable Long taskId, @RequestBody Map requestBody) { + public ResponseEntity updateTaskStatus( + @PathVariable UUID taskId, + @RequestBody Map requestBody, + @AuthenticationPrincipal UserDetails userDetails) { + Task task = userService.getTaskById(taskId); + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!task.getUser().getId().equals(currentUser.getId())) { + return ResponseEntity.status(403).build(); + } boolean completed = requestBody.get("completed"); - Task updatedTask = userService.updateTaskStatus(taskId, completed); - return ResponseEntity.ok(updatedTask); + userService.updateTaskStatus(taskId, completed); + return ResponseEntity.ok().build(); } @GetMapping("/tasks/{taskId}/description") - public ResponseEntity getTaskDescription(@PathVariable Long taskId) { + public ResponseEntity getTaskDescription( + @PathVariable UUID taskId, + @AuthenticationPrincipal UserDetails userDetails) { + Task task = userService.getTaskById(taskId); + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!task.getUser().getId().equals(currentUser.getId())) { + return ResponseEntity.status(403).build(); + } String description = userService.getTaskDescription(taskId); return ResponseEntity.ok(description); } @@ -63,39 +91,121 @@ public ResponseEntity createUser(@RequestBody User user) { } @GetMapping("/{userId}/tasks") - public ResponseEntity> getTasksByUserId(@PathVariable Long userId) { + public ResponseEntity> getTasksByUserId( + @PathVariable Long userId, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!currentUser.getId().equals(userId)) { + return ResponseEntity.status(403).build(); + } List tasks = userService.findTasksByUserId(userId); return ResponseEntity.ok(tasks); } @GetMapping("/{userId}/events") - public ResponseEntity> getEventsByUserId(@PathVariable Long userId) { + public ResponseEntity> getEventsByUserId( + @PathVariable Long userId, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!currentUser.getId().equals(userId)) { + return ResponseEntity.status(403).build(); + } List events = userService.findEventsByUserId(userId); - return ResponseEntity.ok(events); + List eventDtos = events.stream() + .map(userService::toEventDto) + .toList(); + return ResponseEntity.ok(eventDtos); } @PostMapping("/{userId}/events") - public ResponseEntity createEvent(@PathVariable Long userId, @RequestBody Event event) { - User user = userService.findUserById(userId); - event.setOrganizer(user); - Event createdEvent = userService.createEvent(event); - return ResponseEntity.ok(createdEvent); + public ResponseEntity> createEvent( + @PathVariable Long userId, + @RequestBody Event event, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!currentUser.getId().equals(userId)) { + return ResponseEntity.status(403).build(); + } + event.setOrganizer(currentUser); + event.setShared(false); + event.setInvitees(new ArrayList<>()); + event.setParticipants(List.of(currentUser)); + + try { + Event createdEvent = userService.createEventWithCustomId(event); + return ResponseEntity.ok(Map.of("id", createdEvent.getId())); + } catch (IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage())); + } + } + + @PatchMapping("/events/{eventId}") + public ResponseEntity updateEvent( + @PathVariable UUID eventId, + @RequestBody Event event, + @AuthenticationPrincipal UserDetails userDetails) { + Event existingEvent = userService.getEventById(eventId); + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!existingEvent.getOrganizer().getId().equals(currentUser.getId())) { + return ResponseEntity.status(403).build(); + } + + userService.editEvent(eventId, event); + + userService.notifyEventUpdated(existingEvent, event); + + return ResponseEntity.ok().build(); } + @PostMapping("/{userId}/tasks") - public ResponseEntity createTask(@PathVariable Long userId, @RequestBody Task task) { - User user = userService.findUserById(userId); - task.setUser(user); - Task createdTask = userService.createTask(task); - return ResponseEntity.ok(createdTask); + public ResponseEntity> createTask( + @PathVariable Long userId, + @RequestBody Task task, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!currentUser.getId().equals(userId)) { + return ResponseEntity.status(403).build(); + } + task.setUser(currentUser); + Task createdTask = userService.createTaskWithCustomId(task); + return ResponseEntity.ok(Map.of("id", createdTask.getId())); } @DeleteMapping("/tasks/{taskId}") - public ResponseEntity deleteTask(@PathVariable Long taskId) { + public ResponseEntity deleteTask( + @PathVariable UUID taskId, + @AuthenticationPrincipal UserDetails userDetails) { + Task task = userService.getTaskById(taskId); + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!task.getUser().getId().equals(currentUser.getId())) { + return ResponseEntity.status(403).build(); + } userService.deleteTask(taskId); return ResponseEntity.noContent().build(); } + @PatchMapping("/tasks/{taskId}") + public ResponseEntity editTask( + @PathVariable UUID taskId, + @RequestBody Task task, + @AuthenticationPrincipal UserDetails userDetails) { + Task existingTask = userService.getTaskById(taskId); + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!existingTask.getUser().getId().equals(currentUser.getId())) { + return ResponseEntity.status(403).build(); + } + userService.editTask(taskId, task); + return ResponseEntity.ok().build(); + } + @GetMapping("/me") public ResponseEntity> getCurrentUserInfo(@AuthenticationPrincipal UserDetails userDetails) { User user = userService.findByUsername(userDetails.getUsername()) @@ -108,4 +218,202 @@ public ResponseEntity> getCurrentUserInfo(@AuthenticationPri return ResponseEntity.ok(result); } + @GetMapping("/{userId}/events/dailytasks") + public ResponseEntity> getAllEventsAsDailyTasks( + @PathVariable Long userId, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!currentUser.getId().equals(userId)) { + return ResponseEntity.status(403).build(); + } + List dailyTasks = userService.findAllEventsAsDailyTaskDto(userId); + return ResponseEntity.ok(dailyTasks); + } + + @PatchMapping("/events/{eventId}/status") + public ResponseEntity updateEventStatus( + @PathVariable UUID eventId, + @RequestBody Map requestBody, + @AuthenticationPrincipal UserDetails userDetails) { + Event event = userService.getEventById(eventId); + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!event.getOrganizer().getId().equals(currentUser.getId())) { + return ResponseEntity.status(403).build(); + } + boolean completed = requestBody.get("completed"); + Event updatedEvent = userService.updateEventStatus(eventId, completed); + + userService.notifyEventUpdated(event, updatedEvent); + + EventDto updatedEventDto = userService.toEventDto(updatedEvent); + return ResponseEntity.ok(updatedEventDto); + } + + @DeleteMapping("/events/{eventId}") + public ResponseEntity> deleteEventById( + @PathVariable UUID eventId, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + Event event = userService.getEventById(eventId); + + if (!event.getOrganizer().getId().equals(currentUser.getId())) { + return ResponseEntity.status(403).body(Map.of()); + } + + userService.notifyEventDeleted(event); + + UUID deletedId = userService.deleteEventById(eventId); + return ResponseEntity.ok(Map.of("id", deletedId)); + } + + @GetMapping("/{userId}/statistics") + public ResponseEntity getStatistics( + @PathVariable Long userId, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!currentUser.getId().equals(userId)) { + return ResponseEntity.status(403).build(); + } + StatisticsData statistics = userService.getStatistics(userId); + return ResponseEntity.ok(statistics); + } + + @PutMapping("/{userId}/statistics") + public ResponseEntity updateStatistics( + @PathVariable Long userId, + @RequestBody StatisticsData statisticsData, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + if (!currentUser.getId().equals(userId)) { + return ResponseEntity.status(403).build(); + } + userService.updateStatistics(userId, statisticsData); + return ResponseEntity.ok().build(); + } + + @PostMapping("/events/{eventId}/invite") + public ResponseEntity inviteUserToEvent( + @PathVariable UUID eventId, + @RequestBody Map requestBody, + @AuthenticationPrincipal UserDetails userDetails) { + String loginOrEmail = requestBody.get("loginOrEmail"); + Event event = userService.getEventById(eventId); + + Optional userOpt = userService.findByLoginOrEmail(loginOrEmail); + if (userOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "User not found")); + } + User user = userOpt.get(); + + if (event.getParticipants() != null && event.getParticipants().contains(user)) { + return ResponseEntity.badRequest().body(Map.of("error", "User is already a participant")); + } + if (event.getInvitees() != null && event.getInvitees().contains(user.getEmail())) { + return ResponseEntity.badRequest().body(Map.of("error", "User is already invited")); + } + if (event.getInvitees() == null) { + event.setInvitees(new ArrayList<>()); + } + event.getInvitees().add(user.getEmail()); + event.setShared(true); + + userService.saveEvent(event); + userService.notifyInvitees(event); + return ResponseEntity.ok(Map.of("invited", user.getUsername())); + } + + @PostMapping("/events/{eventId}/remove-invite") + public ResponseEntity removeInviteFromEvent( + @PathVariable UUID eventId, + @RequestBody Map requestBody, + @AuthenticationPrincipal UserDetails userDetails) { + String loginOrEmail = requestBody.get("loginOrEmail"); + Event event = userService.getEventById(eventId); + + Optional userOpt = userService.findByLoginOrEmail(loginOrEmail); + if (userOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "User not found")); + } + User user = userOpt.get(); + + if (event.getInvitees() != null) { + event.getInvitees().remove(user.getEmail()); + userService.saveEvent(event); + } + + return ResponseEntity.ok(Map.of("removedInvite", user.getUsername())); + } + + @GetMapping("/me/invites") + public ResponseEntity> getMyInvites(@AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + List invites = userService.findEventsByInvitee(currentUser.getEmail()); + List inviteDtos = invites.stream() + .map(userService::toEventDto) + .toList(); + return ResponseEntity.ok(inviteDtos); + } + + + @PostMapping("/events/{eventId}/accept-invite") + public ResponseEntity acceptInvite( + @PathVariable UUID eventId, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + Event event = userService.getEventById(eventId); + + if (event.getInvitees() == null || !event.getInvitees().contains(currentUser.getEmail())) { + return ResponseEntity.badRequest().body(Map.of("error", "No invite found for this user")); + } + + event.getInvitees().remove(currentUser.getEmail()); + if (!event.getParticipants().contains(currentUser)) { + event.getParticipants().add(currentUser); + } + userService.saveEvent(event); + userService.notifyUserAddedToEvent(currentUser, event, currentUser.getDeviceToken()); + + return ResponseEntity.ok(Map.of("accepted", true)); + } + + @PostMapping("/events/{eventId}/remove-participant") + public ResponseEntity removeParticipantFromEvent( + @PathVariable UUID eventId, + @RequestBody Map requestBody, + @AuthenticationPrincipal UserDetails userDetails) { + User currentUser = userService.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new RuntimeException("User not found")); + Event event = userService.getEventById(eventId); + + if (!event.getOrganizer().getId().equals(currentUser.getId())) { + return ResponseEntity.status(403).body(Map.of("error", "Only organizer can remove participants")); + } + + String loginOrEmail = requestBody.get("loginOrEmail"); + Optional userOpt = userService.findByLoginOrEmail(loginOrEmail); + if (userOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "User not found")); + } + User user = userOpt.get(); + + if (user.getId().equals(currentUser.getId())) { + return ResponseEntity.badRequest().body(Map.of("error", "Organizer cannot be removed")); + } + + boolean removed = event.getParticipants() != null && event.getParticipants().remove(user); + if (removed) { + userService.saveEvent(event); + userService.notifyUserRemovedFromEvent(user, event, user.getDeviceToken()); + return ResponseEntity.ok(Map.of("removedParticipant", user.getUsername())); + } else { + return ResponseEntity.badRequest().body(Map.of("error", "User is not a participant")); + } + } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java b/src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java new file mode 100644 index 0000000..d093bbc --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/AddCollaborativeEventRequest.java @@ -0,0 +1,10 @@ +package com.smartcalendar.dto; +import com.smartcalendar.model.Event; +import lombok.Data; + +@Data +public class AddCollaborativeEventRequest { + private String loginOrEmail; + private String deviceToken; + private Event event; +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/AverageDayTimeDto.java b/src/main/java/com/smartcalendar/dto/AverageDayTimeDto.java new file mode 100644 index 0000000..aa0f861 --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/AverageDayTimeDto.java @@ -0,0 +1,15 @@ +package com.smartcalendar.dto; + +import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AverageDayTimeDto { + private long totalWorkMinutes; + private LocalDate firstDay; +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/ContinuesSuccessDaysDto.java b/src/main/java/com/smartcalendar/dto/ContinuesSuccessDaysDto.java new file mode 100644 index 0000000..9e0188f --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/ContinuesSuccessDaysDto.java @@ -0,0 +1,13 @@ +package com.smartcalendar.dto; + +import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ContinuesSuccessDaysDto { + private int record; + private int now; +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/DailyTaskDto.java b/src/main/java/com/smartcalendar/dto/DailyTaskDto.java new file mode 100644 index 0000000..183f43b --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/DailyTaskDto.java @@ -0,0 +1,24 @@ +package com.smartcalendar.dto; + +import com.smartcalendar.model.EventType; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.UUID; + +@Data +@AllArgsConstructor +public class DailyTaskDto { + private UUID id; + private String title; + private boolean isComplete; + private EventType type; + private LocalDateTime creationTime; + private String description; + private LocalTime start; + private LocalTime end; + private LocalDate date; +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/EventDto.java b/src/main/java/com/smartcalendar/dto/EventDto.java new file mode 100644 index 0000000..ad89fda --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/EventDto.java @@ -0,0 +1,25 @@ +package com.smartcalendar.dto; + +import com.smartcalendar.model.EventType; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Data +public class EventDto { + private UUID id; + private String title; + private String description; + private LocalDateTime start; + private LocalDateTime end; + private String location; + private EventType type; + private LocalDateTime creationTime; + private UserShortDto organizer; + private boolean completed; + private boolean isShared; + private List invitees; + private List participants; +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/RegistrationRequest.java b/src/main/java/com/smartcalendar/dto/RegistrationRequest.java new file mode 100644 index 0000000..381ce76 --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/RegistrationRequest.java @@ -0,0 +1,11 @@ +package com.smartcalendar.dto; + +import lombok.Data; + +@Data +public class RegistrationRequest { + private String username; + private String email; + private String password; + private String firstDay; +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/StatisticsData.java b/src/main/java/com/smartcalendar/dto/StatisticsData.java new file mode 100644 index 0000000..97f0b86 --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/StatisticsData.java @@ -0,0 +1,19 @@ +package com.smartcalendar.dto; + +import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.util.Date; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class StatisticsData { + private TotalTimeTaskTypesDto totalTime = new TotalTimeTaskTypesDto(0, 0, 0, 0); + private long weekTime = 0; + private TodayTimeDto todayTime = new TodayTimeDto(0, 0); + private ContinuesSuccessDaysDto continuesSuccessDays = new ContinuesSuccessDaysDto(0, 0); + private AverageDayTimeDto averageDayTime = new AverageDayTimeDto(0, null); + private Date jsonDate = new Date(); +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/SubscriptionRequest.java b/src/main/java/com/smartcalendar/dto/SubscriptionRequest.java new file mode 100644 index 0000000..8d35615 --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/SubscriptionRequest.java @@ -0,0 +1,14 @@ +package com.smartcalendar.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class SubscriptionRequest { + private Long user2Id; + + public Long getUser2Id() { + return user2Id; + } +} + diff --git a/src/main/java/com/smartcalendar/dto/TodayTimeDto.java b/src/main/java/com/smartcalendar/dto/TodayTimeDto.java new file mode 100644 index 0000000..88d3f15 --- /dev/null +++ b/src/main/java/com/smartcalendar/dto/TodayTimeDto.java @@ -0,0 +1,13 @@ +package com.smartcalendar.dto; + +import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TodayTimeDto { + private long planned; + private long completed; +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/TotalTimeTaskTypes.java b/src/main/java/com/smartcalendar/dto/TotalTimeTaskTypesDto.java similarity index 70% rename from src/main/java/com/smartcalendar/dto/TotalTimeTaskTypes.java rename to src/main/java/com/smartcalendar/dto/TotalTimeTaskTypesDto.java index 46fd111..9493d19 100644 --- a/src/main/java/com/smartcalendar/dto/TotalTimeTaskTypes.java +++ b/src/main/java/com/smartcalendar/dto/TotalTimeTaskTypesDto.java @@ -1,13 +1,15 @@ package com.smartcalendar.dto; -import lombok.AllArgsConstructor; import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; @Data +@NoArgsConstructor @AllArgsConstructor -public class TotalTimeTaskTypes { +public class TotalTimeTaskTypesDto { private long common; private long work; private long study; private long fitness; -} +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/dto/AverageDayTimeVars.java b/src/main/java/com/smartcalendar/dto/UserShortDto.java similarity index 57% rename from src/main/java/com/smartcalendar/dto/AverageDayTimeVars.java rename to src/main/java/com/smartcalendar/dto/UserShortDto.java index bc5df5f..f3d4825 100644 --- a/src/main/java/com/smartcalendar/dto/AverageDayTimeVars.java +++ b/src/main/java/com/smartcalendar/dto/UserShortDto.java @@ -5,6 +5,7 @@ @Data @AllArgsConstructor -public class AverageDayTimeVars { - private long averageMinutesPerDay; +public class UserShortDto { + private String username; + private String email; } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/exceptions/ConflictException.java b/src/main/java/com/smartcalendar/exceptions/ConflictException.java new file mode 100644 index 0000000..3925333 --- /dev/null +++ b/src/main/java/com/smartcalendar/exceptions/ConflictException.java @@ -0,0 +1,11 @@ +package com.smartcalendar.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.CONFLICT) +public class ConflictException extends RuntimeException { + public ConflictException(String message) { + super(message); + } +} diff --git a/src/main/java/com/smartcalendar/exceptions/ResourceNotFoundException.java b/src/main/java/com/smartcalendar/exceptions/ResourceNotFoundException.java new file mode 100644 index 0000000..8f2549b --- /dev/null +++ b/src/main/java/com/smartcalendar/exceptions/ResourceNotFoundException.java @@ -0,0 +1,12 @@ +package com.smartcalendar.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} + diff --git a/src/main/java/com/smartcalendar/model/Event.java b/src/main/java/com/smartcalendar/model/Event.java index cd68fc9..9cf3d4e 100644 --- a/src/main/java/com/smartcalendar/model/Event.java +++ b/src/main/java/com/smartcalendar/model/Event.java @@ -1,11 +1,19 @@ package com.smartcalendar.model; +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.time.Instant; import java.time.LocalDateTime; +import java.util.List; +import java.util.ArrayList; +import java.util.Map; import java.util.UUID; @Entity @@ -15,11 +23,13 @@ @AllArgsConstructor public class Event { @Id - @GeneratedValue(strategy = GenerationType.AUTO) + //@GeneratedValue(strategy = GenerationType.UUID) private UUID id; + @Column private String title; + @Column private String description; @Column(name = "start_time") @@ -28,14 +38,58 @@ public class Event { @Column(name = "end_time") private LocalDateTime end; + @Column(name = "event_location") private String location; @Enumerated(EnumType.STRING) - private EventType type; // Аналог DailyTaskType + private EventType type; private LocalDateTime creationTime = LocalDateTime.now(); @ManyToOne @JoinColumn(name = "organizer_id") + @JsonBackReference(value = "organized_events") private User organizer; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "events_tags", + joinColumns = @JoinColumn(name = "event_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id") + ) + //@JsonManagedReference(value = "event_tags") + @JsonProperty("tags") + private List tags; + + @Column + private boolean completed = false; + + @Column + private boolean isShared = false; + + @ElementCollection + @CollectionTable(name = "event_invitees", joinColumns = @JoinColumn(name = "event_id")) + @Column(name = "invitee") + private List invitees = new ArrayList<>(); + + @ManyToMany + @JoinTable( + name = "event_participants", + joinColumns = @JoinColumn(name = "event_id"), + inverseJoinColumns = @JoinColumn(name = "user_id") + ) + @JsonIgnore + private List participants = new ArrayList<>(); + + public LocalDateTime getEnd() { + return end; + } + + public LocalDateTime getStart() { + return start; + } + + public List getTags() { + return tags; + } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/model/Friendship.java b/src/main/java/com/smartcalendar/model/Friendship.java new file mode 100644 index 0000000..c9dd657 --- /dev/null +++ b/src/main/java/com/smartcalendar/model/Friendship.java @@ -0,0 +1,38 @@ +package com.smartcalendar.model; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@Data +@Table(name = "friendships") +@NoArgsConstructor +@AllArgsConstructor +public class Friendship { + @Id + //@GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id1") + @JsonBackReference(value = "friends1") + private User user1; + + @ManyToOne + @JoinColumn(name = "user_id2") + @JsonBackReference(value = "friends2") + private User user2; + + public void setUser1(User user1) { + this.user1 = user1; + } + + public void setUser2(User user2) { + this.user2 = user2; + } +} diff --git a/src/main/java/com/smartcalendar/model/GroupChat.java b/src/main/java/com/smartcalendar/model/GroupChat.java new file mode 100644 index 0000000..0b25f86 --- /dev/null +++ b/src/main/java/com/smartcalendar/model/GroupChat.java @@ -0,0 +1,37 @@ +package com.smartcalendar.model; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.UUID; + +@Entity +@Data +@Table(name = "group_chats") +@NoArgsConstructor +@AllArgsConstructor +public class GroupChat { + @Id + //@GeneratedValue(strategy = GenerationType.IDENTITY) + private UUID id; + + @ManyToOne + @JoinColumn(name = "admin_id") + @JsonBackReference(value = "admin_chats") + private User admin; + + @OneToMany(mappedBy = "chat", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference(value = "messages_in_group_chat") + private List messages; + + @ManyToMany(mappedBy = "groupChats", fetch = FetchType.LAZY) + //@JsonBackReference(value = "common_chats") + @JsonIgnore + private List users; +} diff --git a/src/main/java/com/smartcalendar/model/GroupMessage.java b/src/main/java/com/smartcalendar/model/GroupMessage.java new file mode 100644 index 0000000..4cfd81b --- /dev/null +++ b/src/main/java/com/smartcalendar/model/GroupMessage.java @@ -0,0 +1,37 @@ +package com.smartcalendar.model; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Data +@Table(name = "group_messages") +@NoArgsConstructor +@AllArgsConstructor +public class GroupMessage { + @Id + //@GeneratedValue(strategy = GenerationType.IDENTITY) + private UUID id; + + @Column + private String messageText; + + @Column(name = "time_sent") + private LocalDateTime timeWhenSent; + + @ManyToOne + @JoinColumn(name = "chat_id") + @JsonBackReference(value = "messages_in_group_chat") + private GroupChat chat; + + @ManyToOne + @JoinColumn(name = "user_id") + @JsonBackReference(value = "group_message_author") + private User user; +} diff --git a/src/main/java/com/smartcalendar/model/PrivateChat.java b/src/main/java/com/smartcalendar/model/PrivateChat.java new file mode 100644 index 0000000..29c9df7 --- /dev/null +++ b/src/main/java/com/smartcalendar/model/PrivateChat.java @@ -0,0 +1,36 @@ +package com.smartcalendar.model; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonManagedReference; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.UUID; + +@Entity +@Data +@Table(name = "private_chats") +@NoArgsConstructor +@AllArgsConstructor +public class PrivateChat { + @Id + //@GeneratedValue(strategy = GenerationType.IDENTITY) + private UUID id; + + @ManyToOne + @JoinColumn(name = "user_id1") + @JsonBackReference(value = "chats1") + private User user1; + + @ManyToOne + @JoinColumn(name = "user_id2") + @JsonBackReference(value = "chats2") + private User user2; + + @OneToMany(mappedBy = "chat", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference(value = "messages_in_private_chat") + private List messages; +} diff --git a/src/main/java/com/smartcalendar/model/PrivateMessage.java b/src/main/java/com/smartcalendar/model/PrivateMessage.java new file mode 100644 index 0000000..55933ca --- /dev/null +++ b/src/main/java/com/smartcalendar/model/PrivateMessage.java @@ -0,0 +1,37 @@ +package com.smartcalendar.model; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Data +@Table(name = "private_messages") +@NoArgsConstructor +@AllArgsConstructor +public class PrivateMessage { + @Id + //@GeneratedValue(strategy = GenerationType.IDENTITY) + private UUID id; + + @Column + private String messageText; + + @Column(name = "time_sent") + private LocalDateTime timeWhenSent; + + @ManyToOne + @JoinColumn(name = "chat_id") + @JsonBackReference(value = "messages_in_private_chat") + private PrivateChat chat; + + @ManyToOne + @JoinColumn(name = "user_id") + @JsonBackReference(value = "private_message_author") + private User user; +} diff --git a/src/main/java/com/smartcalendar/model/Statistics.java b/src/main/java/com/smartcalendar/model/Statistics.java new file mode 100644 index 0000000..d2dd986 --- /dev/null +++ b/src/main/java/com/smartcalendar/model/Statistics.java @@ -0,0 +1,38 @@ +package com.smartcalendar.model; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "statistics") +@Data +@NoArgsConstructor +public class Statistics { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne + @JoinColumn(name = "user_id", unique = true) + private User user; + + private long totalCommon; + private long totalWork; + private long totalStudy; + private long totalFitness; + + private long weekTime; + + private long todayPlanned; + private long todayCompleted; + + private int continuesRecord; + private int continuesNow; + + private long averageWorkMinutes; + private LocalDate firstDay; +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/model/Tag.java b/src/main/java/com/smartcalendar/model/Tag.java new file mode 100644 index 0000000..757a45d --- /dev/null +++ b/src/main/java/com/smartcalendar/model/Tag.java @@ -0,0 +1,34 @@ +package com.smartcalendar.model; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.UUID; + +@Entity +@Data +@Table(name = "tags") +@NoArgsConstructor +@AllArgsConstructor +public class Tag { + @Id + //@GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private String title; + + @ManyToMany(mappedBy = "tags", fetch = FetchType.LAZY) + //@JsonBackReference(value = "event_tags") + @JsonIgnore + private List events; + + public Long getId() { + return id; + } +} diff --git a/src/main/java/com/smartcalendar/model/Task.java b/src/main/java/com/smartcalendar/model/Task.java index 20aa0ec..17c1f77 100644 --- a/src/main/java/com/smartcalendar/model/Task.java +++ b/src/main/java/com/smartcalendar/model/Task.java @@ -1,6 +1,7 @@ package com.smartcalendar.model; import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Data; @@ -12,6 +13,7 @@ @Entity @Data +@Table(name = "tasks") @NoArgsConstructor @AllArgsConstructor public class Task { @@ -19,19 +21,20 @@ public class Task { @GeneratedValue(strategy = GenerationType.AUTO) private UUID id; + @Column private String title; - + @Column private String description; + @Column + private boolean isCompleted; - private boolean completed; - - private LocalDateTime dueDateTime; // дедлайн с точностью до времени - private Boolean allDay = false; // если true — задача только на день (игнорировать время) + private LocalDateTime dueDateTime; + private Boolean allDay = false; private LocalDateTime creationTime = LocalDateTime.now(); @ManyToOne @JoinColumn(name = "user_id") - @JsonBackReference + @JsonBackReference(value = "user_tasks") private User user; } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/model/User.java b/src/main/java/com/smartcalendar/model/User.java index c6cd1d7..ea94434 100644 --- a/src/main/java/com/smartcalendar/model/User.java +++ b/src/main/java/com/smartcalendar/model/User.java @@ -1,6 +1,7 @@ package com.smartcalendar.model; import com.fasterxml.jackson.annotation.JsonManagedReference; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.persistence.*; import jakarta.validation.constraints.Email; import lombok.Data; @@ -9,9 +10,8 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.util.Collection; -import java.util.Collections; -import java.util.List; +import java.time.LocalDateTime; +import java.util.*; @Entity @Data @@ -34,9 +34,64 @@ public class User implements UserDetails { private String password; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference + @JsonManagedReference(value = "user_tasks") private List tasks; + @Column(name = "device_token") + private String deviceToken; + + @OneToMany(mappedBy = "organizer", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference(value = "organized_events") + private List organized_events; + + @OneToMany(mappedBy = "admin", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference(value = "admin_chats") + private List chatsWithAdminRights; + + @OneToMany(mappedBy = "user1", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference(value = "chats1") + private List privateChats1; + + @OneToMany(mappedBy = "user2", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference(value = "chats2") + private List privateChats2; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference(value = "group_message_author") + private List groupMessages; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference(value = "private_message_author") + private List privateMessages; + + @OneToMany(mappedBy = "user1", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference(value = "friends1") + private List friendships1; + + @OneToMany(mappedBy = "user2", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference(value = "friends2") + private List friendships2; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "users_events", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "event_id") + ) + //@JsonManagedReference(value = "personal_events") + @JsonProperty("events") + private List events = new ArrayList<>(); + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "users_group_chats", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "chat_id") + ) + //@JsonManagedReference(value = "common_chats") + @JsonProperty("group_chats") + private List groupChats; + @Override public Collection getAuthorities() { return Collections.emptyList(); @@ -71,4 +126,13 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return true; } + + public Long getId() { + return id; + } + + public List getVisitedEvents() { + return events.stream().filter(event -> event.getStart().isBefore(LocalDateTime.now())).toList(); + } + } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/repository/EventRepository.java b/src/main/java/com/smartcalendar/repository/EventRepository.java index 7cc86b7..312c2d5 100644 --- a/src/main/java/com/smartcalendar/repository/EventRepository.java +++ b/src/main/java/com/smartcalendar/repository/EventRepository.java @@ -2,6 +2,7 @@ import com.smartcalendar.model.Event; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; @@ -10,4 +11,9 @@ @Repository public interface EventRepository extends JpaRepository { List findByOrganizerId(Long organizerId); + @Query("SELECT e FROM Event e WHERE LOWER(e.location) = LOWER(:location) ORDER BY e.end ASC") + List findByLocationIgnoreCase(String location); + @Query("SELECT e FROM Event e WHERE LOWER(e.location) = LOWER(:location) AND :userId IS NOT NULL " + + "AND (e NOT IN (SELECT v FROM User u JOIN u.events v WHERE u.id = :userId))") + List findByLocationForUser(String location, Long userId); } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/repository/FriendshipRepository.java b/src/main/java/com/smartcalendar/repository/FriendshipRepository.java new file mode 100644 index 0000000..873e877 --- /dev/null +++ b/src/main/java/com/smartcalendar/repository/FriendshipRepository.java @@ -0,0 +1,14 @@ +package com.smartcalendar.repository; + +import com.smartcalendar.model.Friendship; +import com.smartcalendar.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface FriendshipRepository extends JpaRepository { + List findByUser1(User user1); + Optional findByUser1AndUser2(User user1, User user2); + boolean existsByUser1AndUser2(User user1, User user2); +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/repository/StatisticsRepository.java b/src/main/java/com/smartcalendar/repository/StatisticsRepository.java new file mode 100644 index 0000000..d325880 --- /dev/null +++ b/src/main/java/com/smartcalendar/repository/StatisticsRepository.java @@ -0,0 +1,12 @@ +package com.smartcalendar.repository; + +import com.smartcalendar.model.Statistics; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface StatisticsRepository extends JpaRepository { + Optional findByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/repository/TaskRepository.java b/src/main/java/com/smartcalendar/repository/TaskRepository.java index 7424fb5..9f4eafd 100644 --- a/src/main/java/com/smartcalendar/repository/TaskRepository.java +++ b/src/main/java/com/smartcalendar/repository/TaskRepository.java @@ -5,8 +5,9 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.UUID; @Repository -public interface TaskRepository extends JpaRepository { +public interface TaskRepository extends JpaRepository { List findByUserId(Long userId); } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/ChatGPTService.java b/src/main/java/com/smartcalendar/service/ChatGPTService.java index 0adaa9d..89a61f8 100644 --- a/src/main/java/com/smartcalendar/service/ChatGPTService.java +++ b/src/main/java/com/smartcalendar/service/ChatGPTService.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.smartcalendar.model.Event; +import com.smartcalendar.model.EventType; import com.smartcalendar.model.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -14,9 +15,9 @@ import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; @Service public class ChatGPTService { @@ -87,9 +88,25 @@ public Map> generateEventsAndTasks(String userQuery) { logger.info("Generating events and tasks for query: {}", userQuery); String prompt = "Based on the user's query: \"" + userQuery + "\", generate a list of events and tasks. " + - "Respond in JSON format with the following structure: " + - "{ \"events\": [{ \"title\": \"string\", \"start\": \"ISO 8601 datetime\", \"end\": \"ISO 8601 datetime\", \"location\": \"string\" }], " + - "\"tasks\": [{ \"title\": \"string\", \"description\": \"string\", \"completed\": false }] }"; + "If the user mentions a note, description, or additional information related to an event, include it in the 'description' field of the corresponding event, " + + "unless it is clearly a separate task. " + + "Respond strictly in JSON format with the following structure: " + + "{ \"events\": [{ " + + "\"title\": \"string\", " + + "\"description\": \"string\", " + + "\"start\": \"ISO 8601 datetime\", " + + "\"end\": \"ISO 8601 datetime\", " + + "\"location\": \"string\", " + + "\"type\": \"COMMON|FITNESS|STUDIES|WORK\" " + + "}], " + + "\"tasks\": [{ " + + "\"title\": \"string\", " + + "\"description\": \"string\", " + + "\"completed\": false, " + + "\"dueDateTime\": \"ISO 8601 datetime\", " + + "\"allDay\": false " + + "}] } " + + "Do not include any additional text or explanation."; String response = askChatGPT(prompt, "gpt-3.5-turbo"); @@ -112,6 +129,24 @@ public List convertToEntities(Map> data) { if (events != null) { for (Map eventData : events) { Event event = objectMapper.convertValue(eventData, Event.class); + + if (event.getId() == null) { + event.setId(UUID.randomUUID()); + } + if (event.getCreationTime() == null) { + event.setCreationTime(LocalDateTime.now()); + } + if (event.getType() == null && eventData.get("type") != null) { + try { + event.setType(EventType.valueOf(eventData.get("type").toString())); + } catch (Exception ignored) {} + } + if (!event.isCompleted() && eventData.get("completed") != null) { + event.setCompleted(Boolean.parseBoolean(eventData.get("completed").toString())); + } + event.setShared(false); + event.setInvitees(new ArrayList<>()); + event.setParticipants(new ArrayList<>()); entities.add(event); } } @@ -119,6 +154,22 @@ public List convertToEntities(Map> data) { if (tasks != null) { for (Map taskData : tasks) { Task task = objectMapper.convertValue(taskData, Task.class); + + if (task.getId() == null) { + task.setId(UUID.randomUUID()); + } + if (task.getCreationTime() == null) { + task.setCreationTime(LocalDateTime.now()); + } + if (task.getAllDay() == null && taskData.get("allDay") != null) { + task.setAllDay(Boolean.parseBoolean(taskData.get("allDay").toString())); + } + if (task.getDueDateTime() == null && taskData.get("dueDate") != null) { + try { + LocalDate date = LocalDate.parse(taskData.get("dueDate").toString()); + task.setDueDateTime(date.atStartOfDay()); + } catch (Exception ignored) {} + } entities.add(task); } } @@ -127,10 +178,26 @@ public List convertToEntities(Map> data) { } public Map processTranscript(String transcript) { - String prompt = "Based on the following transcript: \"" + transcript + "\", determine if it is related to creating events or tasks. " + + String today = LocalDate.now().toString(); + String prompt = "Today is " + today + ". Based on the following transcript: \"" + transcript + "\", determine if it is related to creating events or tasks. " + "If it is, generate a list of events and tasks strictly in JSON format with the following structure: " + - "{ \"events\": [{ \"title\": \"string\", \"start\": \"ISO 8601 datetime\", \"end\": \"ISO 8601 datetime\", \"location\": \"string\", \"description\": \"string\", \"type\": \"string\" }], " + - "\"tasks\": [{ \"title\": \"string\", \"description\": \"string\", \"completed\": false, \"dueDate\": \"ISO 8601 date\" }] }. " + + "{ \"events\": [{ " + + "\"title\": \"string\", " + + "\"description\": \"string\", " + + "\"start\": \"ISO 8601 datetime\", " + + "\"end\": \"ISO 8601 datetime\", " + + "\"location\": \"string\", " + + "\"type\": \"COMMON|FITNESS|STUDIES|WORK\" " + + "}], " + + "\"tasks\": [{ " + + "\"title\": \"string\", " + + "\"description\": \"string\", " + + "\"completed\": false, " + + "\"dueDateTime\": \"ISO 8601 datetime\", " + + "\"allDay\": false " + + "}] } " + + "If the transcript contains a note, description, or additional information about an event, include it in the 'description' field of the event, " + + "unless it is clearly a separate task. " + "If the transcript is not related to events or tasks, respond with: { \"error\": \"Unrelated request\" }. " + "Do not include any additional text or explanation."; @@ -141,9 +208,15 @@ public Map processTranscript(String transcript) { if (result.containsKey("error")) { logger.warn("ChatGPT returned an error: {}", result); } + if (!result.containsKey("events")) { + result.put("events", List.of()); + } + if (!result.containsKey("tasks")) { + result.put("tasks", List.of()); + } return result; } catch (Exception e) { throw new RuntimeException("Failed to process ChatGPT response: " + e.getMessage()); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/smartcalendar/service/EventService.java b/src/main/java/com/smartcalendar/service/EventService.java new file mode 100644 index 0000000..c23f5d9 --- /dev/null +++ b/src/main/java/com/smartcalendar/service/EventService.java @@ -0,0 +1,65 @@ +package com.smartcalendar.service; + +import com.smartcalendar.model.Event; +import com.smartcalendar.model.Tag; +import com.smartcalendar.model.User; +import com.smartcalendar.repository.EventRepository; +import com.smartcalendar.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class EventService { + + private final UserRepository userRepository; + private final EventRepository eventRepository; + + @Autowired + public EventService(UserRepository userRepository, EventRepository eventRepository) { + this.userRepository = userRepository; + this.eventRepository = eventRepository; + } + + public List getPersonalizedEvents(String location, Long userId) { + List events = new ArrayList<>(); + if (userId != null) { + Optional userOpt = userRepository.findById(userId); + if (userOpt.isPresent()) { + User user = userOpt.get(); + events = calculateRelevance(location, user); + } + } + if (events.isEmpty()) { + events = eventRepository.findByLocationIgnoreCase(location); + } + return filterEventsByTime(events); + } + + private List filterEventsByTime(List events) { + return events.stream().filter(event -> event.getEnd().isAfter(LocalDateTime.now())).toList(); + } + + private List calculateRelevance(String location, User user) { + List locationEvents = eventRepository.findByLocationForUser(location, user.getId()); + Map tagFrequency = new HashMap<>(); + user.getVisitedEvents().forEach(event -> event.getTags().forEach(tag -> + tagFrequency.put(tag.getId(), tagFrequency.getOrDefault(tag.getId(), 0) + 1))); + Map relevanceMap = new HashMap<>(); + for (Event event : locationEvents) { + int score = 0; + for (Long tagId : event.getTags().stream().map(Tag::getId).toList()) { + score += tagFrequency.getOrDefault(tagId, 0); + } + relevanceMap.put(event, score); + } + return locationEvents.stream().sorted((lhs, rhs) -> { + int relevanceCompare = Integer.compare(relevanceMap.get(rhs), relevanceMap.get(lhs)); + if (relevanceCompare != 0) return relevanceCompare; + return lhs.getStart().compareTo(rhs.getStart()); + }).collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/smartcalendar/service/FriendshipService.java b/src/main/java/com/smartcalendar/service/FriendshipService.java new file mode 100644 index 0000000..7c6ef76 --- /dev/null +++ b/src/main/java/com/smartcalendar/service/FriendshipService.java @@ -0,0 +1,60 @@ +package com.smartcalendar.service; + +import com.smartcalendar.exceptions.ConflictException; +import com.smartcalendar.exceptions.ResourceNotFoundException; +import com.smartcalendar.model.Friendship; +import com.smartcalendar.model.User; +import com.smartcalendar.repository.FriendshipRepository; +import com.smartcalendar.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class FriendshipService { + + private final FriendshipRepository friendshipRepository; + private final UserRepository userRepository; + + @Autowired + public FriendshipService(FriendshipRepository friendshipRepository, UserRepository userRepository) { + this.friendshipRepository = friendshipRepository; + this.userRepository = userRepository; + } + + public List getSubscriptions(Long user1Id) { + userRepository.findById(user1Id).orElseThrow(() -> + new ResourceNotFoundException("User not found with id: " + user1Id)); + return friendshipRepository.findByUser1(userRepository.findById(user1Id).get()); + } + + public Friendship createSubscription(Long user1Id, Long user2Id) { + userRepository.findById(user1Id).orElseThrow(() -> + new ResourceNotFoundException("User not found with id: " + user1Id)); + + userRepository.findById(user2Id).orElseThrow(() -> + new ResourceNotFoundException("User not found with id: " + user2Id)); + + User user1 = userRepository.findById(user1Id).get(); + User user2 = userRepository.findById(user2Id).get(); + + if (friendshipRepository.existsByUser1AndUser2(user1, user2)) { + throw new ConflictException("Subscription already exists"); + } + + Friendship subscription = new Friendship(); + subscription.setUser1(user1); + subscription.setUser2(user2); + + return friendshipRepository.save(subscription); + } + + public void deleteSubscription(Long user1Id, Long user2Id) { + Friendship subscription = friendshipRepository + .findByUser1AndUser2(userRepository.findById(user1Id).get(), userRepository.findById(user2Id).get()) + .orElseThrow(() -> new ResourceNotFoundException("Subscription not found")); + + friendshipRepository.delete(subscription); + } +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/NotificationService.java b/src/main/java/com/smartcalendar/service/NotificationService.java new file mode 100644 index 0000000..3b43d23 --- /dev/null +++ b/src/main/java/com/smartcalendar/service/NotificationService.java @@ -0,0 +1,35 @@ +package com.smartcalendar.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class NotificationService { + private final JavaMailSender mailSender; + + @Value("${spring.mail.from:}") + private String fromAddress; + + public void sendEmail(String to, String subject, String text) { + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject(subject); + message.setText(text); + if (fromAddress != null && !fromAddress.isBlank()) { + message.setFrom(fromAddress); + } + mailSender.send(message); + } catch (Exception e) { + System.err.println("Failed to send email to " + to + ": " + e.getMessage()); + } + } + + public void sendPush(String deviceToken, String title, String body) { + System.out.println("[STUB] Push to: " + deviceToken + ", title: " + title + ", body: " + body); + } +} \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/StatisticsService.java b/src/main/java/com/smartcalendar/service/StatisticsService.java index 669dc89..562d2fc 100644 --- a/src/main/java/com/smartcalendar/service/StatisticsService.java +++ b/src/main/java/com/smartcalendar/service/StatisticsService.java @@ -1,34 +1,140 @@ package com.smartcalendar.service; import com.smartcalendar.dto.*; +import com.smartcalendar.model.Statistics; +import com.smartcalendar.model.User; +import com.smartcalendar.repository.StatisticsRepository; +import com.smartcalendar.repository.UserRepository; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; @Service +@RequiredArgsConstructor public class StatisticsService { - public TotalTimeTaskTypes getTotalTimeTaskTypes() { - long common = 120; - long work = 300; - long study = 180; - long fitness = 60; - return new TotalTimeTaskTypes(common, work, study, fitness); + private final StatisticsRepository statisticsRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public StatisticsData getStatistics(Long userId) { + Statistics stats = statisticsRepository.findByUserId(userId) + .orElse(null); + + Date now = new Date(); + + if (stats == null) { + return new StatisticsData( + new TotalTimeTaskTypesDto(0, 0, 0, 0), + 0L, + new TodayTimeDto(0, 0), + new ContinuesSuccessDaysDto(0, 0), + new AverageDayTimeDto(0, null), + now + ); + } + + return new StatisticsData( + new TotalTimeTaskTypesDto(stats.getTotalCommon(), stats.getTotalWork(), stats.getTotalStudy(), stats.getTotalFitness()), + stats.getWeekTime(), + new TodayTimeDto(stats.getTodayPlanned(), stats.getTodayCompleted()), + new ContinuesSuccessDaysDto(stats.getContinuesRecord(), stats.getContinuesNow()), + new AverageDayTimeDto(stats.getAverageWorkMinutes(), stats.getFirstDay()), + now + ); } - public TodayTimeVars getTodayTimeVars() { - long planned = 480; - long completed = 300; - return new TodayTimeVars(planned, completed); + @Transactional + public void updateStatistics(Long userId, StatisticsData statisticsData) { + Statistics stats = statisticsRepository.findByUserId(userId) + .orElseGet(() -> { + Statistics s = new Statistics(); + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found")); + s.setUser(user); + return s; + }); + + stats.setTotalCommon(statisticsData.getTotalTime().getCommon()); + stats.setTotalWork(statisticsData.getTotalTime().getWork()); + stats.setTotalStudy(statisticsData.getTotalTime().getStudy()); + stats.setTotalFitness(statisticsData.getTotalTime().getFitness()); + + stats.setWeekTime(statisticsData.getWeekTime()); + + stats.setTodayPlanned(statisticsData.getTodayTime().getPlanned()); + stats.setTodayCompleted(statisticsData.getTodayTime().getCompleted()); + + stats.setContinuesRecord(statisticsData.getContinuesSuccessDays().getRecord()); + stats.setContinuesNow(statisticsData.getContinuesSuccessDays().getNow()); + + stats.setAverageWorkMinutes(statisticsData.getAverageDayTime().getTotalWorkMinutes()); + stats.setFirstDay(statisticsData.getAverageDayTime().getFirstDay()); + + statisticsRepository.save(stats); } - public ContinuousSuccessDaysVars getContinuousSuccessDaysVars() { - int record = 10; - int now = 5; - return new ContinuousSuccessDaysVars(record, now); + @Transactional(readOnly = true) + public TotalTimeTaskTypesDto getTotalTimeTaskTypes(Long userId) { + Statistics stats = statisticsRepository.findByUserId(userId) + .orElse(null); + + if (stats == null) { + return new TotalTimeTaskTypesDto(0, 0, 0, 0); + } + + return new TotalTimeTaskTypesDto( + stats.getTotalCommon(), + stats.getTotalWork(), + stats.getTotalStudy(), + stats.getTotalFitness() + ); + } + + @Transactional(readOnly = true) + public TodayTimeDto getTodayTimeDto(Long userId) { + Statistics stats = statisticsRepository.findByUserId(userId) + .orElse(null); + + if (stats == null) { + return new TodayTimeDto(0, 0); + } + + return new TodayTimeDto( + stats.getTodayPlanned(), + stats.getTodayCompleted() + ); } - public AverageDayTimeVars getAverageDayTimeVars() { - long totalWorkMinutes = 14400; - long totalDays = 30; - return new AverageDayTimeVars(totalWorkMinutes / totalDays); + @Transactional(readOnly = true) + public ContinuesSuccessDaysDto getContinuesSuccessDaysDto(Long userId) { + Statistics stats = statisticsRepository.findByUserId(userId) + .orElse(null); + + if (stats == null) { + return new ContinuesSuccessDaysDto(0, 0); + } + + return new ContinuesSuccessDaysDto( + stats.getContinuesRecord(), + stats.getContinuesNow() + ); + } + + @Transactional(readOnly = true) + public AverageDayTimeDto getAverageDayTimeDto(Long userId) { + Statistics stats = statisticsRepository.findByUserId(userId) + .orElse(null); + + if (stats == null) { + return new AverageDayTimeDto(0, null); + } + + return new AverageDayTimeDto( + stats.getAverageWorkMinutes(), + stats.getFirstDay() + ); } } \ No newline at end of file diff --git a/src/main/java/com/smartcalendar/service/UserService.java b/src/main/java/com/smartcalendar/service/UserService.java index 0a75db3..0362e4f 100644 --- a/src/main/java/com/smartcalendar/service/UserService.java +++ b/src/main/java/com/smartcalendar/service/UserService.java @@ -1,9 +1,14 @@ package com.smartcalendar.service; +import com.smartcalendar.dto.DailyTaskDto; +import com.smartcalendar.dto.EventDto; +import com.smartcalendar.dto.StatisticsData; +import com.smartcalendar.dto.UserShortDto; import com.smartcalendar.model.Event; import com.smartcalendar.model.Task; import com.smartcalendar.model.User; import com.smartcalendar.repository.EventRepository; +import com.smartcalendar.repository.StatisticsRepository; import com.smartcalendar.repository.TaskRepository; import com.smartcalendar.repository.UserRepository; import jakarta.validation.constraints.Email; @@ -15,9 +20,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; +import java.util.*; +import java.util.stream.Collectors; +import java.time.format.DateTimeFormatter; @Service @RequiredArgsConstructor @@ -26,6 +31,9 @@ public class UserService { private final TaskRepository taskRepository; private final EventRepository eventRepository; private final PasswordEncoder passwordEncoder; + private final StatisticsService statisticsService; + private final StatisticsRepository statisticsRepository; + private final NotificationService notificationService; public User createUser(User user) { if (userRepository.existsByUsername(user.getUsername())) { @@ -58,10 +66,16 @@ public Task createTask(Task task) { return taskRepository.save(task); } - public void deleteTask(Long taskId) { + @Transactional + public void deleteTask(UUID taskId) { taskRepository.deleteById(taskId); } + @Transactional + public void deleteEvent(UUID eventId) { + eventRepository.deleteById(eventId); + } + public boolean existsByUsername(String username) { return userRepository.existsByUsername(username); } @@ -74,7 +88,7 @@ public boolean existsByEmail(String email) { public boolean changeCredentials(String currentUsername, String currentPassword, String newUsername, String newPassword) { User user = userRepository.findByUsername(currentUsername) .orElseThrow(() -> new RuntimeException("User not found")); - + if (passwordEncoder.matches(currentPassword, user.getPassword())) { if (newUsername != null && !newUsername.isEmpty()) { user.setUsername(newUsername); @@ -117,29 +131,334 @@ public User updateEmail(Long id, String newEmail) { } @Transactional - public Task updateTaskStatus(Long taskId, boolean completed) { + public Task updateTaskStatus(UUID taskId, boolean completed) { Task task = taskRepository.findById(taskId) .orElseThrow(() -> new RuntimeException("Task not found")); task.setCompleted(completed); return taskRepository.save(task); } + @Transactional + public void updateEvent(UUID eventId, Event event) { + Event existingEvent = eventRepository.findById(eventId) + .orElseThrow(() -> new RuntimeException("Event not found")); + existingEvent.setTitle(event.getTitle()); + existingEvent.setDescription(event.getDescription()); + existingEvent.setStart(event.getStart()); + existingEvent.setEnd(event.getEnd()); + existingEvent.setLocation(event.getLocation()); + existingEvent.setType(event.getType()); + existingEvent.setCreationTime(event.getCreationTime()); + eventRepository.save(existingEvent); + } + @Transactional(readOnly = true) - public String getTaskDescription(Long taskId) { + public String getTaskDescription(UUID taskId) { Task task = taskRepository.findById(taskId) .orElseThrow(() -> new RuntimeException("Task not found")); return task.getDescription(); } public List findEventsByUserId(Long userId) { - return eventRepository.findByOrganizerId(userId); + List asOrganizer = eventRepository.findByOrganizerId(userId); + List asParticipant = eventRepository.findAll().stream() + .filter(e -> e.getParticipants() != null && e.getParticipants().stream().anyMatch(u -> u.getId().equals(userId))) + .collect(Collectors.toList()); + Set all = new HashSet<>(asOrganizer); + all.addAll(asParticipant); + return new ArrayList<>(all); } public Event createEvent(Event event) { + event.setId(null); return eventRepository.save(event); } public Optional findByUsername(String username) { return userRepository.findByUsername(username); } + + public List findAllEventsAsDailyTaskDto(Long userId) { + List events = findEventsByUserId(userId); + return events.stream().map(event -> new DailyTaskDto( + event.getId(), + event.getTitle(), + event.isCompleted(), + event.getType(), + event.getCreationTime(), + event.getDescription(), + event.getStart() != null ? event.getStart().toLocalTime() : null, + event.getEnd() != null ? event.getEnd().toLocalTime() : null, + event.getStart() != null ? event.getStart().toLocalDate() : null + )).collect(Collectors.toList()); + } + + public Task getTaskById(UUID taskId) { + return taskRepository.findById(taskId) + .orElseThrow(() -> new RuntimeException("Task not found")); + } + + public Event getEventById(UUID eventId) { + return eventRepository.findById(eventId) + .orElseThrow(() -> new RuntimeException("Event not found")); + } + + @Transactional + public Event updateEventStatus(UUID eventId, boolean completed) { + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new RuntimeException("Event not found")); + event.setCompleted(completed); + return eventRepository.save(event); + } + + @Transactional + public UUID deleteEventById(UUID eventId) { + eventRepository.deleteById(eventId); + return eventId; + } + + public StatisticsData getStatistics(Long userId) { + return statisticsService.getStatistics(userId); + } + + @Transactional + public void updateStatistics(Long userId, StatisticsData statisticsData) { + statisticsService.updateStatistics(userId, statisticsData); + } + + @Transactional + public Task createTaskWithCustomId(Task task) { + if (task.getId() != null && taskRepository.existsById(task.getId())) { + throw new IllegalArgumentException("Task with this id already exists"); + } + return taskRepository.save(task); + } + + @Transactional + public void editTask(UUID taskId, Task task) { + Task existingTask = taskRepository.findById(taskId) + .orElseThrow(() -> new RuntimeException("Task not found")); + existingTask.setTitle(task.getTitle()); + existingTask.setDescription(task.getDescription()); + existingTask.setCompleted(task.isCompleted()); + existingTask.setDueDateTime(task.getDueDateTime()); + existingTask.setAllDay(task.getAllDay()); + existingTask.setCreationTime(task.getCreationTime()); + taskRepository.save(existingTask); + } + + @Transactional + public Event createEventWithCustomId(Event event) { + if (event.getId() != null && eventRepository.existsById(event.getId())) { + throw new IllegalArgumentException("Event with this id already exists"); + } + return eventRepository.save(event); + } + + @Transactional + public void editEvent(UUID eventId, Event event) { + Event existingEvent = eventRepository.findById(eventId) + .orElseThrow(() -> new RuntimeException("Event not found")); + existingEvent.setTitle(event.getTitle()); + existingEvent.setDescription(event.getDescription()); + existingEvent.setStart(event.getStart()); + existingEvent.setEnd(event.getEnd()); + existingEvent.setLocation(event.getLocation()); + existingEvent.setType(event.getType()); + existingEvent.setCreationTime(event.getCreationTime()); + existingEvent.setCompleted(event.isCompleted()); + eventRepository.save(existingEvent); + } + + @Transactional + public void deleteAllUsersAndStatistics() { + statisticsRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Transactional + public void deleteUser(Long userId) { + userRepository.deleteById(userId); + } + + public Optional findByEmail(String email) { + return userRepository.findByEmail(email); + } + + public void notifyUserAddedToEvent(User user, Event event, String deviceToken) { + if (user.getEmail() != null && !user.getEmail().isBlank()) { + String subject = "[TimeTamer SmartCalendar] You have been added to event: " + event.getTitle(); + String text = buildEventNotificationText( + "You have been added to the event:", + event, + event.getOrganizer() + ); + notificationService.sendEmail(user.getEmail(), subject, text); + } + if (deviceToken != null && !deviceToken.isBlank()) { + notificationService.sendPush( + deviceToken, + "Added to event", + "You have been added to event: " + event.getTitle() + ); + } + } + + public void notifyUserRemovedFromEvent(User user, Event event, String deviceToken) { + if (user.getEmail() != null && !user.getEmail().isBlank()) { + String subject = "[TimeTamer SmartCalendar] You have been removed from event: " + event.getTitle(); + String text = buildEventNotificationText( + "You have been removed from the event:", + event, + event.getOrganizer() + ); + notificationService.sendEmail(user.getEmail(), subject, text); + } + if (deviceToken != null && !deviceToken.isBlank()) { + notificationService.sendPush(deviceToken, + "Removed from event", + "You have been removed from event: " + event.getTitle()); + } + } + + private String buildEventNotificationText(String action, Event event, User organizer) { + DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + String organizerName = organizer != null + ? organizer.getUsername() + " (" + organizer.getEmail() + ")" + : "a user"; + String eventType = event.getType() != null ? event.getType().name() : "COMMON"; + String start = event.getStart() != null ? event.getStart().format(dtf) : "unspecified"; + String end = event.getEnd() != null ? event.getEnd().format(dtf) : "unspecified"; + String location = event.getLocation() != null ? event.getLocation() : "unspecified"; + + return String.format( + "Hello!\n\n" + + "%s\n\n" + + "Title: %s\n" + + "Type: %s\n" + + "Start: %s\n" + + "End: %s\n" + + "Location: %s\n\n" + + "Description: %s\n\n" + + "Organizer: %s\n\n" + + "This is an automatic notification from TimeTamer SmartCalendar.\n", + action, + event.getTitle(), + eventType, + start, + end, + location, + event.getDescription() != null ? event.getDescription() : "No description", + organizerName + ); + } + + public List findEventsByInvitee(String email) { + return eventRepository.findAll().stream() + .filter(event -> event.getInvitees() != null && event.getInvitees().contains(email)) + .collect(Collectors.toList()); + } + + + public void notifyInvitees(Event event) { + if (event.getInvitees() == null || event.getInvitees().isEmpty()) return; + + String subject = "[TimeTamer SmartCalendar] Event invitation: " + event.getTitle(); + String text = buildEventNotificationText( + "You have been invited to the event:", + event, + event.getOrganizer() + ); + + for (String email : event.getInvitees()) { + Optional userOpt = findByEmail(email); + if (userOpt.isPresent()) { + User user = userOpt.get(); + notificationService.sendEmail(user.getEmail(), subject, text); + if (user.getDeviceToken() != null && !user.getDeviceToken().isBlank()) { + notificationService.sendPush(user.getDeviceToken(), "Invitation", text); + } + } else { + notificationService.sendEmail(email, subject, text); + } + } + } + + public Optional findByLoginOrEmail(String loginOrEmail) { + Optional userOpt = userRepository.findByUsername(loginOrEmail); + if (userOpt.isEmpty()) { + userOpt = userRepository.findByEmail(loginOrEmail); + } + return userOpt; + } + + @Transactional + public Event saveEvent(Event event) { + return eventRepository.save(event); + } + + public void notifyEventDeleted(Event event) { + String subject = "[TimeTamer SmartCalendar] Event \"" + event.getTitle() + "\" has been deleted"; + String text = buildEventNotificationText( + "The event has been deleted:", + event, + event.getOrganizer() + ); + notifyAllEventUsers(event, subject, text); + } + + public void notifyEventUpdated(Event oldEvent, Event newEvent) { + String subject = "[TimeTamer SmartCalendar] Event \"" + oldEvent.getTitle() + "\" has been updated"; + String text = buildEventNotificationText( + "The event has been updated:", + newEvent, + oldEvent.getOrganizer() + ); + notifyAllEventUsers(oldEvent, subject, text); + } + + private void notifyAllEventUsers(Event event, String subject, String text) { + if (event.getParticipants() != null) { + for (User user : event.getParticipants()) { + if (!user.getId().equals(event.getOrganizer().getId())) { + notificationService.sendEmail(user.getEmail(), subject, text); + if (user.getDeviceToken() != null && !user.getDeviceToken().isBlank()) { + notificationService.sendPush(user.getDeviceToken(), subject, text); + } + } + } + } + if (event.getInvitees() != null) { + for (String email : event.getInvitees()) { + notificationService.sendEmail(email, subject, text); + } + } + } + + public EventDto toEventDto(Event event) { + EventDto dto = new EventDto(); + dto.setId(event.getId()); + dto.setTitle(event.getTitle()); + dto.setDescription(event.getDescription()); + dto.setStart(event.getStart()); + dto.setEnd(event.getEnd()); + dto.setLocation(event.getLocation()); + dto.setType(event.getType()); + dto.setCreationTime(event.getCreationTime()); + dto.setCompleted(event.isCompleted()); + dto.setShared(event.isShared()); + dto.setInvitees(event.getInvitees()); + + if (event.getOrganizer() != null) { + dto.setOrganizer(new UserShortDto(event.getOrganizer().getUsername(), event.getOrganizer().getEmail())); + } + if (event.getParticipants() != null) { + dto.setParticipants( + event.getParticipants().stream() + .map(u -> new UserShortDto(u.getUsername(), u.getEmail())) + .toList() + ); + } + return dto; + } } \ No newline at end of file diff --git a/src/main/resources/application-h2.properties b/src/main/resources/application-h2.properties new file mode 100644 index 0000000..8d25e7c --- /dev/null +++ b/src/main/resources/application-h2.properties @@ -0,0 +1,71 @@ +# =============================== +# DATABASE (H2) +# =============================== +spring.datasource.url=jdbc:h2:mem:smartcalendar;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +# =============================== +# H2 CONSOLE +# =============================== +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console +spring.h2.console.settings.trace=true +spring.h2.console.settings.web-allow-others=true + +# =============================== +# DATA INITIALIZATION +# =============================== +spring.sql.init.mode=always +spring.jpa.defer-datasource-initialization=true +spring.jpa.properties.hibernate.dialect= + +# =============================== +# LOGGING +# =============================== +logging.level.org.springframework=INFO +logging.level.com.smartcalendar=DEBUG +logging.level.org.hibernate.SQL=DEBUG +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE +logging.level.org.springframework.security=DEBUG + +# =============================== +# JWT +# =============================== +app.jwt.secret=${JWT_SECRET} +app.jwt.expiration-ms=86400000 + +# =============================== +# SWAGGER +# =============================== +springdoc.swagger-ui.enabled=true +springdoc.api-docs.enabled=true +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.tags-sorter=alpha +springdoc.swagger-ui.operations-sorter=alpha +springdoc.swagger-ui.oauth.client-id=your-client-id +springdoc.swagger-ui.oauth.use-pkce-with-authorization-code-grant=true + +# =============================== +# OTHER SETTINGS +# =============================== +server.port=8080 +server.address=0.0.0.0 +spring.main.banner-mode=off + +chatgpt.api.url=https://api.openai.com/v1/chat/completions +whisper.api.url=https://api.openai.com/v1/audio/transcriptions +chatgpt.api.key=${CHATGPT_API_KEY} + +# =============================== +# SMTP +# =============================== +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${MAIL_ADDRESS} +spring.mail.password=${MAIL_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.from=noreply@ttsc.com \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index f1fab6c..54e44e1 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -4,4 +4,13 @@ spring.datasource.url=jdbc:h2:mem:testdb_test chatgpt.api.url=http://dummy-url chatgpt.api.key=dummy-key JWT_SECRET=your_test_secret -spring.sql.init.mode=never +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console +spring.h2.console.settings.trace=true +spring.h2.console.settings.web-allow-others=true +spring.sql.init.mode=always +spring.jpa.defer-datasource-initialization=true +spring.jpa.properties.hibernate.dialect= diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1fd42b6..2d71d91 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,10 +1,12 @@ # =============================== -# DATABASE (H2) -# =============================== -spring.datasource.url=jdbc:h2:mem:smartcalendar;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -# spring.datasource.driver-class-name=org.h2.Driver - по дефолту h2, иначе тот что зададим -spring.datasource.username=sa -spring.datasource.password= +# DATABASE (PostgreSQL) +# =============================== +spring.datasource.url=jdbc:postgresql://localhost:5432/smartcalendar +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.jpa.show-sql=true +spring.jpa.database=postgresql # =============================== # H2 CONSOLE @@ -19,6 +21,9 @@ spring.h2.console.settings.web-allow-others=true # =============================== spring.sql.init.mode=always spring.jpa.defer-datasource-initialization=true +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.generate-ddl=true # =============================== # LOGGING @@ -63,10 +68,8 @@ chatgpt.api.key=${CHATGPT_API_KEY} # =============================== spring.mail.host=smtp.gmail.com spring.mail.port=587 -spring.mail.username=dimarus06122005@gmail.com +spring.mail.username=${MAIL_ADDRESS} spring.mail.password=${MAIL_PASSWORD} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true spring.mail.from=noreply@ttsc.com - -spring.jpa.hibernate.ddl-auto=update diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..5818795 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,71 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + device_token VARCHAR(255) +); + +CREATE TABLE IF NOT EXISTS events ( + id UUID PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description VARCHAR(255), + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + event_location VARCHAR(255) NOT NULL, + completed BOOLEAN NOT NULL, + is_shared BOOLEAN NOT NULL, + organizer_id BIGSERIAL NOT NULL, + FOREIGN KEY (organizer_id) REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS tasks ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description VARCHAR(255) NOT NULL, + is_completed BOOLEAN, + FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS tags ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL +); + +CREATE TABLE IF NOT EXISTS group_chats ( + id BIGSERIAL PRIMARY KEY, + FOREIGN KEY (admin_id) REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS group_messages ( + id BIGSERIAL PRIMARY KEY, + message_text VARCHAR(255) NOT NULL, + time_sent TIMESTAMP NOT NULL, + FOREIGN KEY (chat_id) REFERENCES group_chats (id), + FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS private_chats ( + id BIGSERIAL PRIMARY KEY, + FOREIGN KEY (user_id1) REFERENCES users (id), + FOREIGN KEY (user_id2) REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS private_messages ( + id BIGSERIAL PRIMARY KEY, + message_text VARCHAR(255) NOT NULL, + time_sent TIMESTAMP NOT NULL, + FOREIGN KEY (chat_id) REFERENCES private_chats (id), + FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS friendships ( + id BIGSERIAL PRIMARY KEY, + FOREIGN KEY (user_id1) REFERENCES users (id), + FOREIGN KEY (user_id2) REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS statistics ( + id BIGSERIAL PRIMARY KEY, + FOREIGN KEY (user_id) REFERENCES users (id) +); \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/config/TestSecurityConfig.java b/src/test/java/com/smartcalendar/config/TestSecurityConfig.java new file mode 100644 index 0000000..6be38ce --- /dev/null +++ b/src/test/java/com/smartcalendar/config/TestSecurityConfig.java @@ -0,0 +1,47 @@ +package com.smartcalendar.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.SecurityFilterChain; + +import java.util.Collections; + +@TestConfiguration +public class TestSecurityConfig { + @Bean + @Primary + public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests((authz) -> authz.anyRequest().permitAll()); + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public AuthenticationProvider testAuthenticationProvider() { + return new AuthenticationProvider() { + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + authentication.setAuthenticated(true); + return authentication; + } + + @Override + public boolean supports(Class authentication) { + return true; + } + }; + } +} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java index 05129c6..99282cd 100644 --- a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java @@ -26,9 +26,9 @@ "JWT_SECRET=test_jwt_secret", "chatgpt.api.url=http://dummy-url", "chatgpt.api.key=dummy-key", - "spring.security.enabled=false", - "spring.sql.init.mode=never" + "spring.security.enabled=false" }) +@ActiveProfiles("h2") @AutoConfigureMockMvc class AudioControllerIntegrationTest { diff --git a/src/test/java/com/smartcalendar/controller/AuthControllerTest.java b/src/test/java/com/smartcalendar/controller/AuthControllerTest.java index 2220bd5..b10d96d 100644 --- a/src/test/java/com/smartcalendar/controller/AuthControllerTest.java +++ b/src/test/java/com/smartcalendar/controller/AuthControllerTest.java @@ -11,6 +11,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; @@ -21,6 +22,8 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.web.servlet.MockMvc; +import java.util.Map; + import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -30,6 +33,7 @@ @SpringBootTest @AutoConfigureMockMvc +@Import(com.smartcalendar.config.TestSecurityConfig.class) @ActiveProfiles("test") @SpringJUnitConfig(AuthControllerTest.TestConfig.class) public class AuthControllerTest { @@ -67,10 +71,18 @@ public JwtService jwtService() { private User testUser; + private String registrationRequestJson(String username, String email, String password, String firstDay) throws Exception { + return objectMapper.writeValueAsString(Map.of( + "username", username, + "email", email, + "password", password, + "firstDay", firstDay + )); + } + @BeforeEach void setUp() { - // Cleanup and setup test user - userService.deleteAllUsers(); + userService.deleteAllUsersAndStatistics(); testUser = new User(); testUser.setUsername("testuser"); @@ -81,22 +93,29 @@ void setUp() { @Test void testRegisterUser_Success() throws Exception { - User newUser = new User(); - newUser.setUsername("newuser"); - newUser.setEmail("new@example.com"); - newUser.setPassword("Password123!"); - mockMvc.perform(post("/api/auth/signup") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(newUser))) + .content(registrationRequestJson("newuser", "new@example.com", "Password123!", "2025-06-07"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.username").value("newuser")) .andExpect(jsonPath("$.email").value("new@example.com")); } + @Test + void testRegisterUser_MissingFirstDay() throws Exception { + String json = objectMapper.writeValueAsString(Map.of( + "username", "user2", + "email", "user2@example.com", + "password", "Password123!" + )); + mockMvc.perform(post("/api/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + @Test void testLogin_Success() throws Exception { - // Mock authentication Authentication auth = new UsernamePasswordAuthenticationToken( testUser.getUsername(), testUser.getPassword() @@ -107,7 +126,6 @@ void testLogin_Success() throws Exception { when(jwtService.generateToken(testUser.getUsername())) .thenReturn("mocked.jwt.token"); - // Test request mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(testUser))) @@ -132,13 +150,11 @@ void testLogin_InvalidCredentials() throws Exception { @Test void testLogin_MissingFields() throws Exception { - // Missing username mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"password123\"}")) .andExpect(status().isBadRequest()); - // Missing password mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"testuser\"}")) @@ -147,14 +163,9 @@ void testLogin_MissingFields() throws Exception { @Test void testRegisterUser_DuplicateUsername() throws Exception { - User duplicateUser = new User(); - duplicateUser.setUsername("testuser"); - duplicateUser.setEmail("duplicate@example.com"); - duplicateUser.setPassword("password123"); - mockMvc.perform(post("/api/auth/signup") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(duplicateUser))) + .content(registrationRequestJson("testuser", "duplicate@example.com", "password123", "2025-06-07"))) .andExpect(status().isBadRequest()); } @@ -168,7 +179,6 @@ void testCreateUser_PasswordIsHashed() { User savedUser = userService.createUser(user); assertNotEquals("rawPassword123", savedUser.getPassword()); - assertTrue(passwordEncoder.matches("rawPassword123", savedUser.getPassword())); } } \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java index a04d2e9..081bc73 100644 --- a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java @@ -10,6 +10,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.util.List; @@ -23,9 +24,9 @@ "JWT_SECRET=test_jwt_secret", "chatgpt.api.url=http://dummy-url", "chatgpt.api.key=dummy-key", - "spring.security.enabled=false", - "spring.sql.init.mode=never" + "spring.security.enabled=false" }) +@ActiveProfiles("h2") @AutoConfigureMockMvc class ChatGPTControllerIntegrationTest { diff --git a/src/test/java/com/smartcalendar/controller/StatisticsControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/StatisticsControllerIntegrationTest.java new file mode 100644 index 0000000..dc80247 --- /dev/null +++ b/src/test/java/com/smartcalendar/controller/StatisticsControllerIntegrationTest.java @@ -0,0 +1,125 @@ +package com.smartcalendar.controller; + +import com.smartcalendar.dto.*; +import com.smartcalendar.model.User; +import com.smartcalendar.service.StatisticsService; +import com.smartcalendar.service.UserService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Date; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(properties = { + "JWT_SECRET=test_jwt_secret" +}) +@ActiveProfiles("test") +@AutoConfigureMockMvc +@Import(com.smartcalendar.config.TestSecurityConfig.class) +class StatisticsControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private StatisticsService statisticsService; + + @Autowired + private UserService userService; + + @TestConfiguration + static class MockConfig { + @Bean + public StatisticsService statisticsService() { + return Mockito.mock(StatisticsService.class); + } + @Bean + public UserService userService() { + return Mockito.mock(UserService.class); + } + } + + private void mockAuth() { + User user = new User(); + user.setId(1L); + user.setUsername("testuser"); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + } + + @Test + @WithMockUser + void testGetTotalTimeTaskTypes() throws Exception { + mockAuth(); + Mockito.when(statisticsService.getTotalTimeTaskTypes(any())).thenReturn(new TotalTimeTaskTypesDto(1,2,3,4)); + mockMvc.perform(get("/api/statistics/total-time-task-types")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.common").value(1)); + } + + @Test + @WithMockUser + void testGetTodayTimeDto() throws Exception { + mockAuth(); + Mockito.when(statisticsService.getTodayTimeDto(any())).thenReturn(new TodayTimeDto(5,6)); + mockMvc.perform(get("/api/statistics/today")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.planned").value(5)); + } + + @Test + @WithMockUser + void testGetContinuesSuccessDaysDto() throws Exception { + mockAuth(); + Mockito.when(statisticsService.getContinuesSuccessDaysDto(any())).thenReturn(new ContinuesSuccessDaysDto(7,8)); + mockMvc.perform(get("/api/statistics/continuous-success-days")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.record").value(7)); + } + + @Test + @WithMockUser + void testGetAverageDayTimeDto() throws Exception { + mockAuth(); + Mockito.when(statisticsService.getAverageDayTimeDto(any())).thenReturn(new AverageDayTimeDto(9, null)); + mockMvc.perform(get("/api/statistics/average-day-time")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalWorkMinutes").value(9)); + } + + @Test + @WithMockUser + void testGetStatisticsWithJsonDate() throws Exception { + mockAuth(); + StatisticsData statisticsData = new StatisticsData( + new TotalTimeTaskTypesDto(1, 2, 3, 4), + 5L, + new TodayTimeDto(6, 7), + new ContinuesSuccessDaysDto(8, 9), + new AverageDayTimeDto(10, null), + new Date() + ); + Mockito.when(userService.getStatistics(any())).thenReturn(statisticsData); + + mockMvc.perform(get("/api/users/1/statistics")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalTime.common").value(1)) + .andExpect(jsonPath("$.jsonDate").exists()); + } +} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java new file mode 100644 index 0000000..748c9b0 --- /dev/null +++ b/src/test/java/com/smartcalendar/controller/UserControllerIntegrationTest.java @@ -0,0 +1,545 @@ +package com.smartcalendar.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.smartcalendar.dto.DailyTaskDto; +import com.smartcalendar.dto.EventDto; +import com.smartcalendar.dto.StatisticsData; +import com.smartcalendar.dto.UserShortDto; +import com.smartcalendar.model.Event; +import com.smartcalendar.model.EventType; +import com.smartcalendar.model.Task; +import com.smartcalendar.model.User; +import com.smartcalendar.service.UserService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; + +import static org.mockito.ArgumentMatchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(properties = { + "JWT_SECRET=test_jwt_secret", + "spring.security.enabled=false" +}) +@ActiveProfiles("test") +@AutoConfigureMockMvc +@Import(com.smartcalendar.config.TestSecurityConfig.class) +class UserControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserService userService; + + @Autowired + private ObjectMapper objectMapper; + + @TestConfiguration + static class MockConfig { + @Bean + public UserService userService() { + return Mockito.mock(UserService.class); + } + } + + private User mockUser(Long id, String username) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(username + "@example.com"); + return user; + } + + // --- USERS --- + + @Test + @WithMockUser + void testGetAllUsers() throws Exception { + Mockito.when(userService.findAllUsers()).thenReturn(Collections.emptyList()); + mockMvc.perform(get("/api/users")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + void testGetUserById() throws Exception { + User user = mockUser(1L, "testuser"); + Mockito.when(userService.findUserById(1L)).thenReturn(user); + mockMvc.perform(get("/api/users/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("testuser")); + } + + @Test + @WithMockUser + void testCreateUser() throws Exception { + User user = mockUser(2L, "newuser"); + Mockito.when(userService.createUser(any(User.class))).thenReturn(user); + + String json = """ + { + "username": "newuser", + "email": "newuser@example.com", + "password": "Password123!" + } + """; + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("newuser")); + } + + @Test + @WithMockUser + void testCreateUserWithExistingUsername() throws Exception { + Mockito.when(userService.createUser(any(User.class))) + .thenThrow(new IllegalArgumentException("Username already exists")); + + String json = """ + { + "username": "existinguser", + "email": "newuser@example.com", + "password": "Password123!" + } + """; + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Username already exists")); + } + + @Test + @WithMockUser + void testCreateUserWithExistingEmail() throws Exception { + Mockito.when(userService.createUser(any(User.class))) + .thenThrow(new IllegalArgumentException("Email already exists")); + + String json = """ + { + "username": "newuser", + "email": "existing@example.com", + "password": "Password123!" + } + """; + mockMvc.perform(post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Email already exists")); + } + + // --- EMAIL --- + + @Test + @WithMockUser + void testUpdateEmail() throws Exception { + User user = mockUser(1L, "testuser"); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.updateEmail(eq(1L), anyString())).thenReturn(user); + + mockMvc.perform(put("/api/users/1/email") + .contentType(MediaType.APPLICATION_JSON) + .content("\"newemail@example.com\"")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("testuser")); + } + + @Test + @WithMockUser + void testUpdateEmail_Forbidden() throws Exception { + User user = mockUser(1L, "testuser"); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + mockMvc.perform(put("/api/users/2/email") + .contentType(MediaType.APPLICATION_JSON) + .content("\"newemail@example.com\"")) + .andExpect(status().isForbidden()); + } + + // --- TASKS --- + + @Test + @WithMockUser + void testGetTasksByUserId() throws Exception { + User user = mockUser(1L, "testuser"); + Task task = new Task(); + task.setId(UUID.randomUUID()); + task.setUser(user); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.findTasksByUserId(1L)).thenReturn(List.of(task)); + + mockMvc.perform(get("/api/users/1/tasks")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").exists()); + } + + @Test + @WithMockUser + void testGetTasksByUserId_Forbidden() throws Exception { + User user = mockUser(1L, "testuser"); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + mockMvc.perform(get("/api/users/2/tasks")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + void testCreateTask() throws Exception { + User user = mockUser(1L, "testuser"); + Task task = new Task(); + task.setId(UUID.randomUUID()); + task.setUser(user); + + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.createTaskWithCustomId(any(Task.class))).thenReturn(task); + + String json = objectMapper.writeValueAsString(task); + mockMvc.perform(post("/api/users/1/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").exists()); + } + + @Test + @WithMockUser + void testCreateTask_Forbidden() throws Exception { + User user = mockUser(1L, "testuser"); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Task task = new Task(); + String json = objectMapper.writeValueAsString(task); + mockMvc.perform(post("/api/users/2/tasks") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + void testGetTaskDescription_Forbidden() throws Exception { + User user = mockUser(1L, "testuser"); + Task task = new Task(); + task.setId(UUID.randomUUID()); + User otherUser = mockUser(2L, "otheruser"); + task.setUser(otherUser); + + Mockito.when(userService.getTaskById(any(UUID.class))).thenReturn(task); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + + mockMvc.perform(get("/api/users/tasks/" + task.getId() + "/description")) + .andExpect(status().isForbidden()); + } + + // --- EVENTS --- + + @Test + @WithMockUser + void testGetEventsByUserId() throws Exception { + User user = mockUser(1L, "testuser"); + Event event = new Event(); + event.setId(UUID.randomUUID()); + event.setOrganizer(user); + + EventDto eventDto = new EventDto(); + eventDto.setId(event.getId()); + eventDto.setTitle("Test Event"); + eventDto.setOrganizer(new UserShortDto(user.getUsername(), user.getEmail())); + + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.findEventsByUserId(1L)).thenReturn(List.of(event)); + Mockito.when(userService.toEventDto(event)).thenReturn(eventDto); + + mockMvc.perform(get("/api/users/1/events")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(event.getId().toString())) + .andExpect(jsonPath("$[0].organizer.username").value("testuser")); + } + + @Test + @WithMockUser + void testGetEventsByUserId_Forbidden() throws Exception { + User user = mockUser(1L, "testuser"); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + mockMvc.perform(get("/api/users/2/events")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + void testCreateEvent() throws Exception { + User user = mockUser(1L, "testuser"); + Event event = new Event(); + event.setId(UUID.randomUUID()); + event.setOrganizer(user); + + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.createEventWithCustomId(any(Event.class))).thenReturn(event); + + String json = objectMapper.writeValueAsString(event); + mockMvc.perform(post("/api/users/1/events") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").exists()); + } + + @Test + @WithMockUser + void testCreateEvent_Forbidden() throws Exception { + User user = mockUser(1L, "testuser"); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Event event = new Event(); + String json = objectMapper.writeValueAsString(event); + mockMvc.perform(post("/api/users/2/events") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isForbidden()); + } + + // --- COLLABORATIVE EVENTS --- + + @Test + @WithMockUser + void testInviteUserToEvent_UserAlreadyParticipant() throws Exception { + User user = mockUser(1L, "testuser"); + Event event = new Event(); + event.setId(UUID.randomUUID()); + event.setParticipants(List.of(user)); + Mockito.when(userService.getEventById(event.getId())).thenReturn(event); + Mockito.when(userService.findByLoginOrEmail("testuser")).thenReturn(Optional.of(user)); + + mockMvc.perform(post("/api/users/events/" + event.getId() + "/invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"loginOrEmail\":\"testuser\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("User is already a participant")); + } + + @Test + @WithMockUser + void testInviteUserToEvent_UserAlreadyInvited() throws Exception { + User user = mockUser(1L, "testuser"); + Event event = new Event(); + event.setId(UUID.randomUUID()); + event.setInvitees(new ArrayList<>(List.of(user.getEmail()))); + Mockito.when(userService.getEventById(event.getId())).thenReturn(event); + Mockito.when(userService.findByLoginOrEmail("testuser")).thenReturn(Optional.of(user)); + + mockMvc.perform(post("/api/users/events/" + event.getId() + "/invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"loginOrEmail\":\"testuser\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("User is already invited")); + } + + @Test + @WithMockUser + void testInviteUserToEvent_UserNotFound() throws Exception { + Event event = new Event(); + event.setId(UUID.randomUUID()); + Mockito.when(userService.getEventById(event.getId())).thenReturn(event); + Mockito.when(userService.findByLoginOrEmail("nouser")).thenReturn(Optional.empty()); + + mockMvc.perform(post("/api/users/events/" + event.getId() + "/invite") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"loginOrEmail\":\"nouser\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("User not found")); + } + + @Test + @WithMockUser + void testAcceptInvite_NoInvite() throws Exception { + User user = mockUser(1L, "testuser"); + Event event = new Event(); + event.setId(UUID.randomUUID()); + event.setInvitees(new ArrayList<>()); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.getEventById(event.getId())).thenReturn(event); + + mockMvc.perform(post("/api/users/events/" + event.getId() + "/accept-invite")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("No invite found for this user")); + } + + @Test + @WithMockUser + void testRemoveParticipant_NotOrganizer() throws Exception { + User user = mockUser(1L, "testuser"); + User other = mockUser(2L, "other"); + Event event = new Event(); + event.setId(UUID.randomUUID()); + event.setOrganizer(other); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.getEventById(event.getId())).thenReturn(event); + + mockMvc.perform(post("/api/users/events/" + event.getId() + "/remove-participant") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"loginOrEmail\":\"other\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error").value("Only organizer can remove participants")); + } + + @Test + @WithMockUser + void testRemoveParticipant_OrganizerCannotBeRemoved() throws Exception { + User user = mockUser(1L, "testuser"); + Event event = new Event(); + event.setId(UUID.randomUUID()); + event.setOrganizer(user); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.getEventById(event.getId())).thenReturn(event); + Mockito.when(userService.findByLoginOrEmail("testuser")).thenReturn(Optional.of(user)); + + mockMvc.perform(post("/api/users/events/" + event.getId() + "/remove-participant") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"loginOrEmail\":\"testuser\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Organizer cannot be removed")); + } + + @Test + @WithMockUser + void testRemoveParticipant_UserNotFound() throws Exception { + User user = mockUser(1L, "testuser"); + Event event = new Event(); + event.setId(UUID.randomUUID()); + event.setOrganizer(user); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.getEventById(event.getId())).thenReturn(event); + Mockito.when(userService.findByLoginOrEmail("nouser")).thenReturn(Optional.empty()); + + mockMvc.perform(post("/api/users/events/" + event.getId() + "/remove-participant") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"loginOrEmail\":\"nouser\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("User not found")); + } + + @Test + @WithMockUser + void testRemoveParticipant_UserNotParticipant() throws Exception { + User user = mockUser(1L, "testuser"); + User other = mockUser(2L, "other"); + Event event = new Event(); + event.setId(UUID.randomUUID()); + event.setOrganizer(user); + event.setParticipants(new ArrayList<>()); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.getEventById(event.getId())).thenReturn(event); + Mockito.when(userService.findByLoginOrEmail("other")).thenReturn(Optional.of(other)); + + mockMvc.perform(post("/api/users/events/" + event.getId() + "/remove-participant") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"loginOrEmail\":\"other\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("User is not a participant")); + } + + // --- STATISTICS --- + + @Test + @WithMockUser + void testGetStatistics() throws Exception { + User user = mockUser(1L, "testuser"); + StatisticsData statisticsData = new StatisticsData(); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.getStatistics(1L)).thenReturn(statisticsData); + + mockMvc.perform(get("/api/users/1/statistics")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalTime").exists()); + } + + @Test + @WithMockUser + void testGetStatistics_Forbidden() throws Exception { + User user = mockUser(1L, "testuser"); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + mockMvc.perform(get("/api/users/2/statistics")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser + void testUpdateStatistics() throws Exception { + User user = mockUser(1L, "testuser"); + StatisticsData statisticsData = new StatisticsData(); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + + String json = objectMapper.writeValueAsString(statisticsData); + mockMvc.perform(put("/api/users/1/statistics") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser + void testUpdateStatistics_Forbidden() throws Exception { + User user = mockUser(1L, "testuser"); + StatisticsData statisticsData = new StatisticsData(); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + + String json = objectMapper.writeValueAsString(statisticsData); + mockMvc.perform(put("/api/users/2/statistics") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isForbidden()); + } + + // --- EDGE CASES --- + + @Test + @WithMockUser + void testGetAllEventsAsDailyTasks_Empty() throws Exception { + User user = mockUser(1L, "testuser"); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.findAllEventsAsDailyTaskDto(1L)).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/users/1/events/dailytasks")) + .andExpect(status().isOk()) + .andExpect(content().json("[]")); + } + + @Test + @WithMockUser + void testGetEventsByUserId_Empty() throws Exception { + User user = mockUser(1L, "testuser"); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.findEventsByUserId(1L)).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/users/1/events")) + .andExpect(status().isOk()) + .andExpect(content().json("[]")); + } + + @Test + @WithMockUser + void testGetTasksByUserId_Empty() throws Exception { + User user = mockUser(1L, "testuser"); + Mockito.when(userService.findByUsername(anyString())).thenReturn(Optional.of(user)); + Mockito.when(userService.findTasksByUserId(1L)).thenReturn(Collections.emptyList()); + + mockMvc.perform(get("/api/users/1/tasks")) + .andExpect(status().isOk()) + .andExpect(content().json("[]")); + } +} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java b/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java new file mode 100644 index 0000000..28f339e --- /dev/null +++ b/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java @@ -0,0 +1,54 @@ +package com.smartcalendar.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +@ActiveProfiles("h2") +class ChatGPTServiceTest { + + @InjectMocks + private ChatGPTService chatGPTService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testConvertToEntities() { + Map> data = Map.of( + "events", List.of(Map.of("title", "Event1")), + "tasks", List.of(Map.of("title", "Task1", "completed", false)) + ); + var entities = chatGPTService.convertToEntities(data); + assertEquals(2, entities.size()); + assertTrue(entities.stream().anyMatch(e -> e.getClass().getSimpleName().equals("Event"))); + assertTrue(entities.stream().anyMatch(e -> e.getClass().getSimpleName().equals("Task"))); + } + + @Test + void testProcessTranscript_Error() { + ChatGPTService spyService = spy(chatGPTService); + doReturn("{\"error\": \"Unrelated request\"}").when(spyService).askChatGPT(anyString(), anyString()); + Map result = spyService.processTranscript("some unrelated text"); + assertTrue(result.containsKey("error")); + } + + @Test + void testGenerateEventsAndTasks_ValidJson() { + ChatGPTService spyService = spy(chatGPTService); + doReturn("{\"events\":[],\"tasks\":[]}").when(spyService).askChatGPT(anyString(), anyString()); + Map> result = spyService.generateEventsAndTasks("test"); + assertTrue(result.containsKey("events")); + assertTrue(result.containsKey("tasks")); + } +} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/service/StatisticsServiceTest.java b/src/test/java/com/smartcalendar/service/StatisticsServiceTest.java new file mode 100644 index 0000000..020a23f --- /dev/null +++ b/src/test/java/com/smartcalendar/service/StatisticsServiceTest.java @@ -0,0 +1,177 @@ +package com.smartcalendar.service; + +import com.smartcalendar.dto.*; +import com.smartcalendar.model.Statistics; +import com.smartcalendar.model.User; +import com.smartcalendar.repository.StatisticsRepository; +import com.smartcalendar.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.Date; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +@ActiveProfiles("h2") +class StatisticsServiceTest { + + @Mock + private StatisticsRepository statisticsRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private StatisticsService statisticsService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testGetStatistics_WhenStatsExist() { + Statistics stats = new Statistics(); + stats.setTotalCommon(1); + stats.setTotalWork(2); + stats.setTotalStudy(3); + stats.setTotalFitness(4); + stats.setWeekTime(5); + stats.setTodayPlanned(6); + stats.setTodayCompleted(7); + stats.setContinuesRecord(8); + stats.setContinuesNow(9); + stats.setAverageWorkMinutes(10); + stats.setFirstDay(LocalDate.of(2024, 1, 1)); + + when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); + + StatisticsData data = statisticsService.getStatistics(1L); + + assertEquals(1, data.getTotalTime().getCommon()); + assertEquals(2, data.getTotalTime().getWork()); + assertEquals(3, data.getTotalTime().getStudy()); + assertEquals(4, data.getTotalTime().getFitness()); + assertEquals(5, data.getWeekTime()); + assertEquals(6, data.getTodayTime().getPlanned()); + assertEquals(7, data.getTodayTime().getCompleted()); + assertEquals(8, data.getContinuesSuccessDays().getRecord()); + assertEquals(9, data.getContinuesSuccessDays().getNow()); + assertEquals(10, data.getAverageDayTime().getTotalWorkMinutes()); + assertEquals(LocalDate.of(2024, 1, 1), data.getAverageDayTime().getFirstDay()); + assertNotNull(data.getJsonDate()); // Новая проверка + assertTrue(data.getJsonDate().getTime() <= new Date().getTime()); // jsonDate не в будущем + } + + @Test + void testGetStatistics_WhenStatsNotExist() { + when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.empty()); + StatisticsData data = statisticsService.getStatistics(1L); + assertNotNull(data); + assertEquals(0, data.getTotalTime().getCommon()); + assertNull(data.getAverageDayTime().getFirstDay()); + assertNotNull(data.getJsonDate()); // Новая проверка + assertTrue(data.getJsonDate().getTime() <= new Date().getTime()); + } + + @Test + void testUpdateStatistics_NewStats() { + User user = new User(); + user.setId(1L); + + StatisticsData dto = new StatisticsData( + new TotalTimeTaskTypesDto(1, 2, 3, 4), + 5L, + new TodayTimeDto(6, 7), + new ContinuesSuccessDaysDto(8, 9), + new AverageDayTimeDto(10, LocalDate.of(2024, 1, 1)), + new Date() + ); + + when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.empty()); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(statisticsRepository.save(any(Statistics.class))).thenAnswer(i -> i.getArgument(0)); + + assertDoesNotThrow(() -> statisticsService.updateStatistics(1L, dto)); + verify(statisticsRepository).save(any(Statistics.class)); + } + + @Test + void testUpdateStatistics_ExistingStats() { + User user = new User(); + user.setId(1L); + Statistics stats = new Statistics(); + stats.setUser(user); + + StatisticsData dto = new StatisticsData( + new TotalTimeTaskTypesDto(1, 2, 3, 4), + 5L, + new TodayTimeDto(6, 7), + new ContinuesSuccessDaysDto(8, 9), + new AverageDayTimeDto(10, LocalDate.of(2024, 1, 1)), + new Date() + ); + + when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); + when(statisticsRepository.save(any(Statistics.class))).thenAnswer(i -> i.getArgument(0)); + + assertDoesNotThrow(() -> statisticsService.updateStatistics(1L, dto)); + verify(statisticsRepository).save(any(Statistics.class)); + } + + @Test + void testGetTotalTimeTaskTypes() { + Statistics stats = new Statistics(); + stats.setTotalCommon(1); + stats.setTotalWork(2); + stats.setTotalStudy(3); + stats.setTotalFitness(4); + + when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); + TotalTimeTaskTypesDto dto = statisticsService.getTotalTimeTaskTypes(1L); + assertEquals(1, dto.getCommon()); + assertEquals(2, dto.getWork()); + assertEquals(3, dto.getStudy()); + assertEquals(4, dto.getFitness()); + } + + @Test + void testGetTodayTimeDto() { + Statistics stats = new Statistics(); + stats.setTodayPlanned(5); + stats.setTodayCompleted(6); + + when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); + TodayTimeDto dto = statisticsService.getTodayTimeDto(1L); + assertEquals(5, dto.getPlanned()); + assertEquals(6, dto.getCompleted()); + } + + @Test + void testGetContinuesSuccessDaysDto() { + Statistics stats = new Statistics(); + stats.setContinuesRecord(7); + stats.setContinuesNow(8); + + when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); + ContinuesSuccessDaysDto dto = statisticsService.getContinuesSuccessDaysDto(1L); + assertEquals(7, dto.getRecord()); + assertEquals(8, dto.getNow()); + } + + @Test + void testGetAverageDayTimeDto() { + Statistics stats = new Statistics(); + stats.setAverageWorkMinutes(9); + stats.setFirstDay(LocalDate.of(2024, 2, 2)); + + when(statisticsRepository.findByUserId(1L)).thenReturn(Optional.of(stats)); + AverageDayTimeDto dto = statisticsService.getAverageDayTimeDto(1L); + assertEquals(9, dto.getTotalWorkMinutes()); + assertEquals(LocalDate.of(2024, 2, 2), dto.getFirstDay()); + } +} diff --git a/src/test/java/com/smartcalendar/service/UserServiceTest.java b/src/test/java/com/smartcalendar/service/UserServiceTest.java index baee1ba..4df42ce 100644 --- a/src/test/java/com/smartcalendar/service/UserServiceTest.java +++ b/src/test/java/com/smartcalendar/service/UserServiceTest.java @@ -2,18 +2,24 @@ import com.smartcalendar.model.User; import com.smartcalendar.repository.UserRepository; +import com.smartcalendar.repository.StatisticsRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; import java.util.Optional; +import java.util.Collections; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; - +@ActiveProfiles("h2") class UserServiceTest { @Mock @@ -22,6 +28,9 @@ class UserServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private StatisticsRepository statisticsRepository; + @InjectMocks private UserService userService; @@ -35,7 +44,10 @@ void testCreateUser() { User user = new User(); user.setUsername("testuser"); user.setPassword("password"); + user.setEmail("test@example.com"); + when(userRepository.existsByUsername("testuser")).thenReturn(false); + when(userRepository.existsByEmail("test@example.com")).thenReturn(false); when(passwordEncoder.encode("password")).thenReturn("encodedPassword"); when(userRepository.save(any(User.class))).thenReturn(user); @@ -47,6 +59,31 @@ void testCreateUser() { verify(userRepository).save(user); } + @Test + void testCreateUser_UsernameExists() { + User user = new User(); + user.setUsername("testuser"); + user.setEmail("test@example.com"); + + when(userRepository.existsByUsername("testuser")).thenReturn(true); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> userService.createUser(user)); + assertEquals("Username already exists", ex.getMessage()); + } + + @Test + void testCreateUser_EmailExists() { + User user = new User(); + user.setUsername("testuser"); + user.setEmail("test@example.com"); + + when(userRepository.existsByUsername("testuser")).thenReturn(false); + when(userRepository.existsByEmail("test@example.com")).thenReturn(true); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> userService.createUser(user)); + assertEquals("Email already exists", ex.getMessage()); + } + @Test void testFindUserById() { User user = new User(); @@ -64,20 +101,198 @@ void testFindUserById() { } @Test - void testGetCurrentUserId() { + void testFindUserById_NotFound() { + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + assertThrows(RuntimeException.class, () -> userService.findUserById(99L)); + } + + @Test + void testFindByUsername() { User user = new User(); - user.setId(1L); user.setUsername("testuser"); when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user)); - String username = "testuser"; + Optional found = userService.findByUsername("testuser"); + assertTrue(found.isPresent()); + assertEquals("testuser", found.get().getUsername()); + } + + @Test + void testFindByUsername_NotFound() { + when(userRepository.findByUsername("nouser")).thenReturn(Optional.empty()); + Optional found = userService.findByUsername("nouser"); + assertFalse(found.isPresent()); + } + + @Test + void testFindByEmail() { + User user = new User(); + user.setEmail("test@example.com"); + + when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user)); + + Optional found = userService.findByEmail("test@example.com"); + assertTrue(found.isPresent()); + assertEquals("test@example.com", found.get().getEmail()); + } + + @Test + void testFindByEmail_NotFound() { + when(userRepository.findByEmail("no@mail.com")).thenReturn(Optional.empty()); + Optional found = userService.findByEmail("no@mail.com"); + assertFalse(found.isPresent()); + } + + @Test + void testExistsByUsername() { + when(userRepository.existsByUsername("testuser")).thenReturn(true); + assertTrue(userService.existsByUsername("testuser")); + } + + @Test + void testExistsByEmail() { + when(userRepository.existsByEmail("test@example.com")).thenReturn(true); + assertTrue(userService.existsByEmail("test@example.com")); + } + + @Test + void testUpdateEmail() { + User user = new User(); + user.setId(1L); + user.setEmail("old@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.save(any(User.class))).thenReturn(user); - User foundUser = userService.findUserById(1L); + User updated = userService.updateEmail(1L, "new@example.com"); + assertEquals("new@example.com", updated.getEmail()); + } - assertNotNull(foundUser); - assertEquals(1L, foundUser.getId()); - assertEquals("testuser", foundUser.getUsername()); + @Test + void testUpdateEmail_NotFound() { + when(userRepository.findById(2L)).thenReturn(Optional.empty()); + assertThrows(RuntimeException.class, () -> userService.updateEmail(2L, "new@example.com")); + } + + @Test + void testFindAllUsers() { + User user = new User(); + user.setUsername("testuser"); + when(userRepository.findAll()).thenReturn(Collections.singletonList(user)); + + List users = userService.findAllUsers(); + assertEquals(1, users.size()); + assertEquals("testuser", users.get(0).getUsername()); + } + + @Test + void testDeleteUser() { + doNothing().when(userRepository).deleteById(1L); + userService.deleteUser(1L); + verify(userRepository).deleteById(1L); + } + + @Test + void testDeleteAllUsersAndStatistics() { + doNothing().when(statisticsRepository).deleteAll(); + doNothing().when(userRepository).deleteAll(); + userService.deleteAllUsersAndStatistics(); + verify(statisticsRepository).deleteAll(); + verify(userRepository).deleteAll(); + } + + @Test + void testUserDetailsService() { + User user = new User(); + user.setUsername("testuser"); + user.setPassword("pass"); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user)); + UserDetails details = userService.userDetailsService().loadUserByUsername("testuser"); + assertEquals("testuser", details.getUsername()); + } + + @Test + void testLoadUserByUsername_NotFound() { + when(userRepository.findByUsername("nouser")).thenReturn(Optional.empty()); + assertThrows(UsernameNotFoundException.class, () -> userService.loadUserByUsername("nouser")); + } + + @Test + void testLoadUserByEmail() { + User user = new User(); + user.setUsername("testuser"); + user.setPassword("pass"); + user.setEmail("test@example.com"); + when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user)); + UserDetails details = userService.loadUserByEmail("test@example.com"); + assertEquals("testuser", details.getUsername()); + } + + @Test + void testLoadUserByEmail_NotFound() { + when(userRepository.findByEmail("no@mail.com")).thenReturn(Optional.empty()); + assertThrows(UsernameNotFoundException.class, () -> userService.loadUserByEmail("no@mail.com")); + } + + @Test + void testChangeCredentials_Success() { + User user = new User(); + user.setUsername("olduser"); + user.setPassword("oldpass"); + when(userRepository.findByUsername("olduser")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("oldpass", "oldpass")).thenReturn(true); + when(userRepository.save(any(User.class))).thenReturn(user); + + boolean result = userService.changeCredentials("olduser", "oldpass", "newuser", "newpass"); + assertTrue(result); + assertEquals("newuser", user.getUsername()); + } + + @Test + void testChangeCredentials_Fail_WrongPassword() { + User user = new User(); + user.setUsername("olduser"); + user.setPassword("oldpass"); + when(userRepository.findByUsername("olduser")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrong", "oldpass")).thenReturn(false); + + boolean result = userService.changeCredentials("olduser", "wrong", "newuser", "newpass"); + assertFalse(result); + } + + @Test + void testChangeCredentials_UserNotFound() { + when(userRepository.findByUsername("nouser")).thenReturn(Optional.empty()); + assertThrows(RuntimeException.class, () -> userService.changeCredentials("nouser", "pass", "new", "new")); + } + + @Test + void testFindByLoginOrEmail_ByUsername() { + User user = new User(); + user.setUsername("testuser"); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(user)); + Optional found = userService.findByLoginOrEmail("testuser"); + assertTrue(found.isPresent()); + assertEquals("testuser", found.get().getUsername()); + } + + @Test + void testFindByLoginOrEmail_ByEmail() { + User user = new User(); + user.setEmail("test@example.com"); + when(userRepository.findByUsername("test@example.com")).thenReturn(Optional.empty()); + when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user)); + Optional found = userService.findByLoginOrEmail("test@example.com"); + assertTrue(found.isPresent()); + assertEquals("test@example.com", found.get().getEmail()); + } + + @Test + void testFindByLoginOrEmail_NotFound() { + when(userRepository.findByUsername("nouser")).thenReturn(Optional.empty()); + when(userRepository.findByEmail("nouser")).thenReturn(Optional.empty()); + Optional found = userService.findByLoginOrEmail("nouser"); + assertFalse(found.isPresent()); } } \ No newline at end of file From 7a82e575d6bcdc7f73ace5008e598cac9a7fa8a1 Mon Sep 17 00:00:00 2001 From: UsatovPavel Date: Wed, 20 Aug 2025 00:24:33 +0300 Subject: [PATCH 4/9] Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-compose.yaml и Dockerfile для запуска приложения в контейнере. В Docker используется Postgresql. JWT_SECRET и CHATGPT_API_KEY передаются через файл .env в общей папке. spring.sql.init.mode=never в test-properties и там где properties т.к. в Docker таблицы пересоздавались hibernate Удалил data.sql и sqema.sql, их логика в DataInitializer, теперь hibernate полностью сам генерирует таблицы. Использование: для удаления базы и рестарта: скрипт на shell, для запуска контейнера: через плагин docker отдельная конфигурация в idea. --- src/main/resources/application.properties | 2 +- src/main/resources/schema.sql | 71 ------------------- .../AudioControllerIntegrationTest.java | 3 +- .../ChatGPTControllerIntegrationTest.java | 3 +- 4 files changed, 5 insertions(+), 74 deletions(-) delete mode 100644 src/main/resources/schema.sql diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2d71d91..a806eaf 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -19,7 +19,7 @@ spring.h2.console.settings.web-allow-others=true # =============================== # DATA INITIALIZATION # =============================== -spring.sql.init.mode=always +spring.sql.init.mode=never spring.jpa.defer-datasource-initialization=true spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql deleted file mode 100644 index 5818795..0000000 --- a/src/main/resources/schema.sql +++ /dev/null @@ -1,71 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id BIGSERIAL PRIMARY KEY, - username VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL, - password VARCHAR(255) NOT NULL, - device_token VARCHAR(255) -); - -CREATE TABLE IF NOT EXISTS events ( - id UUID PRIMARY KEY, - title VARCHAR(255) NOT NULL, - description VARCHAR(255), - start_time TIMESTAMP NOT NULL, - end_time TIMESTAMP NOT NULL, - event_location VARCHAR(255) NOT NULL, - completed BOOLEAN NOT NULL, - is_shared BOOLEAN NOT NULL, - organizer_id BIGSERIAL NOT NULL, - FOREIGN KEY (organizer_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS tasks ( - id BIGSERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL, - description VARCHAR(255) NOT NULL, - is_completed BOOLEAN, - FOREIGN KEY (user_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS tags ( - id BIGSERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL -); - -CREATE TABLE IF NOT EXISTS group_chats ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (admin_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS group_messages ( - id BIGSERIAL PRIMARY KEY, - message_text VARCHAR(255) NOT NULL, - time_sent TIMESTAMP NOT NULL, - FOREIGN KEY (chat_id) REFERENCES group_chats (id), - FOREIGN KEY (user_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS private_chats ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (user_id1) REFERENCES users (id), - FOREIGN KEY (user_id2) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS private_messages ( - id BIGSERIAL PRIMARY KEY, - message_text VARCHAR(255) NOT NULL, - time_sent TIMESTAMP NOT NULL, - FOREIGN KEY (chat_id) REFERENCES private_chats (id), - FOREIGN KEY (user_id) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS friendships ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (user_id1) REFERENCES users (id), - FOREIGN KEY (user_id2) REFERENCES users (id) -); - -CREATE TABLE IF NOT EXISTS statistics ( - id BIGSERIAL PRIMARY KEY, - FOREIGN KEY (user_id) REFERENCES users (id) -); \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java index 99282cd..756eea0 100644 --- a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java @@ -26,7 +26,8 @@ "JWT_SECRET=test_jwt_secret", "chatgpt.api.url=http://dummy-url", "chatgpt.api.key=dummy-key", - "spring.security.enabled=false" + "spring.security.enabled=false", + "spring.sql.init.mode=never" }) @ActiveProfiles("h2") @AutoConfigureMockMvc diff --git a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java index 081bc73..4718073 100644 --- a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java @@ -24,7 +24,8 @@ "JWT_SECRET=test_jwt_secret", "chatgpt.api.url=http://dummy-url", "chatgpt.api.key=dummy-key", - "spring.security.enabled=false" + "spring.security.enabled=false", + "spring.sql.init.mode=never" }) @ActiveProfiles("h2") @AutoConfigureMockMvc From 4760d785ed6a88fba3d5d148826b29c4d01acfff Mon Sep 17 00:00:00 2001 From: UsatovPavel Date: Thu, 21 Aug 2025 10:51:24 +0300 Subject: [PATCH 5/9] Audio: debug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Искал причины не работы аудио, добился того что сервер возвращает корректный ответ на запрос. Логировал данные, ключ Chat_API поменял на тот что с 4o-transcriptions работает, хотя на данный момент это не нужно. --- Dockerfile | 3 + .../service/AudioProcessingService.java | 65 ++++++++++++++++--- src/main/resources/application.properties | 1 + 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8fac962..6a92123 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,7 @@ FROM eclipse-temurin:21-jdk-alpine + +RUN apk add --no-cache ffmpeg curl + WORKDIR /app COPY build/libs/*.jar app.jar diff --git a/src/main/java/com/smartcalendar/service/AudioProcessingService.java b/src/main/java/com/smartcalendar/service/AudioProcessingService.java index 6139c75..f39a193 100644 --- a/src/main/java/com/smartcalendar/service/AudioProcessingService.java +++ b/src/main/java/com/smartcalendar/service/AudioProcessingService.java @@ -1,41 +1,88 @@ package com.smartcalendar.service; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; + +@Slf4j @Service public class AudioProcessingService { @Value("${whisper.api.url}") private String whisperApiUrl; - + @Value("${gpt4o-mini-transcribe.api.url}") + private String transcribeApiUrl; @Value("${chatgpt.api.key}") private String apiKey; private final WebClient webClient = WebClient.builder().build(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private void convertToWav(Path input, Path output) throws IOException, InterruptedException { + String[] command = { + "ffmpeg", "-y", + "-i", input.toString(), + "-ar", "16000", + "-ac", "1", + "-c:a", "pcm_s16le", + output.toString() + }; + + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(true); + Process process = pb.start(); + int exitCode = process.waitFor(); + + if (exitCode != 0) { + throw new RuntimeException("ffmpeg failed to convert audio, exit code: " + exitCode); + } + log.info("Converted file with ffmpeg: {}", output.toAbsolutePath()); + } public String transcribeAudio(MultipartFile file) { try { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", file.getResource()); - body.add("model", "whisper-1"); + Path uploadsDir = Paths.get(System.getProperty("user.dir"), "uploads").toAbsolutePath(); + Files.createDirectories(uploadsDir); + + Path path = uploadsDir.resolve(Objects.requireNonNull(file.getOriginalFilename())); + file.transferTo(path.toFile()); + log.info("Saved uploaded file to: {}", path.toAbsolutePath()); + + //Path fixedPath = uploadsDir.resolve("fixed.wav"); + //convertToWav(path, fixedPath); + + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part("file", new FileSystemResource(path)); + builder.part("model", "whisper-1"); String response = webClient.post() - .uri(whisperApiUrl) + .uri("https://api.openai.com/v1/audio/transcriptions") .header("Authorization", "Bearer " + apiKey) .contentType(MediaType.MULTIPART_FORM_DATA) - .bodyValue(body) + .body(BodyInserters.fromMultipartData(builder.build())) .retrieve() .bodyToMono(String.class) .block(); - return response; } catch (Exception e) { + log.info("Using API key starts with: {}", apiKey.substring(0, 10)); + log.error("Transcription request failed", e); throw new RuntimeException("Failed to transcribe audio: " + e.getMessage()); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a806eaf..636a284 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -62,6 +62,7 @@ spring.main.banner-mode=off chatgpt.api.url=https://api.openai.com/v1/chat/completions whisper.api.url=https://api.openai.com/v1/audio/transcriptions chatgpt.api.key=${CHATGPT_API_KEY} +gpt4o-mini-transcribe.api.url = https://api.openai.com//v1/chat/completions # =============================== # SMTP From e30ae29574151edfa49c3d99d3b8e3d46f515c20 Mon Sep 17 00:00:00 2001 From: UsatovPavel Date: Thu, 21 Aug 2025 21:53:54 +0300 Subject: [PATCH 6/9] Real API tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавил тесты использующие реальное OpenAI API: первые через REST API проверят обработку аудиофайлов(они в resources) сервером, вторые корректность service. Для них отдельные test-real.properties Убрал tasks из ChatGPT т.к. не используются на клиенте и возникала при обработке сервера путаница events с tasks. Улучшил распознование промтом типа задания. --- build.gradle | 1 + .../controller/ChatGPTController.java | 2 +- .../com/smartcalendar/model/EventType.java | 4 + .../model/EventTypeDeserializer.java | 19 ++++ .../smartcalendar/service/ChatGPTService.java | 74 ++++------------ .../config/TestSecurityConfig.java | 2 +- .../AudioControllerIntegrationTest.java | 2 - .../AudioControllerRestTemplateTest.java | 81 ++++++++++++++++++ .../ChatGPTControllerIntegrationTest.java | 2 +- .../service/ChatGPTServiceTest.java | 29 +++---- .../service/ServiceRealApiTest.java | 69 +++++++++++++++ src/test/resources/DescriptionIneedStudy.mp3 | Bin 0 -> 45696 bytes src/test/resources/Swimming12-14Sport.mp3 | Bin 0 -> 99264 bytes src/test/resources/empty.mp3 | Bin 0 -> 5184 bytes 14 files changed, 210 insertions(+), 75 deletions(-) create mode 100644 src/main/java/com/smartcalendar/model/EventTypeDeserializer.java create mode 100644 src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java create mode 100644 src/test/java/com/smartcalendar/service/ServiceRealApiTest.java create mode 100644 src/test/resources/DescriptionIneedStudy.mp3 create mode 100644 src/test/resources/Swimming12-14Sport.mp3 create mode 100644 src/test/resources/empty.mp3 diff --git a/build.gradle b/build.gradle index c8ca27e..5102fc3 100644 --- a/build.gradle +++ b/build.gradle @@ -55,6 +55,7 @@ dependencies { testImplementation 'org.mockito:mockito-junit-jupiter' testImplementation 'io.projectreactor:reactor-test' implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2' + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' } diff --git a/src/main/java/com/smartcalendar/controller/ChatGPTController.java b/src/main/java/com/smartcalendar/controller/ChatGPTController.java index 3dfad32..79b46c9 100644 --- a/src/main/java/com/smartcalendar/controller/ChatGPTController.java +++ b/src/main/java/com/smartcalendar/controller/ChatGPTController.java @@ -26,7 +26,7 @@ public ResponseEntity askChatGPT(@RequestBody Map reques @PostMapping("/generate") public ResponseEntity>> generateEventsAndTasks(@RequestBody Map requestBody) { String userQuery = requestBody.get("query"); - Map> result = chatGPTService.generateEventsAndTasks(userQuery); + Map> result = chatGPTService.generateEvents(userQuery); return ResponseEntity.ok(result); } diff --git a/src/main/java/com/smartcalendar/model/EventType.java b/src/main/java/com/smartcalendar/model/EventType.java index 5581f0f..f5917f9 100644 --- a/src/main/java/com/smartcalendar/model/EventType.java +++ b/src/main/java/com/smartcalendar/model/EventType.java @@ -1,5 +1,9 @@ package com.smartcalendar.model; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(using = EventTypeDeserializer.class) public enum EventType { COMMON, FITNESS, WORK, STUDIES } + diff --git a/src/main/java/com/smartcalendar/model/EventTypeDeserializer.java b/src/main/java/com/smartcalendar/model/EventTypeDeserializer.java new file mode 100644 index 0000000..9f0e692 --- /dev/null +++ b/src/main/java/com/smartcalendar/model/EventTypeDeserializer.java @@ -0,0 +1,19 @@ +package com.smartcalendar.model; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; + +public class EventTypeDeserializer extends JsonDeserializer { + @Override + public EventType deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String value = p.getText().toUpperCase(); + try { + return EventType.valueOf(value); + } catch (IllegalArgumentException e) { + return EventType.COMMON; + } + } +} diff --git a/src/main/java/com/smartcalendar/service/ChatGPTService.java b/src/main/java/com/smartcalendar/service/ChatGPTService.java index 89a61f8..1067be8 100644 --- a/src/main/java/com/smartcalendar/service/ChatGPTService.java +++ b/src/main/java/com/smartcalendar/service/ChatGPTService.java @@ -6,7 +6,6 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.smartcalendar.model.Event; import com.smartcalendar.model.EventType; -import com.smartcalendar.model.Task; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -84,10 +83,10 @@ public String askChatGPT(String question, String model) { } } - public Map> generateEventsAndTasks(String userQuery) { - logger.info("Generating events and tasks for query: {}", userQuery); + public Map> generateEvents(String userQuery) { + logger.info("Generating events for query: {}", userQuery); - String prompt = "Based on the user's query: \"" + userQuery + "\", generate a list of events and tasks. " + + String prompt = "Based on the user's query: \"" + userQuery + "\", generate a list of events . " + "If the user mentions a note, description, or additional information related to an event, include it in the 'description' field of the corresponding event, " + "unless it is clearly a separate task. " + "Respond strictly in JSON format with the following structure: " + @@ -99,21 +98,16 @@ public Map> generateEventsAndTasks(String userQuery) { "\"location\": \"string\", " + "\"type\": \"COMMON|FITNESS|STUDIES|WORK\" " + "}], " + - "\"tasks\": [{ " + - "\"title\": \"string\", " + - "\"description\": \"string\", " + - "\"completed\": false, " + - "\"dueDateTime\": \"ISO 8601 datetime\", " + - "\"allDay\": false " + - "}] } " + - "Do not include any additional text or explanation."; + "Do not include any additional text or explanation. The \"type\" field MUST be exactly one of: \"COMMON\", \"FITNESS\", \"STUDIES\", \"WORK\". \n" + + "If the event is about sports or training, always use \"FITNESS\". \n" + + "Do not invent other values (e.g., \"SPORT\")."; String response = askChatGPT(prompt, "gpt-3.5-turbo"); try { return objectMapper.readValue(response, new TypeReference<>() {}); } catch (Exception e) { - logger.error("Error parsing ChatGPT response into events and tasks", e); + logger.error("Error parsing ChatGPT response into events", e); throw new RuntimeException("Failed to parse ChatGPT response: " + e.getMessage()); } } @@ -124,11 +118,17 @@ public List convertToEntities(Map> data) { List entities = new ArrayList<>(); List> events = (List>) data.get("events"); - List> tasks = (List>) data.get("tasks"); if (events != null) { for (Map eventData : events) { Event event = objectMapper.convertValue(eventData, Event.class); + event.setType(EventType.COMMON); + + if (eventData.get("type")!=null && Arrays.stream(EventType.values()).anyMatch(x->Objects.equals(x.toString(),eventData.get("type").toString()))) { + try { + event.setType(EventType.valueOf(eventData.get("type").toString())); + } catch (Exception ignored) {} + } if (event.getId() == null) { event.setId(UUID.randomUUID()); @@ -136,14 +136,10 @@ public List convertToEntities(Map> data) { if (event.getCreationTime() == null) { event.setCreationTime(LocalDateTime.now()); } - if (event.getType() == null && eventData.get("type") != null) { - try { - event.setType(EventType.valueOf(eventData.get("type").toString())); - } catch (Exception ignored) {} - } if (!event.isCompleted() && eventData.get("completed") != null) { event.setCompleted(Boolean.parseBoolean(eventData.get("completed").toString())); } + event.setShared(false); event.setInvitees(new ArrayList<>()); event.setParticipants(new ArrayList<>()); @@ -151,36 +147,14 @@ public List convertToEntities(Map> data) { } } - if (tasks != null) { - for (Map taskData : tasks) { - Task task = objectMapper.convertValue(taskData, Task.class); - - if (task.getId() == null) { - task.setId(UUID.randomUUID()); - } - if (task.getCreationTime() == null) { - task.setCreationTime(LocalDateTime.now()); - } - if (task.getAllDay() == null && taskData.get("allDay") != null) { - task.setAllDay(Boolean.parseBoolean(taskData.get("allDay").toString())); - } - if (task.getDueDateTime() == null && taskData.get("dueDate") != null) { - try { - LocalDate date = LocalDate.parse(taskData.get("dueDate").toString()); - task.setDueDateTime(date.atStartOfDay()); - } catch (Exception ignored) {} - } - entities.add(task); - } - } return entities; } public Map processTranscript(String transcript) { String today = LocalDate.now().toString(); - String prompt = "Today is " + today + ". Based on the following transcript: \"" + transcript + "\", determine if it is related to creating events or tasks. " + - "If it is, generate a list of events and tasks strictly in JSON format with the following structure: " + + String prompt = "Today is " + today + ". Based on the following transcript: \"" + transcript + "\", determine if it is related to creating events. " + + "If it is, generate a list of events strictly in JSON format with the following structure: " + "{ \"events\": [{ " + "\"title\": \"string\", " + "\"description\": \"string\", " + @@ -189,16 +163,9 @@ public Map processTranscript(String transcript) { "\"location\": \"string\", " + "\"type\": \"COMMON|FITNESS|STUDIES|WORK\" " + "}], " + - "\"tasks\": [{ " + - "\"title\": \"string\", " + - "\"description\": \"string\", " + - "\"completed\": false, " + - "\"dueDateTime\": \"ISO 8601 datetime\", " + - "\"allDay\": false " + - "}] } " + "If the transcript contains a note, description, or additional information about an event, include it in the 'description' field of the event, " + - "unless it is clearly a separate task. " + - "If the transcript is not related to events or tasks, respond with: { \"error\": \"Unrelated request\" }. " + + "If the transcript is not related to events, respond with: { \"error\": \"Unrelated request\" }. " + + "Treat any mention of a 'task' as an event. " + "Do not include any additional text or explanation."; String response = askChatGPT(prompt, "gpt-3.5-turbo"); @@ -211,9 +178,6 @@ public Map processTranscript(String transcript) { if (!result.containsKey("events")) { result.put("events", List.of()); } - if (!result.containsKey("tasks")) { - result.put("tasks", List.of()); - } return result; } catch (Exception e) { throw new RuntimeException("Failed to process ChatGPT response: " + e.getMessage()); diff --git a/src/test/java/com/smartcalendar/config/TestSecurityConfig.java b/src/test/java/com/smartcalendar/config/TestSecurityConfig.java index 6be38ce..57e4e48 100644 --- a/src/test/java/com/smartcalendar/config/TestSecurityConfig.java +++ b/src/test/java/com/smartcalendar/config/TestSecurityConfig.java @@ -16,7 +16,7 @@ @TestConfiguration public class TestSecurityConfig { - @Bean + @Bean(name = "securityFilterChain") @Primary public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) diff --git a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java index 756eea0..9dfa26e 100644 --- a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java @@ -9,10 +9,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import java.util.List; diff --git a/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java b/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java new file mode 100644 index 0000000..8f3d7ff --- /dev/null +++ b/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java @@ -0,0 +1,81 @@ +package com.smartcalendar.controller; + +import com.smartcalendar.SmartCalendarApplication; +import com.smartcalendar.config.TestSecurityConfig; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.*; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +//@Disabled("call real OpenAI API") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = {SmartCalendarApplication.class, TestSecurityConfig.class} // подмешиваем TestSecurityConfig +) +@ActiveProfiles("test-real") +public class AudioControllerRestTemplateTest { + + @Autowired + private org.springframework.boot.test.web.client.TestRestTemplate restTemplate; + + ResponseEntity testAudioSend(String filename) throws Exception { + ClassPathResource audioFile = new ClassPathResource(filename); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", new org.springframework.core.io.FileSystemResource(audioFile.getFile())); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + HttpEntity> requestEntity = + new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.postForEntity( + "/api/audio/process", requestEntity, Object.class + ); + return response; + } + void testSuccessfulAudioSend(String filename, Predicate> checker) throws Exception { + ResponseEntity response = testAudioSend(filename); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + ResponseEntity responseList = (ResponseEntity) response; + Map task = (Map) responseList.getBody().get(0); + assertTrue(checker.test(task)); + } + void testUnrelatedAudio(String filename) throws Exception{ + ResponseEntity response = testAudioSend(filename); + assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.OK); + Map body = (Map) response.getBody(); + Assertions.assertNotNull(body); + assertThat(body.containsKey("error")); + } + @Test + void testStudyEvent(){ + Predicate> descriptionStudy = task ->(task.get("description").toString()).contains("I need to study"); + assertDoesNotThrow(()->testSuccessfulAudioSend("DescriptionIneedStudy.mp3", descriptionStudy)); + } + @Test + void testSwimmingEvent(){ + Predicate> type = task ->(task.get("type").toString().contains("FITNESS") || task.get("type").toString().contains("COMMON")); + //если CHAT GPT выставляет некорректный(SPORT например) COMMON выставляется + Predicate> time12to14=task->task.get("start").toString().contains("12:00") && task.get("end").toString().contains("14:00"); + Predicate> predicate = task->type.test(task) && time12to14.test(task); + assertDoesNotThrow(()->testSuccessfulAudioSend("Swimming12-14Sport.mp3", predicate)); + } + @Test + void testEmptyAudio(){ + assertDoesNotThrow(()->testUnrelatedAudio("empty.mp3")); + } +} \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java index 4718073..2a2b4f0 100644 --- a/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/ChatGPTControllerIntegrationTest.java @@ -59,7 +59,7 @@ void testAskChatGPT() throws Exception { @Test @WithMockUser void testGenerateEventsAndTasks() throws Exception { - Mockito.when(chatGPTService.generateEventsAndTasks(any())).thenReturn( + Mockito.when(chatGPTService.generateEvents(any())).thenReturn( Map.of("events", List.of(), "tasks", List.of()) ); mockMvc.perform(post("/api/chatgpt/generate") diff --git a/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java b/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java index 28f339e..18d7f36 100644 --- a/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java +++ b/src/test/java/com/smartcalendar/service/ChatGPTServiceTest.java @@ -1,17 +1,18 @@ package com.smartcalendar.service; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.*; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.web.reactive.function.client.WebClient; - import java.util.List; import java.util.Map; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.anyString; +import org.mockito.InjectMocks; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import org.mockito.MockitoAnnotations; +import org.springframework.test.context.ActiveProfiles; @ActiveProfiles("h2") class ChatGPTServiceTest { @@ -26,13 +27,11 @@ void setUp() { @Test void testConvertToEntities() { Map> data = Map.of( - "events", List.of(Map.of("title", "Event1")), - "tasks", List.of(Map.of("title", "Task1", "completed", false)) + "events", List.of(Map.of("title", "Event1")) ); var entities = chatGPTService.convertToEntities(data); - assertEquals(2, entities.size()); + assertEquals(1, entities.size()); assertTrue(entities.stream().anyMatch(e -> e.getClass().getSimpleName().equals("Event"))); - assertTrue(entities.stream().anyMatch(e -> e.getClass().getSimpleName().equals("Task"))); } @Test @@ -44,10 +43,10 @@ void testProcessTranscript_Error() { } @Test - void testGenerateEventsAndTasks_ValidJson() { + void testGenerateEvents_ValidJson() { ChatGPTService spyService = spy(chatGPTService); doReturn("{\"events\":[],\"tasks\":[]}").when(spyService).askChatGPT(anyString(), anyString()); - Map> result = spyService.generateEventsAndTasks("test"); + Map> result = spyService.generateEvents("test"); assertTrue(result.containsKey("events")); assertTrue(result.containsKey("tasks")); } diff --git a/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java b/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java new file mode 100644 index 0000000..c4fde97 --- /dev/null +++ b/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java @@ -0,0 +1,69 @@ +package com.smartcalendar.service; + +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; + +import static org.hibernate.validator.internal.util.Contracts.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; + +@Nested +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test-real") +class ServiceRealApiTest { + + @Autowired + private AudioProcessingService audioProcessingService; + + @Autowired + private ChatGPTService chatGPTService; + + MockMultipartFile convertFileToMultipart(String filename) throws IOException { + File audio = new File("src/test/resources/"+filename); + byte[] content = Files.readAllBytes(audio.toPath()); + + return new MockMultipartFile( + "file", + audio.getName(), + "audio/mpeg", + content + ); + } + + @Test + @Disabled("call real OpenAI API") + void testRealAudioTranscription() { + MockMultipartFile multipartFile = assertDoesNotThrow(() -> convertFileToMultipart("DescriptionIneedStudy.mp3")); + + String result = audioProcessingService.transcribeAudio(multipartFile); + assertNotNull(result); + assertTrue(result.contains("need") && result.contains("description")); + } + + @Test + @Disabled("call real OpenAI API") + void testProcessTranscript_RealRequest() { + String transcript = "Create event type study with exactly this description: I need to study"; + + Map result = chatGPTService.processTranscript(transcript); + assertNotNull(result); + assertTrue(result.containsKey("events")); + + List> events = (List>) result.get("events"); + assertFalse(events.isEmpty()); + Map firstEvent = events.getFirst(); + assertEquals("I need to study", firstEvent.get("description")); + System.out.println("Processed events: " + result); + } +} \ No newline at end of file diff --git a/src/test/resources/DescriptionIneedStudy.mp3 b/src/test/resources/DescriptionIneedStudy.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d98aa3804dbca6f9d01bac018ba645b6ad4b86ec GIT binary patch literal 45696 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 literal 0 HcmV?d00001 diff --git a/src/test/resources/Swimming12-14Sport.mp3 b/src/test/resources/Swimming12-14Sport.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..4349074ebc321d8b7d86f35c408ce184c180fe68 GIT binary patch literal 99264 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 Z}(4Vo=Kb&hNprzXQ>) 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 g_Pnm+nZKTKV1O9TpZUK({y(_?KR+Xe9smFU literal 0 HcmV?d00001 diff --git a/src/test/resources/empty.mp3 b/src/test/resources/empty.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..e7a87404df06d67e8a65a9ed46adc9b483daae30 GIT binary patch literal 5184 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 jU%Kn6KG2X7la!D^|F7pJ|1;6_f$ Date: Tue, 26 Aug 2025 21:10:26 +0300 Subject: [PATCH 7/9] Add scripts --- restart-docker.ps1 | 7 +++++++ run-docker.ps1 | 6 ++++++ run-docker.sh | 5 +++++ 3 files changed, 18 insertions(+) create mode 100644 restart-docker.ps1 create mode 100644 run-docker.ps1 create mode 100644 run-docker.sh diff --git a/restart-docker.ps1 b/restart-docker.ps1 new file mode 100644 index 0000000..648de7a --- /dev/null +++ b/restart-docker.ps1 @@ -0,0 +1,7 @@ +$ErrorActionPreference = "Stop" + +Set-Location $PSScriptRoot + +.\gradlew.bat clean build +docker-compose down -v +docker-compose up --build -d \ No newline at end of file diff --git a/run-docker.ps1 b/run-docker.ps1 new file mode 100644 index 0000000..cda4327 --- /dev/null +++ b/run-docker.ps1 @@ -0,0 +1,6 @@ +$ErrorActionPreference = "Stop" + +Set-Location $PSScriptRoot + +.\gradlew.bat clean build +docker-compose up --build -d \ No newline at end of file diff --git a/run-docker.sh b/run-docker.sh new file mode 100644 index 0000000..ea26b91 --- /dev/null +++ b/run-docker.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -e + +./gradlew clean build +docker-compose up --build -d postgres app From a3417c1cf756776e4522783dc445af64f8593102 Mon Sep 17 00:00:00 2001 From: Usatov Pavel Date: Fri, 23 Jan 2026 23:17:20 +0300 Subject: [PATCH 8/9] Exclude open-API tests They use real API Also create task gradlew for it --- .gitignore | 3 +++ build.gradle | 10 +++++++- .../AudioControllerIntegrationTest.java | 18 ++++++++------- .../AudioControllerRestTemplateTest.java | 1 + .../service/ServiceRealApiTest.java | 23 +++++++++++-------- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index ca2bfc8..b7b08e9 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ out/ ### Firebase Service Account ### timetamer-smarcalendar-firebase-adminsdk-fbsvc-8be370036a.json + +### Secrets ### +/src/main/resources/application-test-real.properties \ No newline at end of file diff --git a/build.gradle b/build.gradle index 5102fc3..4923e65 100644 --- a/build.gradle +++ b/build.gradle @@ -60,5 +60,13 @@ dependencies { } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform { + excludeTags 'openAI-api' + } +} + +tasks.register('testOpenAI', Test) { + useJUnitPlatform { + includeTags 'openAI-api' + } } \ No newline at end of file diff --git a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java index 9dfa26e..da5990b 100644 --- a/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java +++ b/src/test/java/com/smartcalendar/controller/AudioControllerIntegrationTest.java @@ -1,8 +1,10 @@ package com.smartcalendar.controller; -import com.smartcalendar.service.AudioProcessingService; -import com.smartcalendar.service.ChatGPTService; +import java.util.List; +import java.util.Map; + import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.any; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -11,14 +13,14 @@ import org.springframework.context.annotation.Bean; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; - -import java.util.List; -import java.util.Map; - -import static org.mockito.ArgumentMatchers.any; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.smartcalendar.service.AudioProcessingService; +import com.smartcalendar.service.ChatGPTService; @SpringBootTest(properties = { "JWT_SECRET=test_jwt_secret", diff --git a/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java b/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java index 8f3d7ff..89ca6d2 100644 --- a/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java +++ b/src/test/java/com/smartcalendar/controller/AudioControllerRestTemplateTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.junit.jupiter.api.Assertions.*; +@Tag("openAI-api") //@Disabled("call real OpenAI API") @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, diff --git a/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java b/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java index c4fde97..7a5726e 100644 --- a/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java +++ b/src/test/java/com/smartcalendar/service/ServiceRealApiTest.java @@ -1,13 +1,5 @@ package com.smartcalendar.service; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; - import java.io.File; import java.io.IOException; import java.nio.file.Files; @@ -15,8 +7,21 @@ import java.util.Map; import static org.hibernate.validator.internal.util.Contracts.assertNotNull; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +@Tag("openAI-api") @Nested @SpringBootTest @AutoConfigureMockMvc From 24c52bb2833d0222c0f8fcacbdf6f5c8911ba616 Mon Sep 17 00:00:00 2001 From: Usatov Pavel Date: Sat, 24 Jan 2026 00:07:41 +0300 Subject: [PATCH 9/9] Docker cache, .gitignore fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now uploaded mp3 in tmp/upload хранятся --- .gitignore | 9 +++++++-- Dockerfile | 16 ++++++++++++++-- docker-compose.yaml | 2 +- .../service/AudioProcessingService.java | 2 +- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index b7b08e9..f99d6f8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ - +tmp/* ### STS ### .apt_generated .classpath @@ -39,4 +39,9 @@ out/ timetamer-smarcalendar-firebase-adminsdk-fbsvc-8be370036a.json ### Secrets ### -/src/main/resources/application-test-real.properties \ No newline at end of file +/src/main/resources/application-test-real.properties +.env + +### Internal usage ### +/internalDocs +out.txt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6a92123..fefbc74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,23 @@ +FROM eclipse-temurin:21-jdk-alpine AS builder + +RUN apk add --no-cache bash + +WORKDIR /app + +COPY gradlew gradlew.bat settings.gradle build.gradle ./ +COPY gradle ./gradle +COPY src ./src + +RUN --mount=type=cache,target=/root/.gradle \ + ./gradlew bootJar --no-daemon + FROM eclipse-temurin:21-jdk-alpine RUN apk add --no-cache ffmpeg curl WORKDIR /app -COPY build/libs/*.jar app.jar -RUN ls -la /app +COPY --from=builder /app/build/libs/*.jar app.jar ENV JAVA_OPTS="" diff --git a/docker-compose.yaml b/docker-compose.yaml index a112c49..14656f4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,7 +7,7 @@ services: POSTGRES_USER: smartuser POSTGRES_PASSWORD: smartpass ports: - - "5432:5432" + - "5433:5432" volumes: - postgres_data:/var/lib/postgresql/data networks: diff --git a/src/main/java/com/smartcalendar/service/AudioProcessingService.java b/src/main/java/com/smartcalendar/service/AudioProcessingService.java index f39a193..a378298 100644 --- a/src/main/java/com/smartcalendar/service/AudioProcessingService.java +++ b/src/main/java/com/smartcalendar/service/AudioProcessingService.java @@ -57,7 +57,7 @@ private void convertToWav(Path input, Path output) throws IOException, Interrupt } public String transcribeAudio(MultipartFile file) { try { - Path uploadsDir = Paths.get(System.getProperty("user.dir"), "uploads").toAbsolutePath(); + Path uploadsDir = Paths.get(System.getProperty("user.dir"), "tmp", "uploads").toAbsolutePath(); Files.createDirectories(uploadsDir); Path path = uploadsDir.resolve(Objects.requireNonNull(file.getOriginalFilename()));