├── .dockerignore ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── backend ├── application │ ├── build.gradle │ └── src │ │ └── main │ │ ├── kotlin │ │ └── dev │ │ │ └── marcal │ │ │ └── chatvault │ │ │ ├── Boot.kt │ │ │ └── config │ │ │ └── CacheConfig.kt │ │ └── resources │ │ └── application.properties ├── domain │ ├── build.gradle │ └── src │ │ ├── main │ │ ├── kotlin │ │ │ └── dev │ │ │ │ └── marcal │ │ │ │ └── chatvault │ │ │ │ ├── app_service │ │ │ │ └── bucket_service │ │ │ │ │ └── BucketService.kt │ │ │ │ ├── config │ │ │ │ └── UseCaseConfig.kt │ │ │ │ ├── model │ │ │ │ ├── BucketFile.kt │ │ │ │ ├── Chat.kt │ │ │ │ ├── ChatBucketInfo.kt │ │ │ │ ├── ChatLastMessage.kt │ │ │ │ ├── ChatNamePatternMatcher.kt │ │ │ │ ├── ChatPayload.kt │ │ │ │ ├── MessageParser.kt │ │ │ │ └── Page.kt │ │ │ │ └── repository │ │ │ │ └── ChatRepository.kt │ │ └── resources │ │ │ └── domain.properties │ │ └── test │ │ └── kotlin │ │ └── dev │ │ └── marcal │ │ └── chatvault │ │ └── model │ │ ├── ChatNamePatternMatcherTest.kt │ │ └── MessageParserTest.kt ├── in-out-boundary │ └── src │ │ └── main │ │ └── kotlin │ │ └── dev │ │ └── marcal │ │ └── chatvault │ │ └── in_out_boundary │ │ ├── input │ │ ├── AttachmentCriteriaInput.kt │ │ ├── FileTypeInputEnum.kt │ │ ├── NewChatInput.kt │ │ ├── NewMessageInput.kt │ │ └── PendingChatFile.kt │ │ └── output │ │ ├── AttachmentInfoOutput.kt │ │ ├── ChatBucketInfoOutput.kt │ │ ├── ChatLastMessageOutput.kt │ │ ├── MessageOutput.kt │ │ └── exceptions │ │ ├── AttachmentFinderException.kt │ │ ├── BucketServiceException.kt │ │ ├── ChatImporterException.kt │ │ ├── ChatNotFoundException.kt │ │ └── MessageParserException.kt ├── infra │ ├── app-service │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ ├── kotlin │ │ │ └── dev │ │ │ │ └── marcal │ │ │ │ └── chatvault │ │ │ │ └── app_service │ │ │ │ └── bucket_service │ │ │ │ ├── BucketServiceImpl.kt │ │ │ │ └── DirectoryZipper.kt │ │ │ └── resources │ │ │ └── appservice.properties │ ├── email │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ ├── kotlin │ │ │ └── dev │ │ │ │ └── marcal │ │ │ │ └── chatvault │ │ │ │ └── email │ │ │ │ ├── config │ │ │ │ └── EmailHandlerConfig.kt │ │ │ │ └── listener │ │ │ │ └── EmailMessageHandler.kt │ │ │ └── resources │ │ │ └── email.properties │ ├── persistence │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ ├── kotlin │ │ │ └── dev │ │ │ │ └── marcal │ │ │ │ └── chatvault │ │ │ │ └── persistence │ │ │ │ ├── ChatRepositoryImpl.kt │ │ │ │ ├── config │ │ │ │ └── PostgresConfig.kt │ │ │ │ ├── dto │ │ │ │ ├── AttachmentInfoDTO.kt │ │ │ │ ├── ChatMessagePairDTO.kt │ │ │ │ └── Mapper.kt │ │ │ │ ├── entity │ │ │ │ ├── ChatEntity.kt │ │ │ │ └── Mapper.kt │ │ │ │ └── repository │ │ │ │ ├── ChatCrudRepository.kt │ │ │ │ ├── EventSourceCrudRepository.kt │ │ │ │ └── MessageCrudRepository.kt │ │ │ └── resources │ │ │ └── persistence.properties │ └── web │ │ ├── build.gradle │ │ └── src │ │ └── main │ │ ├── kotlin │ │ └── dev │ │ │ └── marcal │ │ │ └── chatvault │ │ │ └── web │ │ │ ├── ChatController.kt │ │ │ ├── ChatImportExportController.kt │ │ │ ├── MessageController.kt │ │ │ └── config │ │ │ ├── WebConfig.kt │ │ │ └── WebControllerAdvice.kt │ │ └── resources │ │ ├── public │ │ └── default-avatar.png │ │ └── web.properties ├── service │ ├── build.gradle │ └── src │ │ └── main │ │ └── kotlin │ │ └── dev │ │ └── marcal │ │ └── chatvault │ │ └── service │ │ ├── AttachmentFinder.kt │ │ ├── AttachmentInfoFinderByChatId.kt │ │ ├── BucketDiskImporter.kt │ │ ├── ChatCreator.kt │ │ ├── ChatDeleter.kt │ │ ├── ChatFileExporter.kt │ │ ├── ChatFileImporter.kt │ │ ├── ChatLister.kt │ │ ├── ChatMessageParser.kt │ │ ├── ChatNameUpdater.kt │ │ ├── MessageCreator.kt │ │ ├── MessageFinderByChatId.kt │ │ └── ProfileImageManager.kt └── usecase │ ├── build.gradle │ └── src │ ├── main │ └── kotlin │ │ └── dev │ │ └── marcal │ │ └── chatvault │ │ └── usecase │ │ ├── AttachmentFinderUseCase.kt │ │ ├── AttachmentInfoFinderByChatIdUseCase.kt │ │ ├── BucketDiskImporterUseCase.kt │ │ ├── ChatCreatorUseCase.kt │ │ ├── ChatDeleterUseCase.kt │ │ ├── ChatFileExporterUseCase.kt │ │ ├── ChatFileImporterUseCase.kt │ │ ├── ChatListerUseCase.kt │ │ ├── ChatMessageParserUseCase.kt │ │ ├── ChatNameUpdaterUseCase.kt │ │ ├── MessageCreatorUseCase.kt │ │ ├── MessageDeduplicationUseCase.kt │ │ ├── MessageFinderByChatIdUseCase.kt │ │ ├── ProfileImageManagerUseCase.kt │ │ └── mapper │ │ └── Mapper.kt │ └── test │ ├── kotlin │ └── dev │ │ └── marcal │ │ └── chatvault │ │ └── usecase │ │ ├── ChatFileImporterUseCaseTest.kt │ │ ├── ChatMessageParserUseCaseTest.kt │ │ └── MessageDeduplicationUseCaseTest.kt │ └── resources │ └── test_chat.zip ├── build.gradle ├── compose-dev.yml ├── compose.yml ├── doc └── chatvault-blur-enabled.png ├── frontend ├── .gitignore ├── .npmrc ├── README.md ├── app.vue ├── components │ ├── Attachment.vue │ ├── ChatConfig.vue │ ├── ChatDeleter.vue │ ├── ChatExporter.vue │ ├── ChatItem.vue │ ├── ChatList.vue │ ├── FocusableAttachment.vue │ ├── Gallery.vue │ ├── IconCheck.vue │ ├── IconPencilSquare.vue │ ├── IconThreeDots.vue │ ├── IconTrash.vue │ ├── ImportExportChat.vue │ ├── MessageArea.vue │ ├── MessageAreaNavBar.vue │ ├── MessageCreatedAt.vue │ ├── MessageItem.vue │ ├── NewChatUploader.vue │ ├── ProfileImage.vue │ ├── ProfileImageUploader.vue │ ├── RotableArrowIcon.vue │ └── SearchBar.vue ├── nuxt.config.ts ├── package-lock.json ├── package.json ├── pages │ └── index.vue ├── plugins │ └── pinia.ts ├── public │ └── favicon.ico ├── server │ └── tsconfig.json ├── store │ └── index.ts ├── tsconfig.json └── types │ └── index.ts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.dockerignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | **/build 3 | **/.nuxt 4 | **/dist 5 | **/node_modules 6 | **/.output 7 | **/.env 8 | .idea 9 | compose*.yml 10 | .github -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | *.sqlite 39 | 40 | compose-prd.yml 41 | config/application.properties 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.0.0-alpine as frontend_builder 2 | 3 | WORKDIR /app 4 | 5 | COPY --link ./frontend/package.json ./frontend/package-lock.json ./ 6 | RUN npm install 7 | COPY --link ./frontend . 8 | RUN npm run generate 9 | RUN npm prune 10 | 11 | FROM amazoncorretto:21-alpine as backend_builder 12 | 13 | WORKDIR /app 14 | 15 | COPY --link gradle ./gradle 16 | COPY --link build.gradle gradlew gradlew.bat settings.gradle ./ 17 | COPY --link backend ./backend 18 | 19 | RUN ./gradlew clean build 20 | 21 | FROM amazoncorretto:21-alpine 22 | 23 | WORKDIR /app 24 | COPY --from=frontend_builder /app/.output/public /app/public 25 | COPY --from=backend_builder /app/backend/application/build/libs/application-1.15.1.jar chatvault.jar 26 | 27 | VOLUME /config 28 | EXPOSE 8080 29 | 30 | ENTRYPOINT ["java", "-jar", "chatvault.jar", "--spring.config.additional-location=file:/config/", "--spring.web.resources.static-locations=file:/app/public"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Vitor Marçal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Nome da imagem 2 | IMAGE_NAME = ghcr.io/vitormarcal/chatvault 3 | 4 | # Versão da imagem (informe via linha de comando ou utilize a padrão "dev") 5 | VERSION ?= dev 6 | 7 | # Tarefa padrão de build 8 | .PHONY: build 9 | build: 10 | @echo "Building Docker image with version: $(VERSION)" 11 | docker build -t $(IMAGE_NAME):$(VERSION) . 12 | 13 | # Tarefa para buildar a versão latest 14 | .PHONY: build-latest 15 | build-latest: 16 | @echo "Building Docker image with version: latest" 17 | docker build -t $(IMAGE_NAME):latest . 18 | 19 | # Tarefa para dar push nas imagens 20 | .PHONY: push 21 | push: 22 | @echo "Pushing Docker images to the repository" 23 | docker push $(IMAGE_NAME):$(VERSION) 24 | docker push $(IMAGE_NAME):latest 25 | 26 | # Tarefa completa para buildar e dar push 27 | .PHONY: build-and-push 28 | build-and-push: build build-latest push 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chatvault 2 | 3 | Chat Vault is a Kotlin Spring Boot application designed to store backups of WhatsApp conversations from various sources, such as API imports, email, and directory monitoring, and provide easy access to these conversations through a frontend that resembles a chat application, like WhatsApp. 4 | 5 | This project is still in development, and some features may not be fully implemented. 6 | 7 | ![A screenshot of the ChatVault interface displays a blur effect applied to the message text. On the left, there is an area with the list of chats, each accompanied by its corresponding profile picture. In the center, the open chat shows the visible content of the messages. On the right, the chat's image gallery is visible, showing the thumbnails of the images available in the gallery.](doc/chatvault-blur-enabled.png) 8 | 9 | ## Key Features 10 | * Directory importing: Place files exported from whatsapp in specific directories to be imported into the application. 11 | * Automated Email Backup: Set up an email in ChatVault and export your messages as attachments to that email. Chat Vault identifies this email and archives the messages and attachments automatically. 12 | * Intuitive Frontend: To facilitate access to archived messages and their attachments, Chat Vault also includes a user-friendly frontend. Easily navigate conversations, search messages, and view attachments. 13 | 14 | ## How to export via the Whatsapp app 15 | 16 | Please read the [official Whatsapp FAQ](https://faq.whatsapp.com/1180414079177245/?cms_platform=android). 17 | 18 | With the imported file, to ingest it into ChatVault you can: 19 | 20 | * map a shared folder between the ChatVault import directory and your cell phone; 21 | * send by email for automatic import into ChatVault; 22 | * zip the imported file and upload it to the ChatVault interface. 23 | 24 | ## Repository structure 25 | 26 | This repository is divided into two main modules. They are the modules: frontend (javascript, vue, nuxt) and backend (kotlin, java, spring boot, gradle) 27 | 28 | ### Run Frontend module 29 | 30 | The front end module is a Vue/Nuxt application and it serves what will be rendered by the browser: html, css, javascript and static assets. 31 | To run the conventional way, with npm commands, follow the Readme in the frontend directory. 32 | 33 | You can run it with npm: 34 | 35 | `npm run dev` 36 | 37 | The frontend application will listen on port 3000 by default, unless you ran the backend application before (the backend listens on 8080), in which case the frontend will pick up a random port. 38 | 39 | ### Run Backend module 40 | 41 | You can run the backend application without an IDE: 42 | 43 | `./gradlew run` 44 | 45 | The backend application will listen to 8080 port by default. 46 | 47 | 48 | ### Docker 49 | 50 | Note that downloading container images might require you to authenticate to the GitHub Container Registry [steps here](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry). 51 | You can use compose.yml to create a database and build the frontend and backend project locally. 52 | 53 | `docker-compose -f compose.yml` 54 | 55 | There are docker image packages on github. You can download the latest image with: 56 | 57 | `docker pull ghcr.io/vitormarcal/chatvault:latest` 58 | 59 | ### Volumes 60 | 61 | The app requires storing chat files in the file system. For Docker usage, please refer to the Environment Variables section. 62 | 63 | - `chatvault.bucket.root`: This is the volume used to store your files. Do not delete this! 64 | - `chatvault.bucket.import`: This volume is used temporarily to store chat files that are to be parsed by the app and then moved to bucket.root. 65 | - `chatvault.bucket.export`: This volume is used temporarily to store a chat that is to be downloaded. 66 | 67 | 68 | ### Environment variables 69 | For docker, the variables must be in upper case and where is "." it must be "_": 70 | `some.environment.variable` is like `SOME_ENVIRONMENT_VARIABLE` in docker 71 | 72 | | Environment variables | obs | example | 73 | |-------------------------------------------|------------------------------|----------------------------------------------------| 74 | | Database | required | | 75 | | spring.datasource.url | required | jdbc:postgresql://database_host:5432/database_name | 76 | | spring.datasource.username | required | user | 77 | | spring.datasource.password | required | secret | 78 | | -------------------------- | -------------------------- | --------- | 79 | | Email import | feat not required | | 80 | | chatvault.email.enabled | not required | true | 81 | | chatvault.email.host | required to feat | imap.server.com | 82 | | chatvault.email.password | required to feat | secret | 83 | | chatvault.email.port | required to feat | 993 | 84 | | chatvault.email.username | required to feat | someuser | 85 | | chatvault.email.debug | not required | true | 86 | | -------------------------- | | -------------------------- | 87 | | File system | not required | | 88 | | chatvault.bucket.root | not required | /opt/chatvault/archive | 89 | | chatvault.bucket.import | not required | /opt/chatvault/import | 90 | | chatvault.bucket.export | not required | /opt/chatvault/export | 91 | | -------------------------- | | -------------------------- | 92 | | chatvault.host | not required | https://somehost.com ,http://localhost:3000 | 93 | | spring.servlet.multipart.max-file-size | not required | 500MB | 94 | | spring.servlet.multipart.max-request-size | not required | 500MB | 95 | | chatvault.msgparser.dateformat | not required but recommended | dd/MM/yyyy HH:mm | 96 | ------ 97 | 98 | * If not defined chatvault.msgparser.dateformat, the application will not be able to resolve ambiguities in certain situations -------------------------------------------------------------------------------- /backend/application/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'application' 2 | 3 | application { 4 | mainClass.set("dev.marcal.chatvault.Boot") 5 | } 6 | 7 | dependencies { 8 | implementation project(":backend:infra:persistence") 9 | implementation project(":backend:in-out-boundary") 10 | implementation project(":backend:infra:app-service") 11 | implementation project(":backend:infra:web") 12 | implementation project(":backend:infra:email") 13 | implementation project(":backend:domain") 14 | implementation project(":backend:usecase") 15 | implementation project(":backend:service") 16 | } -------------------------------------------------------------------------------- /backend/application/src/main/kotlin/dev/marcal/chatvault/Boot.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | 6 | @SpringBootApplication 7 | class Boot { 8 | 9 | companion object { 10 | @JvmStatic 11 | fun main(args: Array) { 12 | runApplication(*args) 13 | } 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /backend/application/src/main/kotlin/dev/marcal/chatvault/config/CacheConfig.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.config 2 | 3 | import org.springframework.cache.annotation.EnableCaching 4 | import org.springframework.context.annotation.Configuration 5 | 6 | @Configuration 7 | @EnableCaching 8 | class CacheConfig { 9 | } -------------------------------------------------------------------------------- /backend/application/src/main/resources/application.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitormarcal/chatvault/e834d31a778849455749aa47a233f1c2cfdc13e3/backend/application/src/main/resources/application.properties -------------------------------------------------------------------------------- /backend/domain/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation project(":backend:in-out-boundary") 3 | } -------------------------------------------------------------------------------- /backend/domain/src/main/kotlin/dev/marcal/chatvault/app_service/bucket_service/BucketService.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.app_service.bucket_service 2 | 3 | import dev.marcal.chatvault.model.BucketFile 4 | import org.springframework.core.io.Resource 5 | 6 | interface BucketService { 7 | fun save(bucketFile: BucketFile) 8 | fun loadFileAsResource(bucketFile: BucketFile): Resource 9 | fun zipPendingImports(chatName: String? = null): Sequence 10 | fun deleteZipImported(filename: String) 11 | fun saveToImportDir(bucketFile: BucketFile) 12 | fun saveTextToBucket(bucketFile: BucketFile, messages: Sequence) 13 | fun loadBucketAsZip(path: String): Resource 14 | fun delete(bucketFile: BucketFile) 15 | fun loadBucketListAsZip(): Resource 16 | } -------------------------------------------------------------------------------- /backend/domain/src/main/kotlin/dev/marcal/chatvault/config/UseCaseConfig.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.config 2 | 3 | import dev.marcal.chatvault.model.MessageParser 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.beans.factory.annotation.Value 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import org.springframework.context.annotation.PropertySource 9 | 10 | @Configuration 11 | @PropertySource("classpath:domain.properties") 12 | class UseCaseConfig( 13 | @Value("\${chatvault.msgparser.dateformat}") private val localDateTimePattern: String? 14 | ) { 15 | 16 | private val logger = LoggerFactory.getLogger(this.javaClass) 17 | 18 | 19 | @Bean 20 | fun messageParser(): MessageParser { 21 | val customPattern = localDateTimePattern?.takeIf { it.isNotBlank() }?.also { logger.info("Custom local datetime pattern $localDateTimePattern message parser") } 22 | return MessageParser(customPattern) 23 | } 24 | } -------------------------------------------------------------------------------- /backend/domain/src/main/kotlin/dev/marcal/chatvault/model/BucketFile.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.model 2 | 3 | import java.io.File 4 | import java.io.InputStream 5 | import java.nio.file.Paths 6 | 7 | class BucketFile( 8 | val stream: InputStream? = null, 9 | val bytes: ByteArray? = null, 10 | val fileName: String, 11 | val address: Bucket, 12 | ) { 13 | 14 | 15 | fun file(root: String = "/"): File { 16 | val path = Paths.get(root, address.path, fileName).normalize() 17 | 18 | if (!path.startsWith(File(root).toPath())) { 19 | throw IllegalStateException("bad file path!out of root directory") 20 | } 21 | 22 | if (!path.startsWith(Paths.get(root, address.path))) { 23 | throw IllegalStateException("bad file path!out of bucket chat directory") 24 | } 25 | 26 | return path.toFile() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/domain/src/main/kotlin/dev/marcal/chatvault/model/Chat.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.model 2 | 3 | import java.io.InputStream 4 | import java.time.LocalDateTime 5 | 6 | 7 | data class Chat( 8 | val id: Long, 9 | val externalId: String? = null, 10 | val name: String, 11 | val messages: List = emptyList(), 12 | val bucket: Bucket 13 | ) 14 | 15 | data class Message( 16 | val author: Author, 17 | val createdAt: LocalDateTime, 18 | val content: Content, 19 | val externalId: String? 20 | ) 21 | 22 | data class Author( 23 | val name: String, 24 | val type: AuthorType 25 | ) 26 | 27 | enum class AuthorType { 28 | SYSTEM, 29 | USER 30 | } 31 | 32 | data class Content( 33 | val text: String, 34 | val attachment: Attachment? = null 35 | ) 36 | 37 | data class Attachment( 38 | val name: String, 39 | val bucket: Bucket, 40 | ) { 41 | fun toBucketFile(inputStream: InputStream? = null): BucketFile { 42 | return BucketFile(stream = inputStream, fileName = this.name, address = bucket) 43 | } 44 | 45 | fun toBucketFile(bytes: ByteArray): BucketFile { 46 | return BucketFile(bytes = bytes, fileName = this.name, address = bucket) 47 | } 48 | } 49 | 50 | data class Bucket( 51 | val path: String 52 | ) { 53 | fun withPath(path: String): Bucket { 54 | return this.copy(path = this.path + path) 55 | } 56 | 57 | fun toBucketFile(): BucketFile { 58 | return BucketFile( fileName = "/", address = this) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /backend/domain/src/main/kotlin/dev/marcal/chatvault/model/ChatBucketInfo.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.model 2 | 3 | data class ChatBucketInfo(val chatId: Long, val bucket: Bucket) 4 | -------------------------------------------------------------------------------- /backend/domain/src/main/kotlin/dev/marcal/chatvault/model/ChatLastMessage.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.model 2 | 3 | import java.time.LocalDateTime 4 | 5 | data class ChatLastMessage( 6 | val chatId: Long, 7 | val chatName: String, 8 | val author: Author, 9 | val content: String, 10 | val msgCreatedAt: LocalDateTime, 11 | val msgCount: Long 12 | ) 13 | -------------------------------------------------------------------------------- /backend/domain/src/main/kotlin/dev/marcal/chatvault/model/ChatNamePatternMatcher.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.model 2 | 3 | object ChatNamePatternMatcher { 4 | private val whatsappPattern = Regex(".*WhatsApp.*") 5 | private val chatPattern = Regex("(^|.*/)_chat\\.txt$") 6 | 7 | /** 8 | * Matcher for identifying WhatsApp or chat file patterns. 9 | * - Matches filenames containing 'WhatsApp' anywhere. 10 | * - Matches '_chat.txt' files with or without a preceding directory. 11 | * 12 | * @param input the filename or path to check 13 | * @return true if the input matches the WhatsApp or chat file pattern, false otherwise 14 | */ 15 | fun matches(input: String): Boolean { 16 | return matchesWhatsApp(input) || matchesChatFile(input) 17 | } 18 | 19 | private fun matchesWhatsApp(input: String): Boolean { 20 | return whatsappPattern.containsMatchIn(input) 21 | } 22 | 23 | private fun matchesChatFile(input: String): Boolean { 24 | return chatPattern.containsMatchIn(input) 25 | } 26 | } -------------------------------------------------------------------------------- /backend/domain/src/main/kotlin/dev/marcal/chatvault/model/ChatPayload.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.model 2 | 3 | class ChatPayload( 4 | val externalId: String? = null, 5 | val name: String, 6 | val bucket: Bucket 7 | ) 8 | 9 | data class MessagePayload( 10 | val chatId: Long, 11 | val messages: List 12 | ) -------------------------------------------------------------------------------- /backend/domain/src/main/kotlin/dev/marcal/chatvault/model/MessageParser.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.model 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.AmbiguousDateException 4 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.MessageParserException 5 | import java.time.LocalDateTime 6 | import java.time.format.DateTimeFormatter 7 | import java.time.format.DateTimeFormatterBuilder 8 | 9 | 10 | class MessageParser(pattern: String? = null) { 11 | private val customFormatter: DateTimeFormatter? = 12 | pattern?.let { DateTimeFormatter.ofPattern(it.removeBracketsAndTrim()) } 13 | private val firstComesTheDayFormatter: DateTimeFormatter by lazy { 14 | buildWithPattern("[dd.MM.yyyy][dd.MM.yy]") 15 | } 16 | private val firstComesTheMonthFormatter: DateTimeFormatter by lazy { 17 | buildWithPattern("[MM.dd.yyyy][MM.dd.yy]") 18 | } 19 | 20 | private var lastUsed: DateTimeFormatter? = null 21 | 22 | 23 | companion object { 24 | 25 | private const val UNICODE_LEFT_TO_RIGHT = "\u200E" 26 | private const val NULL_CHAR = '\u0000' 27 | 28 | private const val DATE_REGEX = 29 | "^$UNICODE_LEFT_TO_RIGHT?(\\[?\\d{1,4}[-/.]\\d{1,4}[-/.]\\d{1,4}[,.]? \\d{2}:\\d{2}(?::\\d{2})?\\s?([aA][mM]|[pP][mM])?\\]?)" 30 | private val DATE_WITHOUT_NAME_REGEX = "$DATE_REGEX(?: - |: )(.*)$".toRegex() 31 | private val DATE_WITH_NAME_REGEX = "$DATE_REGEX(?: - |: )([^:]+): (.+)$".toRegex() 32 | private val ONLY_DATE = DATE_REGEX.toRegex() 33 | private val ATTACHMENT_NAME_REGEX = "^(.*?)\\s+\\((.*?)\\)$".toRegex() 34 | } 35 | 36 | fun parse(text: String, transform: (Message) -> R): R { 37 | return transform(parse(text)) 38 | } 39 | 40 | private fun buildWithPattern(pattern: String): DateTimeFormatter { 41 | return DateTimeFormatterBuilder().parseCaseSensitive().appendPattern(pattern).optionalStart() 42 | .appendPattern("[,][.]").optionalEnd().appendPattern("[hh:mma][HH:mm]").toFormatter() 43 | } 44 | 45 | fun parseDate(text: String): LocalDateTime { 46 | return customFormatter?.let { 47 | LocalDateTime.parse(text.removeBracketsAndTrim(), it) 48 | } ?: tryToInfer(text) 49 | } 50 | 51 | /** 52 | * Tries to infer the date from the provided text based on pre-configured formats. 53 | * Throws [AmbiguousDateException] if the day and month cannot be distinguished. 54 | */ 55 | private fun tryToInfer(text: String): LocalDateTime { 56 | text.removeBracketsAndTrim() 57 | .normalizeToDotSeparatedFormat() 58 | .let { 59 | val groups = it.split(".") 60 | val textToParse = groups.dayAndMonthWith2Digits() ?: it 61 | val formatter = when { 62 | groups[0].toInt() > 12 -> firstComesTheDayFormatter 63 | groups[1].toInt() > 12 -> firstComesTheMonthFormatter 64 | lastUsed != null -> lastUsed 65 | else -> throw AmbiguousDateException("There is ambiguity in the date, it is not possible to know which value is the day and which is the month: $text") 66 | } 67 | lastUsed = formatter!! 68 | return LocalDateTime.parse(textToParse, formatter) 69 | } 70 | } 71 | 72 | fun extractTextDate(text: String): String? { 73 | val dateMatcher = ONLY_DATE.find(text) 74 | return dateMatcher?.value 75 | } 76 | 77 | private fun parse( 78 | text: String 79 | ): Message { 80 | val firstLine = text.lineSequence().first() 81 | val textMessage = text.lineSequence().drop(1).joinToString("\n") 82 | 83 | val (date, name, firstLineMessage) = extractDateNameFirstLineMessage(firstLine, text) 84 | 85 | val content = buildContent(firstLineMessage, textMessage) 86 | val attachment = extractAttachment(firstLineMessage) 87 | 88 | return Message( 89 | author = buildAuthor(name), 90 | content = Content(text = content.removeNullCharacters(), attachment = attachment), 91 | createdAt = date, 92 | externalId = null 93 | ) 94 | } 95 | 96 | private fun extractDateNameFirstLineMessage( 97 | firstLine: String, text: String 98 | ): ParsedMessageInfo { 99 | return DATE_WITH_NAME_REGEX.find(firstLine)?.let { result -> 100 | val date = parseDate(result.groupValues[1]) 101 | val name = result.groupValues[3].trim() 102 | val content = result.groupValues[4].trim() 103 | ParsedMessageInfo(date, name, content.removeLtrPrefix()) 104 | } ?: DATE_WITHOUT_NAME_REGEX.find(text)?.let { result -> 105 | val date = parseDate(result.groupValues[1]) 106 | val content = result.groupValues[3].trim() 107 | ParsedMessageInfo(date, null, content.removeLtrPrefix()) 108 | 109 | } ?: throw MessageParserException("Parse text fail. Unexpected situation for the line $firstLine") 110 | } 111 | 112 | private fun buildAuthor(name: String?): Author { 113 | return name?.let { Author(name = it, type = AuthorType.USER) } ?: Author(name = "", type = AuthorType.SYSTEM) 114 | } 115 | 116 | private fun buildContent(firstLineMessage: String, textMessage: String): String { 117 | return listOf(firstLineMessage, textMessage).filter { it.isNotEmpty() }.joinToString("\n") 118 | } 119 | 120 | private fun extractAttachment(firstLineMessage: String): Attachment? { 121 | return ATTACHMENT_NAME_REGEX.find(firstLineMessage)?.groupValues?.get(1)?.let { 122 | Attachment(name = it, bucket = Bucket("/")) 123 | } 124 | } 125 | 126 | private fun String.removeLtrPrefix() = this.removePrefix(UNICODE_LEFT_TO_RIGHT) 127 | private fun String.removeNullCharacters() = this.filterNot { it == NULL_CHAR } 128 | 129 | data class ParsedMessageInfo(val date: LocalDateTime, val name: String?, val content: String) 130 | } 131 | 132 | fun String.removeBracketsAndTrim(): String = this.replace("[\\[\\]]+".toRegex(), " ").trim() 133 | 134 | fun String.normalizeToDotSeparatedFormat(): String = this.replace("[.\\s,\\-/]+".toRegex(), ".") 135 | 136 | fun List.dayAndMonthWith2Digits(): String? { 137 | 138 | val first = if (this[0].length == 1) "0" + this[0] else null 139 | val second = if (this[1].length == 1) "0" + this[1] else null 140 | if (first == null && second == null) { 141 | return null 142 | } 143 | val mutable = this.toMutableList() 144 | first?.let { mutable[0] = it } 145 | second?.let { mutable[1] = it } 146 | return mutable.joinToString(".") 147 | } 148 | -------------------------------------------------------------------------------- /backend/domain/src/main/kotlin/dev/marcal/chatvault/model/Page.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.model 2 | 3 | data class Page( 4 | val page: Int, 5 | val totalPages: Int, 6 | val totalItems: Long, 7 | val items: Int, 8 | val data: List 9 | ) { 10 | fun hasNext(): Boolean { 11 | if (totalPages == 0) return false 12 | if (totalPages == page) return false 13 | if (page < 1) return false 14 | return page < totalPages 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/domain/src/main/kotlin/dev/marcal/chatvault/repository/ChatRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.repository 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.AttachmentInfoOutput 4 | import dev.marcal.chatvault.in_out_boundary.output.MessageOutput 5 | import dev.marcal.chatvault.model.* 6 | import org.springframework.data.domain.Pageable 7 | 8 | interface ChatRepository { 9 | 10 | fun saveNewMessages(payload: MessagePayload, eventSource: Boolean = false) 11 | fun findChatBucketInfoByChatId(chatId: Long): ChatBucketInfo? 12 | 13 | fun create(payload: ChatPayload): ChatBucketInfo 14 | fun existsByExternalId(externalId: String): Boolean 15 | fun existsByChatId(chatId: Long): Boolean 16 | fun findChatBucketInfoByExternalId(externalId: String): ChatBucketInfo 17 | fun findLegacyToImport(chatId: Long, page: Int, size: Int): Page 18 | fun findAttachmentLegacyToImport(chatId: Long, page: Int, size: Int): Page 19 | fun findAllEventSourceChatId(): List 20 | fun saveLegacyMessage(messagePayload: MessagePayload) 21 | 22 | fun setLegacyAttachmentImported(messageExternalId: String) 23 | 24 | fun findAllChatsWithLastMessage(): Sequence 25 | 26 | fun findMessagesBy(chatId: Long, query: String? = null, pageable: Pageable): org.springframework.data.domain.Page 27 | fun findMessageBy(chatId: Long, messageId: Long): Message? 28 | fun findChatBucketInfoByChatName(chatName: String): ChatBucketInfo? 29 | fun countChatMessages(chatId: Long): Long 30 | fun setChatNameByChatId(chatId: Long, chatName: String) 31 | fun findAttachmentMessageIdsByChatId(chatId: Long): Sequence 32 | fun findLastMessageByChatId(chatId: Long): Message? 33 | fun deleteChat(chatId: Long) 34 | } -------------------------------------------------------------------------------- /backend/domain/src/main/resources/domain.properties: -------------------------------------------------------------------------------- 1 | # chatvault.msgparser.dateformat ex: "dd/MM/YYYY HH:mm", "MM/dd/YYYY HH:mm", "dd-MM-YYYY hh:mm a" 2 | chatvault.msgparser.dateformat= -------------------------------------------------------------------------------- /backend/domain/src/test/kotlin/dev/marcal/chatvault/model/ChatNamePatternMatcherTest.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.model 2 | 3 | import org.junit.jupiter.api.Assertions.assertFalse 4 | import org.junit.jupiter.api.Assertions.assertTrue 5 | import org.junit.jupiter.params.ParameterizedTest 6 | import org.junit.jupiter.params.provider.MethodSource 7 | 8 | class ChatNamePatternMatcherTest { 9 | 10 | companion object { 11 | @JvmStatic 12 | fun validPatternStrings() = listOf( 13 | "_chat.txt", // Exact match for _chat.txt 14 | "folder/_chat.txt", // Exact match for dir with _chat.txt 15 | "Conversa com WhatsApp.txt", // Contains WhatsApp 16 | "Conversation with WhatsApp.txt", // Contains WhatsApp 17 | ) 18 | 19 | @JvmStatic 20 | fun invalidPatternStrings() = listOf( 21 | "Outras mensagens", // Does not contain WhatsApp or _chat.txt 22 | "Este é um arquivo _chato.txt", // Does not end with _chat.txt 23 | "mensagem aleatória", // No patterns 24 | "arquivo_chatx.txt", // Incorrect pattern (error at the end) 25 | "arquivo_com_chat e outro conteudo", // Does not end with .txt 26 | "arquivo_chat.txt" , // Ends with _chat.txt but is not the exact pattern 27 | "dir/arquivo_chat.txt" // Ends with _chat.txt but is not the exact pattern 28 | ) 29 | } 30 | 31 | @ParameterizedTest 32 | @MethodSource("validPatternStrings") 33 | fun `should return true for valid pattern strings`(input: String) { 34 | assertTrue(ChatNamePatternMatcher.matches(input), "Expected '$input' to match the pattern") 35 | } 36 | 37 | @ParameterizedTest 38 | @MethodSource("invalidPatternStrings") 39 | fun `should return false for invalid pattern strings`(input: String) { 40 | assertFalse(ChatNamePatternMatcher.matches(input), "Expected '$input' not to match the pattern") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/input/AttachmentCriteriaInput.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.input 2 | 3 | data class AttachmentCriteriaInput( 4 | val chatId: Long, 5 | val messageId: Long 6 | ) -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/input/FileTypeInputEnum.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.input 2 | 3 | enum class FileTypeInputEnum { 4 | TEXT, 5 | ZIP 6 | } -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/input/NewChatInput.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.input 2 | 3 | data class NewChatInput( 4 | val name: String, 5 | val externalId: String? = null 6 | ) 7 | -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/input/NewMessageInput.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.input 2 | 3 | import java.time.LocalDateTime 4 | 5 | data class NewMessageInput( 6 | val authorName: String, 7 | val chatId: Long, 8 | val externalId: String? = null, 9 | val createdAt: LocalDateTime? = null, 10 | val content: String, 11 | val attachment: NewAttachmentInput? = null 12 | ) 13 | 14 | data class NewMessagePayloadInput( 15 | val chatId: Long, 16 | val eventSource: Boolean, 17 | val messages: List 18 | ) 19 | 20 | data class NewAttachmentInput( 21 | val name: String, 22 | val content: String 23 | ) 24 | -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/input/PendingChatFile.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.input 2 | 3 | import java.io.InputStream 4 | 5 | class PendingChatFile( 6 | val stream: InputStream, 7 | val fileName: String, 8 | val chatName: String 9 | ) -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/output/AttachmentInfoOutput.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.output 2 | 3 | data class AttachmentInfoOutput( 4 | val id: Long, 5 | val name: String 6 | ) 7 | -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/output/ChatBucketInfoOutput.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.output 2 | 3 | data class ChatBucketInfoOutput( 4 | val chatId: Long, 5 | val path: String 6 | ) 7 | -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/output/ChatLastMessageOutput.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.output 2 | 3 | import java.time.LocalDateTime 4 | 5 | data class ChatLastMessageOutput( 6 | val chatId: Long, 7 | val chatName: String, 8 | val authorName: String, 9 | val authorType: String, 10 | val content: String, 11 | val msgCreatedAt: LocalDateTime, 12 | val msgCount: Long 13 | ) -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/output/MessageOutput.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.output 2 | 3 | import java.time.LocalDateTime 4 | 5 | data class MessageOutput( 6 | val id: Long? = null, 7 | val author: String? = null, 8 | val authorType: String, 9 | val content: String, 10 | val attachmentName: String? = null, 11 | val createdAt: LocalDateTime 12 | ) -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/output/exceptions/AttachmentFinderException.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.output.exceptions 2 | 3 | open class AttachmentFinderException(message: String? = null, throwable: Throwable?): RuntimeException(message, throwable) 4 | 5 | class AttachmentNotFoundException(message: String? = "the message was not found", throwable: Throwable? = null): AttachmentFinderException(message, throwable) -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/output/exceptions/BucketServiceException.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.output.exceptions 2 | open class BucketServiceException(message: String?, throwable: Throwable?): RuntimeException(message, throwable) 3 | 4 | class BucketFileNotFoundException(message: String?, throwable: Throwable?): BucketServiceException(message, throwable) -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/output/exceptions/ChatImporterException.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.output.exceptions 2 | 3 | class ChatImporterException(message: String? = null, throwable: Throwable? = null) : RuntimeException(message, throwable) -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/output/exceptions/ChatNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.output.exceptions 2 | 3 | class ChatNotFoundException(message: String? = null, throwable: Throwable? = null): RuntimeException(message, throwable) -------------------------------------------------------------------------------- /backend/in-out-boundary/src/main/kotlin/dev/marcal/chatvault/in_out_boundary/output/exceptions/MessageParserException.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.in_out_boundary.output.exceptions 2 | 3 | open class MessageParserException(message: String) : RuntimeException(message) 4 | 5 | class AmbiguousDateException(message: String) : MessageParserException(message) -------------------------------------------------------------------------------- /backend/infra/app-service/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation project(":backend:domain") 3 | implementation project(":backend:service") 4 | implementation project(":backend:in-out-boundary") 5 | } -------------------------------------------------------------------------------- /backend/infra/app-service/src/main/kotlin/dev/marcal/chatvault/app_service/bucket_service/BucketServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.app_service.bucket_service 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.AttachmentFinderException 4 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.AttachmentNotFoundException 5 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.BucketFileNotFoundException 6 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.BucketServiceException 7 | import dev.marcal.chatvault.model.Bucket 8 | import dev.marcal.chatvault.model.BucketFile 9 | import jakarta.annotation.PostConstruct 10 | import org.slf4j.LoggerFactory 11 | import org.springframework.beans.factory.annotation.Value 12 | import org.springframework.context.annotation.PropertySource 13 | import org.springframework.core.io.InputStreamResource 14 | import org.springframework.core.io.Resource 15 | import org.springframework.core.io.UrlResource 16 | import org.springframework.stereotype.Service 17 | import java.io.* 18 | import java.nio.file.Files 19 | import java.nio.file.StandardCopyOption 20 | 21 | 22 | @Service 23 | @PropertySource("classpath:appservice.properties") 24 | class BucketServiceImpl( 25 | @Value("\${chatvault.bucket.root}") val bucketRootPath: String, 26 | @Value("\${chatvault.bucket.import}") val bucketImportPath: String, 27 | @Value("\${chatvault.bucket.export}") val bucketExportPath: String 28 | ) : BucketService { 29 | 30 | private val logger = LoggerFactory.getLogger(this.javaClass) 31 | 32 | @PostConstruct 33 | fun init() { 34 | createBucketIfNotExists(bucketRootPath) 35 | createBucketIfNotExists(bucketImportPath) 36 | createBucketIfNotExists(bucketExportPath) 37 | } 38 | 39 | fun saveToBucket(bucketFile: BucketFile, bucketRootPath: String) { 40 | try { 41 | val file = bucketFile.file(bucketRootPath).also { createBucketIfNotExists(it.parentFile) } 42 | 43 | val bytes = bucketFile.bytes 44 | if (bytes != null) { 45 | FileOutputStream(file).use { fos -> 46 | fos.write(bytes) 47 | fos.flush() 48 | } 49 | } else { 50 | val inputStream = requireNotNull(bucketFile.stream) 51 | Files.copy(inputStream, file.toPath(), StandardCopyOption.REPLACE_EXISTING) 52 | } 53 | 54 | logger.info("File save at ${file.absolutePath}") 55 | } catch (e: FileNotFoundException) { 56 | throw BucketServiceException("file not found when try to save ${bucketFile.fileName}", e) 57 | } catch (e: IOException) { 58 | throw BucketServiceException("I/O error when try to save ${bucketFile.fileName}", e) 59 | } catch (e: Exception) { 60 | throw BucketServiceException("not mapped error when try to save ${bucketFile.fileName}", e) 61 | } 62 | } 63 | 64 | override fun save(bucketFile: BucketFile) { 65 | saveToBucket(bucketFile, bucketRootPath) 66 | } 67 | 68 | override fun saveToImportDir(bucketFile: BucketFile) { 69 | saveToBucket(bucketFile, bucketImportPath) 70 | } 71 | 72 | override fun saveTextToBucket(bucketFile: BucketFile, messages: Sequence) { 73 | try { 74 | val file = bucketFile.file(bucketRootPath) 75 | BufferedWriter(FileWriter(file)).use { writer -> 76 | messages.forEach { messageLine -> 77 | writer.write(messageLine) 78 | writer.newLine() 79 | } 80 | } 81 | } catch (ex: FileNotFoundException) { 82 | throw BucketFileNotFoundException("File to save ${bucketFile.fileName}. Bucket chat was not found", ex) 83 | } catch (ex: Exception) { 84 | throw BucketServiceException("File to save ${bucketFile.fileName}. Unexpected error", ex) 85 | } 86 | 87 | } 88 | 89 | override fun loadBucketAsZip(path: String): Resource { 90 | try { 91 | return File(bucketRootPath).getDirectoriesWithContentAndZipFiles() 92 | .first { path == it.name } 93 | .let { dir -> zip(dir, targetDir = bucketExportPath) } 94 | } catch (e: Exception) { 95 | throw BucketServiceException(message = "Fail to zip bucket", throwable = e) 96 | } 97 | 98 | } 99 | 100 | override fun loadBucketListAsZip(): Resource = zip(File(bucketRootPath), targetDir = bucketExportPath) 101 | 102 | private fun zip(dir: File, targetDir: String) = DirectoryZipper.zip(dir, targetDir, saveInSameBaseDir = false).let { resource -> 103 | InputStreamResource(object : FileInputStream(resource.file) { 104 | @Throws(IOException::class) 105 | override fun close() { 106 | super.close() 107 | val isDeleted: Boolean = resource.file.delete() 108 | logger.info( 109 | "export:'{}':" + if (isDeleted) "deleted" else "preserved", resource.file.name 110 | ) 111 | } 112 | }) 113 | } 114 | 115 | 116 | override fun delete(bucketFile: BucketFile) { 117 | val file = bucketFile.file(bucketRootPath) 118 | logger.info("start to delete bucket at: $file") 119 | 120 | 121 | if (!file.exists()) { 122 | return 123 | } 124 | 125 | Files.walk(file.toPath()) 126 | .sorted(reverseOrder()) 127 | .forEach { 128 | logger.info("item to delete: {}", it) 129 | Files.delete(it) 130 | } 131 | 132 | } 133 | 134 | override fun zipPendingImports(chatName: String?): Sequence { 135 | try { 136 | return File(bucketImportPath) 137 | .getDirectoriesWithContentAndZipFiles() 138 | .asSequence() 139 | .filter { chatName == null || chatName == it.name } 140 | .map { chatGroupDir -> 141 | if (chatGroupDir.name.endsWith(".zip")) { 142 | UrlResource(chatGroupDir.toURI()) 143 | } else { 144 | DirectoryZipper.zipAndDeleteSource(chatGroupDir) 145 | } 146 | 147 | } 148 | } catch (e: Exception) { 149 | throw BucketServiceException(message = "Fail to zip pending imports", throwable = e) 150 | } 151 | 152 | } 153 | 154 | override fun deleteZipImported(filename: String) { 155 | val toDelete = BucketFile( 156 | fileName = filename, 157 | address = Bucket(path = "/") 158 | ).file(root = bucketImportPath) 159 | toDelete.delete() 160 | } 161 | 162 | override fun loadFileAsResource(bucketFile: BucketFile): Resource { 163 | try { 164 | val file = bucketFile.file(bucketRootPath) 165 | val resource = UrlResource(file.toURI()) 166 | 167 | return resource.takeIf { it.exists() } ?: throw AttachmentNotFoundException("file not found ${file.name}") 168 | } catch (e: Exception) { 169 | throw AttachmentFinderException("failed to load file ${bucketFile.fileName}", e) 170 | } 171 | } 172 | 173 | private fun createBucketIfNotExists(path: String) { 174 | createBucketIfNotExists(File(path)) 175 | } 176 | 177 | private fun createBucketIfNotExists(file: File) { 178 | file.takeIf { !it.exists() }?.also { 179 | logger.info("bucket $it not exists, creating...") 180 | if (it.mkdirs()) { 181 | logger.info("bucket $it created") 182 | } else { 183 | throw BucketServiceException(message = "check if the user has permission to write to chatvault directories: $file. You can change the app.bucket.root environment variable for the location of ChatVault files ", null) 184 | } 185 | } 186 | } 187 | 188 | 189 | } 190 | 191 | 192 | fun File.getDirectoriesWithContentAndZipFiles(): Array { 193 | return this.listFilesName { 194 | it.isDirectory && (it.list() ?: emptyArray()).isNotEmpty() || 195 | it.name.endsWith(".zip") 196 | } 197 | } 198 | 199 | fun File.listFilesName(filter: FileFilter? = null): Array = this.listFiles(filter) ?: emptyArray() -------------------------------------------------------------------------------- /backend/infra/app-service/src/main/kotlin/dev/marcal/chatvault/app_service/bucket_service/DirectoryZipper.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.app_service.bucket_service 2 | 3 | import org.springframework.core.io.UrlResource 4 | import org.springframework.util.StreamUtils 5 | import java.io.File 6 | import java.io.FileInputStream 7 | import java.io.FileOutputStream 8 | import java.io.IOException 9 | import java.util.zip.ZipEntry 10 | import java.util.zip.ZipOutputStream 11 | 12 | object DirectoryZipper { 13 | 14 | fun zip(directory: File, targetDir: String, saveInSameBaseDir: Boolean = true): UrlResource { 15 | val zipFileName: String = "${directory.name}.zip" 16 | val zipFile = File(targetDir, zipFileName) 17 | 18 | try { 19 | FileOutputStream(zipFile).use { fos -> 20 | ZipOutputStream(fos).use { zipOut -> 21 | zipDirectory(directory, directory.name, zipOut, saveInSameBaseDir) 22 | } 23 | } 24 | return UrlResource(zipFile.toURI()) 25 | } catch (e: IOException) { 26 | throw RuntimeException("Failed to zip file $zipFileName", e) 27 | } 28 | } 29 | 30 | private fun zipDirectory(sourceDir: File, baseDirName: String, zipOut: ZipOutputStream, saveInSameBaseDir: Boolean = true) { 31 | val files = sourceDir.listFiles() ?: return 32 | 33 | for (file in files) { 34 | val baseDir = if (saveInSameBaseDir) file.name else "$baseDirName/${file.name}" 35 | if (file.isDirectory) { 36 | zipDirectory(file, baseDir, zipOut, saveInSameBaseDir) 37 | } else { 38 | FileInputStream(file).use { fis -> 39 | val zipEntry = ZipEntry(baseDir) 40 | zipOut.putNextEntry(zipEntry) 41 | StreamUtils.copy(fis, zipOut) 42 | zipOut.closeEntry() 43 | } 44 | } 45 | } 46 | } 47 | 48 | fun zipAndDeleteSource(directory: File): UrlResource { 49 | return zip(directory, directory.parent, saveInSameBaseDir = true).also { _ -> 50 | directory.listFiles()?.forEach { it.deleteRecursively() } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /backend/infra/app-service/src/main/resources/appservice.properties: -------------------------------------------------------------------------------- 1 | chatvault.bucket.root=/opt/chatvault/archive 2 | chatvault.bucket.import=/opt/chatvault/import 3 | chatvault.bucket.export=/opt/chatvault/export -------------------------------------------------------------------------------- /backend/infra/email/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation project(":backend:service") 3 | implementation project(":backend:in-out-boundary") 4 | 5 | 6 | implementation "org.springframework.integration:spring-integration-mail" 7 | implementation("org.eclipse.angus:jakarta.mail:2.0.2") 8 | } -------------------------------------------------------------------------------- /backend/infra/email/src/main/kotlin/dev/marcal/chatvault/email/config/EmailHandlerConfig.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.email.config 2 | 3 | import org.slf4j.LoggerFactory 4 | import org.springframework.beans.factory.annotation.Value 5 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | import org.springframework.context.annotation.PropertySource 9 | import org.springframework.integration.dsl.IntegrationFlow 10 | import org.springframework.integration.dsl.MessageChannels 11 | import org.springframework.integration.dsl.PollerFactory 12 | import org.springframework.integration.dsl.SourcePollingChannelAdapterSpec 13 | import org.springframework.integration.mail.dsl.Mail 14 | import org.springframework.integration.support.PropertiesBuilder 15 | 16 | 17 | @Configuration 18 | @PropertySource("classpath:email.properties") 19 | @ConditionalOnProperty(prefix = "chatvault.email", name = ["enabled"], havingValue = "true", matchIfMissing = true) 20 | class EmailHandlerConfig( 21 | @Value("\${chatvault.email.host}") private val host: String, 22 | @Value("\${chatvault.email.port}") private val port: String, 23 | @Value("\${chatvault.email.username}") private val username: String, 24 | @Value("\${chatvault.email.password}") private val password: String, 25 | @Value("\${chatvault.email.fixed-delay-mlss}") private val fixedDelay: Long, 26 | @Value("\${chatvault.email.debug}") private val emailDebug: Boolean, 27 | @Value("\${chatvault.email.subject-starts-with}") private val subjectStartsWithList: List, 28 | ) { 29 | 30 | private val logger = LoggerFactory.getLogger(this::class.java) 31 | 32 | @Bean 33 | fun imapMailFlow(): IntegrationFlow { 34 | logger.info("creating imapMailFlow integration flow receive emails") 35 | return IntegrationFlow 36 | .from(Mail.imapInboundAdapter("imap://${username}:${password}@${host}:${port}/INBOX") 37 | .selector { mimeMessage -> 38 | subjectStartsWithList.any { mimeMessage.subject.startsWith(it, ignoreCase = true) } 39 | } 40 | .userFlag("chat-vault") 41 | .autoCloseFolder(false) 42 | .javaMailProperties { p: PropertiesBuilder -> 43 | p.put("mail.debug", emailDebug) 44 | p.put("mail.imap.socketFactory.class", "javax.net.ssl.SSLSocketFactory") 45 | p.put("mail.store.protocol", "imap") 46 | p.put("mail.imap.socketFactory.fallback", "false") 47 | } 48 | ) { e: SourcePollingChannelAdapterSpec -> 49 | e.autoStartup(true) 50 | .poller { p: PollerFactory -> p.fixedDelay(fixedDelay) } 51 | } 52 | .channel(MessageChannels.queue("imapChannel")) 53 | .get() 54 | } 55 | 56 | } -------------------------------------------------------------------------------- /backend/infra/email/src/main/kotlin/dev/marcal/chatvault/email/listener/EmailMessageHandler.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.email.listener 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.PendingChatFile 4 | import dev.marcal.chatvault.service.BucketDiskImporter 5 | import dev.marcal.chatvault.service.ChatFileImporter 6 | import jakarta.mail.Folder 7 | import jakarta.mail.Multipart 8 | import jakarta.mail.internet.MimeMessage 9 | import org.slf4j.LoggerFactory 10 | import org.springframework.beans.factory.annotation.Value 11 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 12 | import org.springframework.integration.annotation.ServiceActivator 13 | import org.springframework.messaging.Message 14 | import org.springframework.stereotype.Component 15 | import java.io.InputStream 16 | import java.time.LocalDateTime 17 | 18 | @Component 19 | @ConditionalOnProperty(prefix = "chatvault.email", name = ["enabled"], havingValue = "true", matchIfMissing = true) 20 | class EmailMessageHandler( 21 | private val chatFileImporter: ChatFileImporter, 22 | @Value("\${chatvault.email.subject-starts-with}") private val subjectStartsWithList: List, 23 | @Value("\${chatvault.email.debug}") private val emailDebug: Boolean, 24 | private val bucketDiskImporter: BucketDiskImporter 25 | ) { 26 | 27 | private val logger = LoggerFactory.getLogger(this.javaClass) 28 | 29 | @ServiceActivator(inputChannel = "imapChannel") 30 | fun handleMessage(message: Message) { 31 | val mimeMessage = message.payload.also { 32 | if (emailDebug) logger.info("Starting to process new email message ${it.subject}") 33 | } 34 | 35 | useFolder(mimeMessage) { 36 | if (mimeMessage.isMimeType("multipart/*")) { 37 | val multipart = mimeMessage.content as Multipart 38 | 39 | val chatName = getChatName(mimeMessage) 40 | val pendingList = getInputStream(chatName, multipart) 41 | bucketDiskImporter.apply { 42 | this.saveToImportDir(pendingList) 43 | this.execute(chatName) 44 | } 45 | } 46 | } 47 | } 48 | 49 | fun useFolder(mimeMessage: MimeMessage, doIt: (MimeMessage) -> Unit) { 50 | val folder: Folder = mimeMessage.folder 51 | try { 52 | if (!folder.isOpen) { 53 | folder.open(Folder.READ_ONLY) 54 | } 55 | doIt(mimeMessage) 56 | } catch (e: Exception) { 57 | logger.error("Fail to handle message ${mimeMessage.subject}", e) 58 | throw e 59 | } finally { 60 | folder.close(false) 61 | } 62 | 63 | } 64 | 65 | private fun getInputStream(chatName: String, multipart: Multipart): List { 66 | val indexStart = 1 // because 0 is the body text (ignored) 67 | return if (multipart.count == 2) { 68 | // only one file (body email text and attachment), body text is ignored 69 | val bodyPart = multipart.getBodyPart(indexStart) 70 | listOf( 71 | PendingChatFile( 72 | stream = bodyPart.inputStream, 73 | fileName = bodyPart.fileName, 74 | chatName = chatName 75 | ) 76 | ) 77 | } else { 78 | //many files 79 | (indexStart until multipart.count) 80 | .asSequence() 81 | .map { multipart.getBodyPart(it) } 82 | .map { 83 | PendingChatFile( 84 | stream = it.inputStream, 85 | fileName = it.fileName, 86 | chatName = chatName 87 | ) 88 | } 89 | .toList() 90 | } 91 | } 92 | 93 | private fun getChatName(mimeMessage: MimeMessage): String { 94 | return getChatNameOrNull(mimeMessage) ?: ("todo ${mimeMessage.subject} ${LocalDateTime.now()}") 95 | } 96 | 97 | private fun getChatNameOrNull(mimeMessage: MimeMessage): String? { 98 | val subjectStartsWith = subjectStartsWithList.first { mimeMessage.subject.startsWith(it, ignoreCase = true) } 99 | val chatName = Regex("(?i)($subjectStartsWith)(.*)").find(mimeMessage.subject)?.let { 100 | it.groupValues[2] 101 | } 102 | return chatName?.trim() 103 | } 104 | } -------------------------------------------------------------------------------- /backend/infra/email/src/main/resources/email.properties: -------------------------------------------------------------------------------- 1 | chatvault.email.host= 2 | chatvault.email.port= 3 | chatvault.email.username= 4 | chatvault.email.password= 5 | chatvault.email.fixed-delay-mlss=10000 6 | chatvault.email.subject-starts-with=chat-vault,Conversa do WhatsApp com 7 | chatvault.email.enabled=false 8 | chatvault.email.debug=false -------------------------------------------------------------------------------- /backend/infra/persistence/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation project(":backend:domain") 3 | implementation project(":backend:in-out-boundary") 4 | } -------------------------------------------------------------------------------- /backend/infra/persistence/src/main/kotlin/dev/marcal/chatvault/persistence/ChatRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.persistence 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import dev.marcal.chatvault.in_out_boundary.output.AttachmentInfoOutput 5 | import dev.marcal.chatvault.in_out_boundary.output.MessageOutput 6 | import dev.marcal.chatvault.model.* 7 | import dev.marcal.chatvault.persistence.dto.toChatLastMessage 8 | import dev.marcal.chatvault.persistence.entity.* 9 | import dev.marcal.chatvault.persistence.repository.ChatCrudRepository 10 | import dev.marcal.chatvault.persistence.repository.EventSourceCrudRepository 11 | import dev.marcal.chatvault.persistence.repository.MessageCrudRepository 12 | import dev.marcal.chatvault.repository.ChatRepository 13 | import org.slf4j.LoggerFactory 14 | import org.springframework.data.domain.PageRequest 15 | import org.springframework.data.domain.Pageable 16 | import org.springframework.data.domain.Sort 17 | import org.springframework.stereotype.Service 18 | import org.springframework.transaction.annotation.Transactional 19 | import kotlin.jvm.optionals.getOrNull 20 | 21 | @Service 22 | class ChatRepositoryImpl( 23 | private val chatCrudRepository: ChatCrudRepository, 24 | private val messageCrudRepository: MessageCrudRepository, 25 | private val eventSourceCrudRepository: EventSourceCrudRepository, 26 | private val objectMapper: ObjectMapper 27 | ) : ChatRepository { 28 | 29 | private val logger = LoggerFactory.getLogger(this.javaClass) 30 | 31 | @Transactional 32 | override fun saveNewMessages(payload: MessagePayload, eventSource: Boolean) { 33 | if (eventSource) { 34 | saveNewMessageEventSource(payload) 35 | return 36 | } 37 | val messagesToSave = payload.toMessagesEntity() 38 | messageCrudRepository.saveAll(messagesToSave) 39 | } 40 | 41 | private fun saveNewMessageEventSource(payload: MessagePayload) { 42 | val messagesToSave = payload.toEventSourceEntity(objectMapper) 43 | eventSourceCrudRepository.saveAll(messagesToSave) 44 | } 45 | 46 | override fun findLegacyToImport(chatId: Long, page: Int, size: Int): Page { 47 | return eventSourceCrudRepository.findLegacyMessageNotImportedByChatId( 48 | chatId, 49 | PageRequest.of(page - 1, size, Sort.by(Sort.Order.asc("externalId"))) 50 | ) 51 | .let { pageRequest -> 52 | Page( 53 | data = pageRequest.map { it.toMessage(objectMapper) }.toList(), 54 | page = page, 55 | totalPages = pageRequest.totalPages, 56 | items = size, 57 | totalItems = pageRequest.totalElements 58 | ) 59 | } 60 | 61 | } 62 | 63 | override fun findAttachmentLegacyToImport(chatId: Long, page: Int, size: Int): Page { 64 | return eventSourceCrudRepository.findLegacyAttachmentNotImportedByChatId( 65 | chatId, 66 | PageRequest.of(page - 1, size, Sort.by(Sort.Order.asc("externalId"))) 67 | ) 68 | .let { pageRequest -> 69 | Page( 70 | data = pageRequest.map { it.toMessage(objectMapper) }.toList(), 71 | page = page, 72 | totalPages = pageRequest.totalPages, 73 | items = size, 74 | totalItems = pageRequest.totalElements 75 | ) 76 | } 77 | 78 | } 79 | 80 | override fun findAllEventSourceChatId(): List { 81 | return eventSourceCrudRepository.findAllChatId() 82 | } 83 | 84 | @Transactional 85 | override fun saveLegacyMessage(messagePayload: MessagePayload) { 86 | saveNewMessages(messagePayload, eventSource = false) 87 | messagePayload.messages 88 | .map { requireNotNull(it.externalId) } 89 | .forEach { eventSourceCrudRepository.setImportedTrue(it) } 90 | 91 | } 92 | 93 | override fun setLegacyAttachmentImported(messageExternalId: String) { 94 | eventSourceCrudRepository.setAttachmentImportedTrue(messageExternalId) 95 | } 96 | 97 | override fun findAllChatsWithLastMessage(): Sequence { 98 | return chatCrudRepository.findAllChatsWithLastMessage().asSequence().map { it.toChatLastMessage() } 99 | } 100 | 101 | override fun findMessagesBy(chatId: Long, query: String?, pageable: Pageable): org.springframework.data.domain.Page { 102 | return messageCrudRepository.findAllByChatIdIs( 103 | chatId = chatId, 104 | query = query ?: "", 105 | pageable = pageable 106 | ).map { objectMapper.convertValue(it, MessageOutput::class.java) } 107 | } 108 | 109 | override fun findAttachmentMessageIdsByChatId(chatId: Long): Sequence { 110 | return messageCrudRepository.findMessageIdByChatIdAndAttachmentExists(chatId) 111 | .asSequence() 112 | .map { AttachmentInfoOutput(id = it.messageId, name = it.name) } 113 | } 114 | 115 | override fun findLastMessageByChatId(chatId: Long): Message? { 116 | return messageCrudRepository.findTopByChatIdOrderByIdDesc(chatId)?.toMessageDomain() 117 | } 118 | 119 | @Transactional 120 | override fun deleteChat(chatId: Long) { 121 | logger.info("start to remove messages from $chatId") 122 | messageCrudRepository.deleteAllByChatId(chatId) 123 | logger.info("start to remove chat from $chatId") 124 | chatCrudRepository.deleteById(chatId) 125 | } 126 | 127 | override fun countChatMessages(chatId: Long): Long { 128 | return messageCrudRepository.countByChatId(chatId) 129 | } 130 | 131 | @Transactional 132 | override fun setChatNameByChatId(chatId: Long, chatName: String) { 133 | chatCrudRepository.updateChatNameByChatId(chatName = chatName, chatId = chatId) 134 | } 135 | 136 | override fun findMessageBy(chatId: Long, messageId: Long): Message? { 137 | return messageCrudRepository.findMessageEntityByIdAndChatId(id = messageId, chatId = chatId)?.toMessageDomain() 138 | } 139 | 140 | override fun findChatBucketInfoByChatName(chatName: String): ChatBucketInfo? { 141 | return chatCrudRepository.findByName(chatName)?.toChatBucketInfo() 142 | } 143 | 144 | override fun create(payload: ChatPayload): ChatBucketInfo { 145 | return chatCrudRepository.save(payload.toChatEntity()).toChatBucketInfo() 146 | } 147 | 148 | override fun findChatBucketInfoByChatId(chatId: Long): ChatBucketInfo? { 149 | return chatCrudRepository.findById(chatId).getOrNull()?.toChatBucketInfo() 150 | } 151 | 152 | override fun existsByExternalId(externalId: String): Boolean { 153 | return chatCrudRepository.existsByExternalId(externalId) 154 | } 155 | 156 | override fun existsByChatId(chatId: Long): Boolean { 157 | return chatCrudRepository.existsById(chatId) 158 | } 159 | 160 | override fun findChatBucketInfoByExternalId(externalId: String): ChatBucketInfo { 161 | return chatCrudRepository.findByExternalId(externalId).toChatBucketInfo() 162 | } 163 | } -------------------------------------------------------------------------------- /backend/infra/persistence/src/main/kotlin/dev/marcal/chatvault/persistence/config/PostgresConfig.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.persistence.config 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import org.springframework.context.annotation.PropertySource 5 | 6 | @Configuration 7 | @PropertySource("classpath:persistence.properties") 8 | class PostgresConfig { 9 | } -------------------------------------------------------------------------------- /backend/infra/persistence/src/main/kotlin/dev/marcal/chatvault/persistence/dto/AttachmentInfoDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.persistence.dto 2 | 3 | data class AttachmentInfoDTO( 4 | val messageId: Long, 5 | val name: String 6 | ) 7 | -------------------------------------------------------------------------------- /backend/infra/persistence/src/main/kotlin/dev/marcal/chatvault/persistence/dto/ChatMessagePairDTO.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.persistence.dto 2 | import dev.marcal.chatvault.persistence.entity.ChatEntity 3 | import dev.marcal.chatvault.persistence.entity.MessageEntity 4 | 5 | data class ChatMessagePairDTO( 6 | val chat: ChatEntity, 7 | val message: MessageEntity, 8 | val amountOfMessages: Long 9 | ) 10 | -------------------------------------------------------------------------------- /backend/infra/persistence/src/main/kotlin/dev/marcal/chatvault/persistence/dto/Mapper.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.persistence.dto 2 | 3 | import dev.marcal.chatvault.model.ChatLastMessage 4 | import dev.marcal.chatvault.persistence.entity.toAuthorDomain 5 | 6 | fun ChatMessagePairDTO.toChatLastMessage(): ChatLastMessage { 7 | return ChatLastMessage( 8 | chatId = requireNotNull(this.chat.id), 9 | chatName = this.chat.name, 10 | author = this.message.toAuthorDomain(), 11 | content = this.message.content, 12 | msgCreatedAt = this.message.createdAt, 13 | msgCount = this.amountOfMessages 14 | ) 15 | } -------------------------------------------------------------------------------- /backend/infra/persistence/src/main/kotlin/dev/marcal/chatvault/persistence/entity/ChatEntity.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.persistence.entity 2 | 3 | import jakarta.persistence.* 4 | import org.hibernate.annotations.ColumnTransformer 5 | import java.time.LocalDateTime 6 | 7 | @Entity 8 | @Table( 9 | name = "chat", uniqueConstraints = [ 10 | UniqueConstraint(name = "chat_external_id_key", columnNames = ["externalId"]) 11 | ] 12 | ) 13 | data class ChatEntity( 14 | @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, 15 | val name: String, 16 | val externalId: String? = null, 17 | val bucket: String 18 | ) 19 | 20 | @Entity 21 | @Table( 22 | name = "message", uniqueConstraints = [ 23 | UniqueConstraint(name = "message_external_id_key", columnNames = ["externalId"]) 24 | ] 25 | ) 26 | data class MessageEntity( 27 | @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, 28 | val author: String, 29 | val authorType: String, 30 | val createdAt: LocalDateTime, 31 | @Column(columnDefinition = "TEXT") val content: String, 32 | val attachmentPath: String?, 33 | val attachmentName: String?, 34 | val chatId: Long, 35 | val externalId: String? = null, 36 | @ManyToOne(fetch = FetchType.LAZY) 37 | @JoinColumn(name = "chatId", insertable = false, updatable = false) 38 | val chat: ChatEntity? = null 39 | ) 40 | 41 | 42 | @Entity 43 | @Table( 44 | name = "event_source", uniqueConstraints = [ 45 | UniqueConstraint(name = "event_source_external_id_key", columnNames = ["externalId"]) 46 | ] 47 | ) 48 | data class EventSourceEntity( 49 | @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, 50 | val chatId: Long, 51 | val externalId: String? = null, 52 | val messageImported: Boolean = false, 53 | val attachmentImported: Boolean? = null, 54 | val hasAttachment: Boolean = false, 55 | @Column(columnDefinition = "jsonb") @ColumnTransformer(write = "?::jsonb") val payload: String, 56 | val createdAt: LocalDateTime 57 | ) -------------------------------------------------------------------------------- /backend/infra/persistence/src/main/kotlin/dev/marcal/chatvault/persistence/entity/Mapper.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.persistence.entity 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import dev.marcal.chatvault.model.* 5 | 6 | 7 | fun MessagePayload.toMessagesEntity(): List { 8 | return this.messages.map { 9 | MessageEntity( 10 | author = it.author.name, 11 | authorType = it.author.type.name, 12 | content = it.content.text, 13 | attachmentPath = it.content.attachment?.bucket?.path, 14 | attachmentName = it.content.attachment?.name, 15 | chatId = this.chatId, 16 | externalId = it.externalId, 17 | createdAt = it.createdAt 18 | ) 19 | } 20 | } 21 | 22 | fun MessagePayload.toEventSourceEntity(objectMapper: ObjectMapper): List { 23 | return this.messages.map { 24 | val hasAttachment = it.content.attachment != null 25 | EventSourceEntity( 26 | chatId = this.chatId, 27 | externalId = it.externalId, 28 | createdAt = it.createdAt, 29 | messageImported = false, 30 | attachmentImported = if (hasAttachment) false else null, 31 | hasAttachment = hasAttachment, 32 | payload = objectMapper.writeValueAsString(it) 33 | ) 34 | } 35 | } 36 | 37 | fun EventSourceEntity.toMessage(objectMapper: ObjectMapper): Message { 38 | return objectMapper.readValue(this.payload, Message::class.java) 39 | } 40 | 41 | fun ChatPayload.toChatEntity(): ChatEntity { 42 | return ChatEntity( 43 | name = this.name, 44 | externalId = this.externalId, 45 | bucket = this.bucket.path 46 | ) 47 | } 48 | 49 | fun MessageEntity.toMessageDomain() = Message( 50 | author = this.toAuthorDomain(), 51 | createdAt = this.createdAt, 52 | externalId = this.externalId, 53 | content = this.toContentDomain() 54 | ) 55 | 56 | fun MessageEntity.toAuthorDomain() = Author(name = this.author, type = AuthorType.valueOf(this.authorType)) 57 | 58 | fun MessageEntity.toContentDomain() = Content(text = this.content, attachment = this.toAttachmentDomain()) 59 | 60 | fun MessageEntity.toAttachmentDomain() = this.attachmentName?.let { 61 | Attachment(name = it, bucket = this.toBucketDomain()) 62 | } 63 | 64 | fun MessageEntity.toBucketDomain() = Bucket(path = requireNotNull(this.attachmentPath)) 65 | 66 | fun ChatEntity.toChatBucketInfo() = ChatBucketInfo(chatId = this.id!!, Bucket(this.bucket)) -------------------------------------------------------------------------------- /backend/infra/persistence/src/main/kotlin/dev/marcal/chatvault/persistence/repository/ChatCrudRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.persistence.repository 2 | 3 | import dev.marcal.chatvault.persistence.dto.ChatMessagePairDTO 4 | import dev.marcal.chatvault.persistence.entity.ChatEntity 5 | import org.springframework.data.jpa.repository.JpaRepository 6 | import org.springframework.data.jpa.repository.Modifying 7 | import org.springframework.data.jpa.repository.Query 8 | import org.springframework.data.repository.query.Param 9 | import org.springframework.stereotype.Repository 10 | 11 | @Repository 12 | interface ChatCrudRepository: JpaRepository { 13 | 14 | fun existsByExternalId(externalId: String): Boolean 15 | 16 | fun findByExternalId(externalId: String): ChatEntity 17 | 18 | fun findByName(chatName: String): ChatEntity? 19 | 20 | 21 | @Query( 22 | """ 23 | SELECT new dev.marcal.chatvault.persistence.dto.ChatMessagePairDTO(c, m, (SELECT COUNT(*) FROM MessageEntity me WHERE me.chatId = c.id) ) 24 | FROM ChatEntity c 25 | JOIN MessageEntity m ON c.id = m.chatId AND m.id = (SELECT MAX(m2.id) FROM MessageEntity m2 WHERE m2.chatId = c.id) 26 | ORDER BY m.createdAt desc 27 | """ 28 | ) 29 | fun findAllChatsWithLastMessage(): List 30 | 31 | 32 | @Modifying 33 | @Query("update ChatEntity c set c.name = :chatName where c.id = :chatId") 34 | fun updateChatNameByChatId(@Param("chatName") chatName: String, @Param("chatId") chatId: Long) 35 | 36 | } -------------------------------------------------------------------------------- /backend/infra/persistence/src/main/kotlin/dev/marcal/chatvault/persistence/repository/EventSourceCrudRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.persistence.repository 2 | 3 | import dev.marcal.chatvault.persistence.entity.EventSourceEntity 4 | import org.springframework.data.domain.Page 5 | import org.springframework.data.domain.Pageable 6 | import org.springframework.data.jpa.repository.JpaRepository 7 | import org.springframework.data.jpa.repository.Modifying 8 | import org.springframework.data.jpa.repository.Query 9 | import org.springframework.data.repository.query.Param 10 | import org.springframework.transaction.annotation.Transactional 11 | 12 | interface EventSourceCrudRepository: JpaRepository { 13 | 14 | @Query( 15 | """ 16 | SELECT e FROM EventSourceEntity e 17 | WHERE 0=0 18 | AND e.chatId = :chatId 19 | AND e.messageImported <> true 20 | AND e.externalId is not null 21 | """ 22 | ) 23 | fun findLegacyMessageNotImportedByChatId(@Param("chatId") chatId: Long, pageable: Pageable): Page 24 | 25 | @Query( 26 | """ 27 | SELECT e FROM EventSourceEntity e 28 | WHERE 0=0 29 | AND e.chatId = :chatId 30 | AND e.attachmentImported = false 31 | AND e.externalId is not null 32 | """ 33 | ) 34 | fun findLegacyAttachmentNotImportedByChatId(@Param("chatId") chatId: Long, pageable: Pageable): Page 35 | 36 | @Query( 37 | """ 38 | SELECT DISTINCT e.chatId FROM EventSourceEntity e 39 | """ 40 | ) 41 | fun findAllChatId(): List 42 | 43 | @Modifying 44 | @Transactional 45 | @Query( 46 | """ 47 | UPDATE EventSourceEntity e 48 | SET e.messageImported = true 49 | WHERE e.externalId = :externalId 50 | """ 51 | ) 52 | fun setImportedTrue(@Param("externalId") externalId: String) 53 | 54 | @Modifying 55 | @Transactional 56 | @Query( 57 | """ 58 | UPDATE EventSourceEntity e 59 | SET e.attachmentImported = true 60 | WHERE e.externalId = :externalId 61 | """ 62 | ) 63 | fun setAttachmentImportedTrue(@Param("externalId") externalId: String) 64 | } -------------------------------------------------------------------------------- /backend/infra/persistence/src/main/kotlin/dev/marcal/chatvault/persistence/repository/MessageCrudRepository.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.persistence.repository 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.MessageOutput 4 | import dev.marcal.chatvault.persistence.dto.AttachmentInfoDTO 5 | import dev.marcal.chatvault.persistence.entity.MessageEntity 6 | import org.springframework.data.domain.Page 7 | import org.springframework.data.domain.Pageable 8 | import org.springframework.data.jpa.repository.JpaRepository 9 | import org.springframework.data.jpa.repository.Query 10 | import org.springframework.data.repository.query.Param 11 | 12 | interface MessageCrudRepository : JpaRepository { 13 | 14 | fun countByChatId(chatId: Long): Long 15 | 16 | @Query(""" 17 | SELECT m FROM MessageEntity m 18 | WHERE 0=0 19 | AND m.chatId = :chatId 20 | AND (:query = '' 21 | OR ((LOWER(m.author) LIKE LOWER(CONCAT('%', :query, '%')) 22 | OR LOWER(m.content) LIKE LOWER(CONCAT('%', :query, '%'))))) 23 | """) 24 | fun findAllByChatIdIs( 25 | @Param("query") query: String, 26 | @Param("chatId") chatId: Long, 27 | pageable: Pageable 28 | ): Page 29 | 30 | 31 | fun findMessageEntityByIdAndChatId(id: Long, chatId: Long): MessageEntity? 32 | 33 | fun findTopByChatIdOrderByIdDesc(chatId: Long): MessageEntity? 34 | 35 | @Query("SELECT new dev.marcal.chatvault.persistence.dto.AttachmentInfoDTO(m.id, m.attachmentName) FROM MessageEntity m WHERE m.chatId = :chatId AND m.attachmentPath IS NOT NULL") 36 | fun findMessageIdByChatIdAndAttachmentExists(chatId: Long): List 37 | 38 | fun deleteAllByChatId(chatId: Long) 39 | } -------------------------------------------------------------------------------- /backend/infra/persistence/src/main/resources/persistence.properties: -------------------------------------------------------------------------------- 1 | spring.datasource.url=jdbc:postgresql://localhost:5432/chatvault 2 | spring.datasource.username=usr_chatvault 3 | spring.datasource.password=secret 4 | spring.datasource.driver-class-name=org.postgresql.Driver 5 | spring.jpa.hibernate.ddl-auto=update 6 | -------------------------------------------------------------------------------- /backend/infra/web/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation project(":backend:service") 3 | implementation project(":backend:in-out-boundary") 4 | } -------------------------------------------------------------------------------- /backend/infra/web/src/main/kotlin/dev/marcal/chatvault/web/ChatController.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.web 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.AttachmentCriteriaInput 4 | import dev.marcal.chatvault.in_out_boundary.output.AttachmentInfoOutput 5 | import dev.marcal.chatvault.in_out_boundary.output.ChatLastMessageOutput 6 | import dev.marcal.chatvault.in_out_boundary.output.MessageOutput 7 | import dev.marcal.chatvault.service.* 8 | import org.springframework.core.io.Resource 9 | import org.springframework.data.domain.Page 10 | import org.springframework.data.domain.Pageable 11 | import org.springframework.data.domain.Sort 12 | import org.springframework.data.web.SortDefault 13 | import org.springframework.http.* 14 | import org.springframework.web.bind.annotation.* 15 | import org.springframework.web.multipart.MultipartFile 16 | import java.util.concurrent.TimeUnit 17 | 18 | @RestController 19 | @RequestMapping("/api/chats") 20 | class ChatController( 21 | private val chatLister: ChatLister, 22 | private val messageFinderByChatId: MessageFinderByChatId, 23 | private val attachmentFinder: AttachmentFinder, 24 | private val chatNameUpdater: ChatNameUpdater, 25 | private val attachmentInfoFinderByChatId: AttachmentInfoFinderByChatId, 26 | private val profileImageManager: ProfileImageManager, 27 | private val chatDeleter: ChatDeleter 28 | ) { 29 | 30 | @GetMapping 31 | fun listChats(): List { 32 | return chatLister.execute() 33 | } 34 | 35 | @GetMapping("{chatId}") 36 | fun findChatMessages( 37 | @PathVariable("chatId") chatId: Long, 38 | @RequestParam("query", required = false) query: String? = null, 39 | @SortDefault( 40 | sort = ["createdAt", "id"], 41 | direction = Sort.Direction.DESC 42 | ) pageable: Pageable 43 | ): Page { 44 | return messageFinderByChatId.execute( 45 | chatId = chatId, 46 | query = query, 47 | pageable = pageable 48 | ) 49 | } 50 | 51 | @DeleteMapping("{chatId}") 52 | fun deleteChatAndAssets( 53 | @PathVariable("chatId") chatId: Long, 54 | ) { 55 | return chatDeleter.execute(chatId) 56 | 57 | } 58 | 59 | @PatchMapping("{chatId}/chatName/{chatName}") 60 | fun update( 61 | @PathVariable chatId: Long, 62 | @PathVariable chatName: String, 63 | ): ResponseEntity { 64 | chatNameUpdater.execute(chatId, chatName) 65 | return ResponseEntity.noContent().build() 66 | } 67 | 68 | @GetMapping("{chatId}/messages/{messageId}/attachment") 69 | fun downloadAttachment( 70 | @PathVariable("chatId") chatId: Long, 71 | @PathVariable("messageId") messageId: Long 72 | ): ResponseEntity { 73 | val resource = attachmentFinder.execute( 74 | AttachmentCriteriaInput( 75 | chatId = chatId, 76 | messageId = messageId 77 | ) 78 | ) 79 | 80 | val cacheControl = CacheControl.maxAge(1, TimeUnit.DAYS) 81 | 82 | return ResponseEntity.ok() 83 | .contentType(resource.getMediaType()) 84 | .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"${resource.filename}\"") 85 | .cacheControl(cacheControl) 86 | .body(resource) 87 | } 88 | 89 | @GetMapping("{chatId}/attachments") 90 | fun downloadAttachment( 91 | @PathVariable("chatId") chatId: Long 92 | ): ResponseEntity> { 93 | val cacheControl = CacheControl.maxAge(5, TimeUnit.MINUTES) 94 | 95 | return ResponseEntity.ok() 96 | .cacheControl(cacheControl) 97 | .body(attachmentInfoFinderByChatId.execute(chatId)) 98 | } 99 | 100 | @PostMapping("{chatId}/profile-image") 101 | fun putProfileImage( 102 | @PathVariable("chatId") chatId: Long, 103 | @RequestParam("profile-image") file: MultipartFile 104 | ): ResponseEntity { 105 | profileImageManager.updateImage(file.inputStream, chatId) 106 | 107 | return ResponseEntity.noContent().build() 108 | } 109 | 110 | @GetMapping("{chatId}/profile-image") 111 | fun getProfileImage( 112 | @PathVariable("chatId") chatId: Long 113 | ): ResponseEntity { 114 | val image = profileImageManager.getImage(chatId) 115 | 116 | val cacheControl = CacheControl.maxAge(5, TimeUnit.MINUTES) 117 | 118 | return ResponseEntity.ok() 119 | .contentType(image.getMediaType()) 120 | .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"${image.filename}\"") 121 | .cacheControl(cacheControl) 122 | .body(image) 123 | 124 | } 125 | } 126 | 127 | fun Resource.getMediaType(): MediaType { 128 | return MediaTypeFactory.getMediaTypes(this.filename).firstOrNull() 129 | ?: MediaType.APPLICATION_OCTET_STREAM 130 | } -------------------------------------------------------------------------------- /backend/infra/web/src/main/kotlin/dev/marcal/chatvault/web/ChatImportExportController.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.web 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.FileTypeInputEnum 4 | import dev.marcal.chatvault.service.BucketDiskImporter 5 | import dev.marcal.chatvault.service.ChatFileExporter 6 | import dev.marcal.chatvault.service.ChatFileImporter 7 | import org.springframework.core.io.Resource 8 | import org.springframework.http.HttpHeaders 9 | import org.springframework.http.HttpStatus 10 | import org.springframework.http.MediaType 11 | import org.springframework.http.ResponseEntity 12 | import org.springframework.web.bind.annotation.* 13 | import org.springframework.web.multipart.MultipartFile 14 | import org.springframework.web.server.ResponseStatusException 15 | import org.springframework.web.util.HtmlUtils 16 | import java.util.* 17 | 18 | @RestController 19 | @RequestMapping("/api/chats") 20 | class ChatImportExportController( 21 | private val chatFileImporter: ChatFileImporter, 22 | private val bucketDiskImporter: BucketDiskImporter, 23 | private val chatFileExporter: ChatFileExporter, 24 | ) { 25 | 26 | @PostMapping("disk-import") 27 | fun executeDiskImport() { 28 | bucketDiskImporter.execute() 29 | } 30 | 31 | 32 | @PostMapping("{chatId}/messages/import") 33 | fun importFileByChatId( 34 | @PathVariable chatId: Long, 35 | @RequestParam("file") file: MultipartFile 36 | ): ResponseEntity { 37 | return importChat(chatId = chatId, file = file) 38 | } 39 | 40 | @PostMapping("import/{chatName}") 41 | fun importChatByChatName( 42 | @PathVariable chatName: String, 43 | @RequestParam("file") file: MultipartFile 44 | ): ResponseEntity { 45 | return importChat(chatName = chatName, file = file) 46 | } 47 | 48 | @GetMapping("{chatId}/export") 49 | fun importChatByChatName( 50 | @PathVariable chatId: Long, 51 | ): ResponseEntity { 52 | val resource = chatFileExporter.execute(chatId) 53 | val headers = HttpHeaders() 54 | headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=${resource.filename ?: UUID.randomUUID()}.zip") 55 | headers.contentType = MediaType.APPLICATION_OCTET_STREAM 56 | return ResponseEntity.ok() 57 | .headers(headers) 58 | .body(resource) 59 | } 60 | 61 | @GetMapping("/export/all") 62 | fun executeDiskExport(): ResponseEntity { 63 | val resource = chatFileExporter.executeAll() 64 | val headers = HttpHeaders() 65 | headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=${resource.filename ?: UUID.randomUUID()}.zip") 66 | headers.contentType = MediaType.APPLICATION_OCTET_STREAM 67 | return ResponseEntity.ok() 68 | .headers(headers) 69 | .body(resource) 70 | } 71 | 72 | fun importChat(chatId: Long? = null, chatName: String? = null, file: MultipartFile): ResponseEntity { 73 | if (file.isEmpty) { 74 | throw ResponseStatusException(HttpStatus.BAD_REQUEST, "This file cannot be imported. It is empty.") 75 | } 76 | 77 | val fileType = getFileType(file) 78 | 79 | importChat(chatId, file, fileType, chatName) 80 | return ResponseEntity.noContent().build() 81 | } 82 | 83 | 84 | private fun getFileType(file: MultipartFile): FileTypeInputEnum { 85 | val fileType = when (file.contentType) { 86 | null -> throw ResponseStatusException( 87 | HttpStatus.BAD_REQUEST, 88 | "This file cannot be imported. Media type is required." 89 | ) 90 | 91 | "text/plain" -> FileTypeInputEnum.TEXT 92 | "application/zip" -> FileTypeInputEnum.ZIP 93 | else -> throw ResponseStatusException( 94 | HttpStatus.UNSUPPORTED_MEDIA_TYPE, 95 | "This file cannot be imported. Media type not supported ${HtmlUtils.htmlEscape(file.contentType!!)}." 96 | ) 97 | } 98 | return fileType 99 | } 100 | 101 | private fun importChat( 102 | chatId: Long?, 103 | file: MultipartFile, 104 | fileType: FileTypeInputEnum, 105 | chatName: String? 106 | ) { 107 | chatId?.let { 108 | chatFileImporter.execute( 109 | chatId = it, 110 | inputStream = file.inputStream, 111 | fileType = fileType 112 | ) 113 | } ?: run { 114 | chatFileImporter.execute( 115 | chatName = chatName, 116 | inputStream = file.inputStream, 117 | fileType = fileType 118 | ) 119 | } 120 | } 121 | 122 | } -------------------------------------------------------------------------------- /backend/infra/web/src/main/kotlin/dev/marcal/chatvault/web/MessageController.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.web 2 | 3 | import dev.marcal.chatvault.service.MessageCreator 4 | import dev.marcal.chatvault.in_out_boundary.input.NewMessageInput 5 | import org.springframework.http.ResponseEntity 6 | import org.springframework.web.bind.annotation.PostMapping 7 | import org.springframework.web.bind.annotation.RequestBody 8 | import org.springframework.web.bind.annotation.RequestMapping 9 | import org.springframework.web.bind.annotation.RestController 10 | 11 | @RestController 12 | @RequestMapping("/api/messages") 13 | class MessageController( 14 | private val messageCreator: MessageCreator 15 | ) { 16 | 17 | @PostMapping 18 | fun newMessage(@RequestBody input: NewMessageInput): ResponseEntity { 19 | messageCreator.execute(input) 20 | return ResponseEntity.noContent().build() 21 | } 22 | } -------------------------------------------------------------------------------- /backend/infra/web/src/main/kotlin/dev/marcal/chatvault/web/config/WebConfig.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.web.config 2 | 3 | import org.slf4j.LoggerFactory 4 | import org.springframework.beans.factory.annotation.Value 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.context.annotation.PropertySource 8 | import org.springframework.web.servlet.config.annotation.CorsRegistry 9 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 10 | 11 | @PropertySource("classpath:web.properties") 12 | @Configuration 13 | class WebConfig( 14 | @Value("\${chatvault.host}") private val allowedOrigin: List 15 | ) { 16 | 17 | private val logger = LoggerFactory.getLogger(this.javaClass) 18 | 19 | @Bean 20 | fun corsConfigurer(): WebMvcConfigurer { 21 | logger.info("AllowedOrigins: $allowedOrigin") 22 | return object : WebMvcConfigurer { 23 | override fun addCorsMappings(registry: CorsRegistry) { 24 | registry.addMapping("/**") 25 | .allowedMethods("*") 26 | .allowedOrigins(*allowedOrigin.toTypedArray()) 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /backend/infra/web/src/main/kotlin/dev/marcal/chatvault/web/config/WebControllerAdvice.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.web.config 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.* 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.http.HttpStatus 6 | import org.springframework.web.bind.annotation.ControllerAdvice 7 | import org.springframework.web.bind.annotation.ExceptionHandler 8 | import org.springframework.web.context.request.WebRequest 9 | import org.springframework.web.server.ResponseStatusException 10 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler 11 | 12 | @ControllerAdvice 13 | class WebControllerAdvice : ResponseEntityExceptionHandler() { 14 | 15 | private val log = LoggerFactory.getLogger(this.javaClass) 16 | 17 | @ExceptionHandler(value = [AttachmentNotFoundException::class, AttachmentFinderException::class, ChatNotFoundException::class, BucketFileNotFoundException::class]) 18 | fun handleNotFound( 19 | ex: RuntimeException, request: WebRequest 20 | ): ResponseStatusException { 21 | return ResponseStatusException( 22 | HttpStatus.NOT_FOUND, ex.message ?: "Resource was not found", ex 23 | ) 24 | } 25 | 26 | @ExceptionHandler(value = [MessageParserException::class]) 27 | fun handleUnprocessableEntity( 28 | ex: RuntimeException, request: WebRequest 29 | ): ResponseStatusException { 30 | log.error("The request failed due to an unprocessable entity error at", ex) 31 | return ResponseStatusException( 32 | HttpStatus.UNPROCESSABLE_ENTITY, ex.message 33 | ) 34 | } 35 | 36 | @ExceptionHandler(value = [IllegalArgumentException::class, IllegalStateException::class]) 37 | fun handleConflict( 38 | ex: RuntimeException, request: WebRequest 39 | ): ResponseStatusException { 40 | log.error("Invalid state or requirements at", ex) 41 | return ResponseStatusException( 42 | HttpStatus.FAILED_DEPENDENCY, ex.message ?: "Invalid state or requirements", ex 43 | ) 44 | } 45 | 46 | @ExceptionHandler(value = [ChatImporterException::class, BucketServiceException::class]) 47 | fun handleInternalServerError(ex: RuntimeException, request: WebRequest): ResponseStatusException { 48 | log.error("The request failed due to an unexpected error at", ex) 49 | return ResponseStatusException( 50 | HttpStatus.INTERNAL_SERVER_ERROR, 51 | ex.message ?: "The request failed due to an unexpected error. See the server logs for more details.", 52 | ex 53 | ) 54 | } 55 | 56 | } -------------------------------------------------------------------------------- /backend/infra/web/src/main/resources/public/default-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitormarcal/chatvault/e834d31a778849455749aa47a233f1c2cfdc13e3/backend/infra/web/src/main/resources/public/default-avatar.png -------------------------------------------------------------------------------- /backend/infra/web/src/main/resources/web.properties: -------------------------------------------------------------------------------- 1 | spring.servlet.multipart.max-file-size=500MB 2 | spring.servlet.multipart.max-request-size=500MB 3 | spring.web.resources.static-locations=file:../../../frontend/.output/public 4 | chatvault.host=http://localhost:3000,http://localhost:8080 -------------------------------------------------------------------------------- /backend/service/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation project(":backend:in-out-boundary") 3 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/AttachmentFinder.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.AttachmentCriteriaInput 4 | import org.springframework.core.io.Resource 5 | 6 | interface AttachmentFinder { 7 | 8 | fun execute(criteriaInput: AttachmentCriteriaInput): Resource 9 | 10 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/AttachmentInfoFinderByChatId.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.AttachmentInfoOutput 4 | 5 | interface AttachmentInfoFinderByChatId { 6 | fun execute(chatId: Long): Sequence 7 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/BucketDiskImporter.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.PendingChatFile 4 | import java.io.InputStream 5 | 6 | interface BucketDiskImporter { 7 | 8 | fun execute(chatName: String? = null) 9 | 10 | fun saveToImportDir(pendingList: List) 11 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/ChatCreator.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.NewChatInput 4 | import dev.marcal.chatvault.in_out_boundary.output.ChatBucketInfoOutput 5 | 6 | interface ChatCreator { 7 | 8 | fun executeIfNotExists(input: NewChatInput): ChatBucketInfoOutput 9 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/ChatDeleter.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | interface ChatDeleter { 4 | fun execute(chatId: Long) 5 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/ChatFileExporter.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | import org.springframework.core.io.Resource 4 | 5 | interface ChatFileExporter { 6 | 7 | fun execute(chatId: Long): Resource 8 | fun executeAll(): Resource 9 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/ChatFileImporter.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.FileTypeInputEnum 4 | import java.io.InputStream 5 | 6 | interface ChatFileImporter { 7 | fun execute(chatId: Long, inputStream: InputStream, fileType: FileTypeInputEnum) 8 | fun execute(chatName: String?, inputStream: InputStream, fileType: FileTypeInputEnum) 9 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/ChatLister.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.ChatLastMessageOutput 4 | 5 | interface ChatLister { 6 | 7 | fun execute(): List 8 | 9 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/ChatMessageParser.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.MessageOutput 4 | import kotlinx.coroutines.flow.Flow 5 | import java.io.InputStream 6 | 7 | interface ChatMessageParser { 8 | fun parse(inputStream: InputStream): Flow 9 | fun parseToList(inputStream: InputStream): List 10 | fun parseAndTransform(inputStream: InputStream, transformIn: (MessageOutput) -> R): List 11 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/ChatNameUpdater.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | interface ChatNameUpdater { 4 | 5 | fun execute(chatId: Long, chatName: String) 6 | 7 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/MessageCreator.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.NewMessageInput 4 | import dev.marcal.chatvault.in_out_boundary.input.NewMessagePayloadInput 5 | 6 | interface MessageCreator { 7 | fun execute(input: NewMessageInput) 8 | fun execute(input: NewMessagePayloadInput) 9 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/MessageFinderByChatId.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.MessageOutput 4 | import org.springframework.data.domain.Page 5 | import org.springframework.data.domain.Pageable 6 | 7 | interface MessageFinderByChatId { 8 | 9 | fun execute(chatId: Long, query: String?, pageable: Pageable): Page 10 | 11 | fun execute(chatId: Long): Sequence 12 | } -------------------------------------------------------------------------------- /backend/service/src/main/kotlin/dev/marcal/chatvault/service/ProfileImageManager.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.service 2 | 3 | import org.springframework.core.io.Resource 4 | import java.io.InputStream 5 | 6 | interface ProfileImageManager { 7 | fun updateImage(inputStream: InputStream, chatId: Long) 8 | fun getImage(chatId: Long): Resource 9 | } -------------------------------------------------------------------------------- /backend/usecase/build.gradle: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation project(":backend:service") 3 | implementation project(":backend:in-out-boundary") 4 | implementation project(":backend:domain") 5 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/AttachmentFinderUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.app_service.bucket_service.BucketService 4 | import dev.marcal.chatvault.in_out_boundary.input.AttachmentCriteriaInput 5 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.AttachmentNotFoundException 6 | import dev.marcal.chatvault.repository.ChatRepository 7 | import dev.marcal.chatvault.service.AttachmentFinder 8 | import org.springframework.core.io.Resource 9 | import org.springframework.stereotype.Service 10 | 11 | @Service 12 | class AttachmentFinderUseCase( 13 | private val bucketService: BucketService, 14 | private val chatRepository: ChatRepository 15 | ) : AttachmentFinder { 16 | override fun execute(criteriaInput: AttachmentCriteriaInput): Resource { 17 | val message = 18 | chatRepository.findMessageBy(chatId = criteriaInput.chatId, messageId = criteriaInput.messageId) 19 | ?: throw AttachmentNotFoundException() 20 | 21 | val bucketFile = message.content.attachment?.toBucketFile() 22 | ?: throw AttachmentNotFoundException("the message exists but there are no attachments linked to it") 23 | 24 | return bucketService.loadFileAsResource(bucketFile) 25 | 26 | } 27 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/AttachmentInfoFinderByChatIdUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.AttachmentInfoOutput 4 | import dev.marcal.chatvault.repository.ChatRepository 5 | import dev.marcal.chatvault.service.AttachmentInfoFinderByChatId 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class AttachmentInfoFinderByChatIdUseCase( 10 | private val chatRepository: ChatRepository 11 | ) : AttachmentInfoFinderByChatId { 12 | override fun execute(chatId: Long): Sequence { 13 | return chatRepository.findAttachmentMessageIdsByChatId(chatId) 14 | } 15 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/BucketDiskImporterUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.app_service.bucket_service.BucketService 4 | import dev.marcal.chatvault.in_out_boundary.input.FileTypeInputEnum 5 | import dev.marcal.chatvault.in_out_boundary.input.NewChatInput 6 | import dev.marcal.chatvault.in_out_boundary.input.PendingChatFile 7 | import dev.marcal.chatvault.model.Bucket 8 | import dev.marcal.chatvault.model.BucketFile 9 | import dev.marcal.chatvault.model.ChatBucketInfo 10 | import dev.marcal.chatvault.repository.ChatRepository 11 | import dev.marcal.chatvault.service.BucketDiskImporter 12 | import dev.marcal.chatvault.service.ChatCreator 13 | import dev.marcal.chatvault.service.ChatFileImporter 14 | import org.springframework.core.io.Resource 15 | import org.springframework.stereotype.Service 16 | 17 | @Service 18 | class BucketDiskImporterUseCase( 19 | private val bucketService: BucketService, 20 | private val chatRepository: ChatRepository, 21 | private val chatFileImporter: ChatFileImporter, 22 | private val chatCreator: ChatCreator 23 | ) : BucketDiskImporter { 24 | 25 | override fun execute(chatName: String?) { 26 | bucketService.zipPendingImports(chatName) 27 | .map { identifyChat(it) } 28 | .forEach { (chatId, resource) -> 29 | chatFileImporter.execute( 30 | chatId = chatId, 31 | fileType = FileTypeInputEnum.ZIP, 32 | inputStream = resource.inputStream 33 | ) 34 | bucketService.deleteZipImported(resource.filename!!) 35 | } 36 | } 37 | 38 | override fun saveToImportDir(pendingList: List) { 39 | pendingList.map { 40 | BucketFile( 41 | stream = it.stream, 42 | fileName = it.fileName, 43 | address = Bucket(it.chatName) 44 | ) 45 | }.forEach { 46 | bucketService.saveToImportDir(it) 47 | } 48 | } 49 | 50 | private fun identifyChat(resource: Resource): Pair { 51 | val chatName = requireNotNull(resource.filename).removeSuffix(".zip") 52 | val chatBucketInfo = findOrCreateChatIfNotExists(chatName) 53 | return chatBucketInfo.chatId to resource 54 | } 55 | 56 | private fun findOrCreateChatIfNotExists(chatName: String): ChatBucketInfo { 57 | val chatBucketInfo = chatRepository.findChatBucketInfoByChatName(chatName) ?: run { 58 | chatCreator.executeIfNotExists(NewChatInput(name = chatName)) 59 | chatRepository.findChatBucketInfoByChatName(chatName) 60 | } 61 | 62 | return requireNotNull(chatBucketInfo) { "error when finding and trying to create chat not existent $chatName" } 63 | 64 | } 65 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/ChatCreatorUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.model.Bucket 4 | import dev.marcal.chatvault.model.ChatPayload 5 | import dev.marcal.chatvault.repository.ChatRepository 6 | import dev.marcal.chatvault.service.ChatCreator 7 | import dev.marcal.chatvault.in_out_boundary.input.NewChatInput 8 | import dev.marcal.chatvault.in_out_boundary.output.ChatBucketInfoOutput 9 | import dev.marcal.chatvault.usecase.mapper.toOutput 10 | import org.springframework.stereotype.Service 11 | import java.util.UUID 12 | 13 | @Service 14 | class ChatCreatorUseCase( 15 | private val chatRepository: ChatRepository 16 | ): ChatCreator { 17 | override fun executeIfNotExists(input: NewChatInput): ChatBucketInfoOutput { 18 | 19 | if (chatWithExternalIdExists(input.externalId)) { 20 | return chatRepository.findChatBucketInfoByExternalId(input.externalId!!).toOutput() 21 | } 22 | 23 | val chatToSave = buildChat(input) 24 | 25 | return chatRepository.create(chatToSave).toOutput() 26 | 27 | } 28 | 29 | private fun buildChat(input: NewChatInput): ChatPayload { 30 | return ChatPayload( 31 | name = input.name, 32 | externalId = input.externalId, 33 | bucket = Bucket( 34 | path = UUID.randomUUID().toString() 35 | ) 36 | ) 37 | } 38 | 39 | private fun chatWithExternalIdExists(externalId: String?): Boolean { 40 | return externalId != null && chatRepository.existsByExternalId(externalId) 41 | } 42 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/ChatDeleterUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.app_service.bucket_service.BucketService 4 | import dev.marcal.chatvault.repository.ChatRepository 5 | import dev.marcal.chatvault.service.ChatDeleter 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class ChatDeleterUseCase( 10 | private val chatRepository: ChatRepository, 11 | private val buckService: BucketService 12 | ) : ChatDeleter { 13 | override fun execute(chatId: Long) { 14 | chatRepository.findChatBucketInfoByChatId(chatId)?.let { chatBucketInfo -> 15 | buckService.delete(chatBucketInfo.bucket.toBucketFile()) 16 | chatRepository.deleteChat(chatId) 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/ChatFileExporterUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.app_service.bucket_service.BucketService 4 | import dev.marcal.chatvault.in_out_boundary.output.MessageOutput 5 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.ChatNotFoundException 6 | import dev.marcal.chatvault.model.AuthorType 7 | import dev.marcal.chatvault.model.Bucket 8 | import dev.marcal.chatvault.model.BucketFile 9 | import dev.marcal.chatvault.repository.ChatRepository 10 | import dev.marcal.chatvault.service.ChatFileExporter 11 | import dev.marcal.chatvault.service.MessageFinderByChatId 12 | import org.springframework.core.io.Resource 13 | import org.springframework.stereotype.Service 14 | import java.time.format.DateTimeFormatter 15 | 16 | 17 | @Service 18 | class ChatFileExporterUseCase( 19 | private val bucketService: BucketService, 20 | private val chatRepository: ChatRepository, 21 | private val messageFinderByChatId: MessageFinderByChatId 22 | ) : ChatFileExporter { 23 | 24 | private val formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm") 25 | 26 | override fun execute(chatId: Long): Resource { 27 | 28 | val bucket = findMessagesAndUpdateBucket(chatId) 29 | 30 | return bucketService.loadBucketAsZip(bucket.path) 31 | 32 | } 33 | 34 | override fun executeAll(): Resource { 35 | chatRepository.findAllChatsWithLastMessage() 36 | .forEach { findMessagesAndUpdateBucket(it.chatId) } 37 | return bucketService.loadBucketListAsZip() 38 | } 39 | 40 | fun findMessagesAndUpdateBucket(chatId: Long): Bucket { 41 | val chatBucketInfo = 42 | chatRepository.findChatBucketInfoByChatId(chatId) 43 | ?: throw ChatNotFoundException("The export failed. Chat with id $chatId was not found.") 44 | 45 | messageFinderByChatId.execute(chatId).map { 46 | parseToLineText(it) 47 | }.also { sequence -> 48 | bucketService.saveTextToBucket( 49 | bucketFile = BucketFile( 50 | fileName = "_WhatsApp talk.txt", 51 | address = chatBucketInfo.bucket 52 | ), sequence 53 | ) 54 | } 55 | return chatBucketInfo.bucket 56 | } 57 | 58 | private fun parseToLineText(it: MessageOutput): String { 59 | val builder = StringBuilder(it.content.length + 100) 60 | builder.append(formatter.format(it.createdAt)) 61 | builder.append(" - ") 62 | if (it.authorType == AuthorType.USER.name) { 63 | builder.append("${it.author}: ") 64 | } 65 | builder.append(it.content) 66 | return builder.toString() 67 | } 68 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/ChatFileImporterUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.app_service.bucket_service.BucketService 4 | import dev.marcal.chatvault.in_out_boundary.input.FileTypeInputEnum 5 | import dev.marcal.chatvault.in_out_boundary.input.NewChatInput 6 | import dev.marcal.chatvault.in_out_boundary.input.NewMessagePayloadInput 7 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.ChatImporterException 8 | import dev.marcal.chatvault.model.BucketFile 9 | import dev.marcal.chatvault.model.ChatNamePatternMatcher 10 | import dev.marcal.chatvault.repository.ChatRepository 11 | import dev.marcal.chatvault.service.ChatCreator 12 | import dev.marcal.chatvault.service.ChatFileImporter 13 | import dev.marcal.chatvault.service.ChatMessageParser 14 | import dev.marcal.chatvault.service.MessageCreator 15 | import dev.marcal.chatvault.usecase.mapper.toNewMessageInput 16 | import org.slf4j.LoggerFactory 17 | import org.springframework.stereotype.Service 18 | import java.io.BufferedInputStream 19 | import java.io.ByteArrayInputStream 20 | import java.io.ByteArrayOutputStream 21 | import java.io.InputStream 22 | import java.time.LocalDateTime 23 | import java.util.zip.ZipInputStream 24 | 25 | @Service 26 | class ChatFileImporterUseCase( 27 | private val chatMessageParser: ChatMessageParser, 28 | private val messageCreator: MessageCreator, 29 | private val bucketService: BucketService, 30 | private val chatRepository: ChatRepository, 31 | private val chatCreator: ChatCreator 32 | ) : ChatFileImporter { 33 | 34 | private val logger = LoggerFactory.getLogger(this.javaClass) 35 | override fun execute(chatId: Long, inputStream: InputStream, fileType: FileTypeInputEnum) { 36 | when (fileType) { 37 | FileTypeInputEnum.ZIP -> { 38 | iterateOverZip(chatId, inputStream) 39 | } 40 | 41 | FileTypeInputEnum.TEXT -> { 42 | createMessages(inputStream = inputStream, chatId = chatId) 43 | } 44 | 45 | else -> throw IllegalStateException("file type $fileType not supported") 46 | } 47 | 48 | 49 | } 50 | 51 | override fun execute(chatName: String?, inputStream: InputStream, fileType: FileTypeInputEnum) { 52 | val chatId = 53 | chatName?.let { chatRepository.findChatBucketInfoByChatName(it)?.chatId } ?: createTodoChat(chatName) 54 | execute(chatId, inputStream, fileType) 55 | } 56 | 57 | private fun createTodoChat(chatName: String?): Long { 58 | val tempChatName = chatName?.takeIf { it.isNotEmpty() } ?: "todo imported at ${LocalDateTime.now()}" 59 | chatCreator.executeIfNotExists(NewChatInput(name = tempChatName)) 60 | return requireNotNull(chatRepository.findChatBucketInfoByChatName(tempChatName)?.chatId) { "temp chat creation fails: chatName: $tempChatName" } 61 | } 62 | 63 | private fun iterateOverZip(chatId: Long, inputStream: InputStream) { 64 | val chatBucketInfo = 65 | chatRepository.findChatBucketInfoByChatId(chatId = chatId) 66 | ?: throw ChatImporterException("Chat id $chatId was not found. File import failed.") 67 | val bucket = chatBucketInfo.bucket.withPath("/") 68 | 69 | val zipInputStream = ZipInputStream(BufferedInputStream(inputStream)) 70 | 71 | var entry = zipInputStream.nextEntry 72 | while (entry != null) { 73 | val fileName = entry.name 74 | 75 | val byteArray = bytes(zipInputStream) 76 | bucketService.save(BucketFile(bytes = byteArray, fileName = fileName, address = bucket)) 77 | 78 | if (ChatNamePatternMatcher.matches(fileName)) { 79 | execute( 80 | chatId = chatId, 81 | inputStream = ByteArrayInputStream(byteArray), 82 | fileType = FileTypeInputEnum.TEXT 83 | ) 84 | } 85 | 86 | entry = zipInputStream.nextEntry 87 | } 88 | zipInputStream.close() 89 | } 90 | 91 | private fun bytes(zipInputStream: ZipInputStream): ByteArray { 92 | val byteArrayOutputStream = ByteArrayOutputStream() 93 | val buffer = ByteArray(1024) 94 | var len: Int 95 | 96 | while (zipInputStream.read(buffer).also { len = it } > 0) { 97 | byteArrayOutputStream.write(buffer, 0, len) 98 | } 99 | 100 | return byteArrayOutputStream.toByteArray() 101 | } 102 | 103 | private fun createMessages(inputStream: InputStream, chatId: Long) { 104 | val messages = chatMessageParser.parseAndTransform(inputStream) { messageOutput -> 105 | messageOutput.toNewMessageInput(chatId = chatId) 106 | } 107 | 108 | val count = chatRepository.countChatMessages(chatId) 109 | 110 | messageCreator.execute( 111 | NewMessagePayloadInput( 112 | chatId = chatId, 113 | eventSource = false, 114 | messages = messages 115 | ) 116 | ) 117 | 118 | val countUpdated = chatRepository.countChatMessages(chatId) 119 | 120 | val newMessages = countUpdated - count 121 | 122 | logger.info("imported $newMessages of ${messages.size} messages to chatId=$chatId") 123 | } 124 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/ChatListerUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.repository.ChatRepository 4 | import dev.marcal.chatvault.service.ChatLister 5 | import dev.marcal.chatvault.in_out_boundary.output.ChatLastMessageOutput 6 | import dev.marcal.chatvault.usecase.mapper.toOutput 7 | import org.springframework.stereotype.Service 8 | 9 | @Service 10 | class ChatListerUseCase( 11 | private val chatRepository: ChatRepository 12 | ): ChatLister { 13 | override fun execute(): List { 14 | return chatRepository.findAllChatsWithLastMessage() 15 | .map { it.toOutput() } 16 | .toList() 17 | } 18 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/ChatMessageParserUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.MessageOutput 4 | import dev.marcal.chatvault.model.MessageParser 5 | import dev.marcal.chatvault.service.ChatMessageParser 6 | import dev.marcal.chatvault.usecase.mapper.toOutput 7 | import kotlinx.coroutines.channels.ProducerScope 8 | import kotlinx.coroutines.channels.trySendBlocking 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.callbackFlow 11 | import kotlinx.coroutines.flow.map 12 | import kotlinx.coroutines.flow.toList 13 | import kotlinx.coroutines.runBlocking 14 | import org.springframework.beans.factory.annotation.Value 15 | import org.springframework.stereotype.Service 16 | import java.io.BufferedReader 17 | import java.io.InputStream 18 | import java.io.InputStreamReader 19 | 20 | @Service 21 | class ChatMessageParserUseCase( 22 | private val messageParser: MessageParser 23 | ) : ChatMessageParser { 24 | 25 | override fun parseAndTransform( 26 | inputStream: InputStream, 27 | transformIn: (MessageOutput) -> R 28 | ): List { 29 | return runBlocking { parse(inputStream).map { transformIn(it) }.toList() } 30 | } 31 | 32 | override fun parseToList( 33 | inputStream: InputStream 34 | ): List { 35 | return parseAndTransform(inputStream) { it } 36 | } 37 | 38 | override fun parse(inputStream: InputStream): Flow { 39 | return sequenceOfTextMessage(inputStream) 40 | .map { messageText -> messageParser.parse(messageText) { it.toOutput() } } 41 | } 42 | 43 | private fun sequenceOfTextMessage(inputStream: InputStream): Flow { 44 | val message = callbackFlow { 45 | val reader = BufferedReader(InputStreamReader(inputStream)) 46 | var currentDate: String? = null 47 | var currentLines = StringBuilder() 48 | 49 | reader.forEachLine { line -> 50 | 51 | messageParser.extractTextDate(line)?.let { lineDate -> 52 | if (currentDate != null && currentLines.isNotEmpty()) { 53 | trySendBlocking(currentLines) 54 | } 55 | currentDate = lineDate 56 | currentLines = StringBuilder(line) 57 | } ?: currentLines.appendLine().append(line) 58 | } 59 | 60 | if (currentDate != null && currentLines.isNotEmpty()) { 61 | trySendBlocking(currentLines) 62 | } 63 | close() 64 | 65 | } 66 | return message 67 | } 68 | 69 | private fun ProducerScope.trySendBlocking(currentLines: StringBuilder) { 70 | trySendBlocking(currentLines.toString()).takeIf { it.isFailure }?.getOrThrow() 71 | } 72 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/ChatNameUpdaterUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.repository.ChatRepository 4 | import dev.marcal.chatvault.service.ChatNameUpdater 5 | import org.springframework.stereotype.Service 6 | 7 | @Service 8 | class ChatNameUpdaterUseCase( 9 | private val chatRepository: ChatRepository 10 | ) : ChatNameUpdater { 11 | override fun execute(chatId: Long, chatName: String) { 12 | require(chatName.isNotBlank()) { "chatName must not be empty" } 13 | require(chatRepository.existsByChatId(chatId)) { "chat not found" } 14 | 15 | chatRepository.setChatNameByChatId(chatId, chatName) 16 | } 17 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/MessageCreatorUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.NewMessageInput 4 | import dev.marcal.chatvault.in_out_boundary.input.NewMessagePayloadInput 5 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.ChatNotFoundException 6 | import dev.marcal.chatvault.model.* 7 | import dev.marcal.chatvault.repository.ChatRepository 8 | import dev.marcal.chatvault.service.MessageCreator 9 | import org.slf4j.LoggerFactory 10 | import org.springframework.stereotype.Service 11 | import java.time.LocalDateTime 12 | 13 | @Service 14 | class MessageCreatorUseCase( 15 | private val chatRepository: ChatRepository, 16 | private val messageDeduplicationUseCase: MessageDeduplicationUseCase 17 | ) : MessageCreator { 18 | 19 | private val logger = LoggerFactory.getLogger(this.javaClass) 20 | override fun execute(input: NewMessageInput) { 21 | val payloadInput = NewMessagePayloadInput( 22 | chatId = input.chatId, 23 | messages = listOf(input), 24 | eventSource = false, 25 | ) 26 | execute(payloadInput) 27 | } 28 | 29 | override fun execute(input: NewMessagePayloadInput) { 30 | val chatBucketInfo = 31 | chatRepository.findChatBucketInfoByChatId(input.chatId) ?: throw ChatNotFoundException("Unable to create a message because the chat ${input.chatId} was not found") 32 | 33 | val theMessagePayload = MessagePayload( 34 | chatId = chatBucketInfo.chatId, 35 | messages = input.messages 36 | .let { messageDeduplicationUseCase.execute(chatBucketInfo.chatId, it) } 37 | .map { buildNewMessage(it, chatBucketInfo) } 38 | ) 39 | 40 | if (theMessagePayload.messages.isEmpty()) { 41 | throw IllegalStateException("there are no messages to create, message list is empty $theMessagePayload") 42 | } 43 | 44 | logger.info("try to save ${theMessagePayload.messages.size} messages, chatInfo=${chatBucketInfo}") 45 | chatRepository.saveNewMessages(payload = theMessagePayload, eventSource = input.eventSource) 46 | } 47 | 48 | private fun buildNewMessage(input: NewMessageInput, chatBucketInfo: ChatBucketInfo): Message { 49 | require(input.chatId == chatBucketInfo.chatId) 50 | return Message( 51 | author = Author( 52 | name = input.authorName, 53 | type = if (input.authorName.isEmpty()) AuthorType.SYSTEM else AuthorType.USER 54 | ), 55 | createdAt = input.createdAt ?: LocalDateTime.now(), 56 | externalId = input.externalId, 57 | content = Content(text = input.content, attachment = input.attachment?.let { 58 | Attachment( 59 | name = it.name, 60 | bucket = chatBucketInfo.bucket.withPath("/") 61 | ) 62 | }) 63 | ) 64 | } 65 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/MessageDeduplicationUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.NewMessageInput 4 | import dev.marcal.chatvault.repository.ChatRepository 5 | import dev.marcal.chatvault.usecase.mapper.toMessageDomain 6 | import org.slf4j.LoggerFactory 7 | import org.springframework.stereotype.Service 8 | 9 | @Service 10 | class MessageDeduplicationUseCase( 11 | private val chatRepository: ChatRepository 12 | ) { 13 | 14 | private val logger = LoggerFactory.getLogger(this.javaClass) 15 | 16 | fun execute(chatId: Long, messages: List): List { 17 | 18 | if (messages.isEmpty()) return emptyList() 19 | 20 | val chatBucketInfo = chatRepository.findChatBucketInfoByChatId(chatId) ?: return messages 21 | val lastSaved = chatRepository.findLastMessageByChatId(chatId) ?: return messages 22 | 23 | logger.info("starting message deduplication chatInfo=${chatBucketInfo}, found ${messages.size} messages") 24 | 25 | messages[messages.size - 1].toMessageDomain(chatBucketInfo).also { lastInput -> 26 | if (lastInput == lastSaved || lastInput.createdAt < lastSaved.createdAt) { 27 | logger.info("messages are older than the last stored chatInfo=${chatBucketInfo}") 28 | return emptyList() 29 | } 30 | } 31 | 32 | messages[0].toMessageDomain(chatBucketInfo).also { firstInput -> 33 | if (firstInput.createdAt > lastSaved.createdAt) return messages 34 | } 35 | 36 | var low = 0 37 | var high = messages.size - 1 38 | 39 | while (true) { 40 | val middle = (low + high).ushr(1) 41 | if (middle == low) { 42 | break 43 | } 44 | val middleMessage = messages[middle].toMessageDomain(chatBucketInfo) 45 | 46 | if (middleMessage == lastSaved) return messages.subList(middle + 1, messages.size) 47 | 48 | val compareTo = middleMessage.createdAt.compareTo(lastSaved.createdAt) 49 | 50 | if (compareTo < 0) { 51 | low = middle 52 | } else { 53 | high = middle 54 | } 55 | } 56 | 57 | return messages.subList(high, messages.size) 58 | } 59 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/MessageFinderByChatIdUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.MessageOutput 4 | import dev.marcal.chatvault.repository.ChatRepository 5 | import dev.marcal.chatvault.service.MessageFinderByChatId 6 | import dev.marcal.chatvault.usecase.mapper.toOutput 7 | import org.springframework.data.domain.Page 8 | import org.springframework.data.domain.PageRequest 9 | import org.springframework.data.domain.Pageable 10 | import org.springframework.data.domain.Sort.Direction 11 | import org.springframework.stereotype.Service 12 | 13 | @Service 14 | class MessageFinderByChatIdUseCase( 15 | private val chatRepository: ChatRepository 16 | ) : MessageFinderByChatId { 17 | override fun execute(chatId: Long, query: String?, pageable: Pageable): Page { 18 | return chatRepository.findMessagesBy(chatId = chatId, query = query, pageable = pageable) 19 | } 20 | 21 | override fun execute(chatId: Long): Sequence { 22 | return generateSequence(0) { it + 1 } 23 | .map { page -> 24 | val pageable = PageRequest.of(page, 200, Direction.ASC, "id") 25 | chatRepository.findMessagesBy(chatId = chatId, pageable = pageable) 26 | } 27 | .takeWhile { it.hasContent() } 28 | .flatMap { it.content.asSequence() } 29 | } 30 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/ProfileImageManagerUseCase.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.app_service.bucket_service.BucketService 4 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.AttachmentFinderException 5 | import dev.marcal.chatvault.in_out_boundary.output.exceptions.ChatNotFoundException 6 | import dev.marcal.chatvault.model.BucketFile 7 | import dev.marcal.chatvault.repository.ChatRepository 8 | import dev.marcal.chatvault.service.ProfileImageManager 9 | import org.springframework.context.ApplicationContext 10 | import org.springframework.core.io.Resource 11 | import org.springframework.stereotype.Service 12 | import java.io.InputStream 13 | 14 | @Service 15 | class ProfileImageManagerUseCase( 16 | private val bucketService: BucketService, 17 | private val chatRepository: ChatRepository, 18 | private val context: ApplicationContext 19 | ) : ProfileImageManager { 20 | override fun updateImage(inputStream: InputStream, chatId: Long) { 21 | val bucketInfo = 22 | chatRepository.findChatBucketInfoByChatId(chatId) 23 | ?: throw ChatNotFoundException("Unable to update chat image because chat $chatId was not found") 24 | bucketService.save( 25 | BucketFile( 26 | stream = inputStream, 27 | fileName = "profile-image.jpg", 28 | address = bucketInfo.bucket.withPath("/") 29 | ) 30 | ) 31 | } 32 | 33 | override fun getImage(chatId: Long): Resource { 34 | val bucketInfo = 35 | chatRepository.findChatBucketInfoByChatId(chatId) 36 | ?: throw ChatNotFoundException("Unable to retrieve chat image because chat $chatId was not found") 37 | 38 | return try { 39 | bucketService.loadFileAsResource( 40 | BucketFile( 41 | fileName = "profile-image.jpg", 42 | address = bucketInfo.bucket.withPath("/") 43 | ) 44 | ) 45 | } catch (ex: AttachmentFinderException) { 46 | context.getResource("classpath:public/default-avatar.png") 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /backend/usecase/src/main/kotlin/dev/marcal/chatvault/usecase/mapper/Mapper.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase.mapper 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.NewAttachmentInput 4 | import dev.marcal.chatvault.in_out_boundary.input.NewMessageInput 5 | import dev.marcal.chatvault.in_out_boundary.output.ChatBucketInfoOutput 6 | import dev.marcal.chatvault.in_out_boundary.output.ChatLastMessageOutput 7 | import dev.marcal.chatvault.in_out_boundary.output.MessageOutput 8 | import dev.marcal.chatvault.model.* 9 | import java.time.LocalDateTime 10 | 11 | fun ChatBucketInfo.toOutput(): ChatBucketInfoOutput { 12 | return ChatBucketInfoOutput( 13 | chatId = this.chatId, 14 | path = this.bucket.path 15 | ) 16 | } 17 | 18 | fun ChatLastMessage.toOutput(): ChatLastMessageOutput { 19 | return ChatLastMessageOutput( 20 | chatId = this.chatId, 21 | chatName = this.chatName, 22 | authorName = this.author.name, 23 | authorType = this.author.type.name, 24 | content = this.content, 25 | msgCreatedAt = this.msgCreatedAt, 26 | msgCount = this.msgCount 27 | ) 28 | } 29 | 30 | fun Message.toOutput(): MessageOutput { 31 | return MessageOutput( 32 | id = null, 33 | content = this.content.text, 34 | createdAt = this.createdAt, 35 | attachmentName = this.content.attachment?.name, 36 | authorType = this.author.type.name, 37 | author = this.author.name 38 | ) 39 | } 40 | 41 | fun MessageOutput.toNewMessageInput(chatId: Long): NewMessageInput { 42 | return NewMessageInput( 43 | authorName = this.author ?: "", 44 | chatId = chatId, 45 | createdAt = this.createdAt, 46 | content = this.content, 47 | attachment = this.attachmentName?.let { NewAttachmentInput(name = it, content = "") } 48 | ) 49 | } 50 | 51 | fun NewMessageInput.toMessageDomain(chatBucketInfo: ChatBucketInfo): Message { 52 | return Message( 53 | author = Author( 54 | name = this.authorName, 55 | type = if (this.authorName.isEmpty()) AuthorType.SYSTEM else AuthorType.USER 56 | ), 57 | createdAt = this.createdAt ?: LocalDateTime.now(), 58 | externalId = this.externalId, 59 | content = Content(text = this.content, attachment = this.attachment?.let { 60 | Attachment( 61 | name = it.name, 62 | bucket = chatBucketInfo.bucket.withPath("/") 63 | ) 64 | }) 65 | ) 66 | } -------------------------------------------------------------------------------- /backend/usecase/src/test/kotlin/dev/marcal/chatvault/usecase/ChatFileImporterUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.app_service.bucket_service.BucketService 4 | import dev.marcal.chatvault.in_out_boundary.input.FileTypeInputEnum 5 | import dev.marcal.chatvault.in_out_boundary.input.NewMessagePayloadInput 6 | import dev.marcal.chatvault.model.Bucket 7 | import dev.marcal.chatvault.model.ChatBucketInfo 8 | import dev.marcal.chatvault.model.MessageParser 9 | import dev.marcal.chatvault.repository.ChatRepository 10 | import dev.marcal.chatvault.service.ChatCreator 11 | import dev.marcal.chatvault.service.MessageCreator 12 | import io.mockk.* 13 | import org.junit.jupiter.api.BeforeEach 14 | import org.junit.jupiter.api.Test 15 | 16 | class ChatFileImporterUseCaseTest { 17 | private val chatMessageParser = ChatMessageParserUseCase(MessageParser()) 18 | private val chatRepository: ChatRepository = mockk() 19 | private val bucketService: BucketService = mockk() 20 | private val messageCreator: MessageCreator = mockk() 21 | private val chatCreator: ChatCreator = mockk() 22 | private val chatFileImporter = ChatFileImporterUseCase( 23 | chatMessageParser = chatMessageParser, 24 | chatRepository = chatRepository, 25 | bucketService = bucketService, 26 | messageCreator = messageCreator, 27 | chatCreator = chatCreator, 28 | 29 | ) 30 | 31 | @BeforeEach 32 | fun setup() { 33 | every { chatRepository.findChatBucketInfoByChatId(1) } returns ChatBucketInfo(1, Bucket("/")) 34 | every { chatRepository.countChatMessages(1) } returns 1 35 | every { messageCreator.execute(any()) } just Runs 36 | every { bucketService.save(any()) } just Runs 37 | } 38 | 39 | 40 | @Test 41 | fun `when receive input stream of text message file should properly execute chatParser and save messages`() { 42 | val inputStream = """ 43 | 22/09/2023 13:33 - Fulano: ‎IMG-20230922-WA0006.jpg (arquivo anexado) 44 | Esse é um teste 45 | """.trimIndent().byteInputStream() 46 | chatFileImporter.execute(chatId = 1, inputStream = inputStream, fileType = FileTypeInputEnum.TEXT) 47 | 48 | 49 | verify { messageCreator.execute(any())} 50 | verify(exactly = 0) { bucketService.save(any()) } 51 | } 52 | 53 | @Test 54 | fun `when receive zip input stream should iterate properly and save files e parse text`() { 55 | val inputStream = requireNotNull(this.javaClass.classLoader.getResourceAsStream("test_chat.zip")) { "resource test_chat.zip not found! used by unit test" } 56 | 57 | chatFileImporter.execute(chatId = 1, inputStream = inputStream, fileType = FileTypeInputEnum.ZIP) 58 | 59 | verify(exactly = 1) { messageCreator.execute(any())} 60 | verify(exactly = 2) { bucketService.save(any()) } 61 | } 62 | } -------------------------------------------------------------------------------- /backend/usecase/src/test/kotlin/dev/marcal/chatvault/usecase/ChatMessageParserUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.in_out_boundary.output.MessageOutput 4 | import dev.marcal.chatvault.model.MessageParser 5 | import org.junit.jupiter.api.Assertions.assertEquals 6 | import org.junit.jupiter.api.Test 7 | import java.time.LocalDateTime 8 | 9 | class ChatMessageParserUseCaseTest { 10 | 11 | private val chatMessageParser = ChatMessageParserUseCase(MessageParser()) 12 | 13 | @Test 14 | fun `when receive input stream should build messages`() { 15 | 16 | val expected = listOf( 17 | MessageOutput( 18 | id = null, 19 | author = "", 20 | authorType = "SYSTEM", 21 | attachmentName = null, 22 | createdAt = LocalDateTime.of(2023, 8, 13, 18, 16), 23 | content = "As mensagens e as chamadas são protegidas com a criptografia de ponta a ponta e ficam somente entre você e os participantes desta conversa. Nem mesmo o WhatsApp pode ler ou ouvi-las. Toque para saber mais." 24 | ), 25 | MessageOutput( 26 | id = null, 27 | author = "Fulano", 28 | authorType = "USER", 29 | attachmentName = null, 30 | createdAt = LocalDateTime.of(2023, 8, 13, 17, 45), 31 | content = "Que lindooos. Feliz dia dos pais, Beltrano!" 32 | ), 33 | MessageOutput( 34 | id = null, 35 | author = "Beltrano", 36 | authorType = "USER", 37 | attachmentName = null, 38 | createdAt = LocalDateTime.of(2023, 8, 13, 18, 24), 39 | content = "Opa, vlw Fulano !\uD83D\uDE43" 40 | ), 41 | MessageOutput( 42 | id = null, 43 | author = "Fulano", 44 | authorType = "USER", 45 | attachmentName = null, 46 | createdAt = LocalDateTime.of(2023, 9, 19, 23, 3), 47 | content = "Eita\nEssa ficou bonita" 48 | ) 49 | ) 50 | 51 | val inputStream = """ 52 | 13/08/2023 18:16 - As mensagens e as chamadas são protegidas com a criptografia de ponta a ponta e ficam somente entre você e os participantes desta conversa. Nem mesmo o WhatsApp pode ler ou ouvi-las. Toque para saber mais. 53 | 13/08/2023 17:45 - Fulano: Que lindooos. Feliz dia dos pais, Beltrano! 54 | 13/08/2023 18:24 - Beltrano: Opa, vlw Fulano !🙃 55 | 19/09/2023 23:03 - Fulano: Eita 56 | Essa ficou bonita 57 | """.trimIndent().byteInputStream() 58 | 59 | val list = chatMessageParser.parseToList(inputStream) 60 | 61 | 62 | assertEquals(expected, list) 63 | } 64 | 65 | @Test 66 | fun `when using custom pattern and receive input stream should build messages`() { 67 | val chatMessageParser = ChatMessageParserUseCase(MessageParser("[dd/MM/yyyy HH:mm]")) 68 | val expected = listOf( 69 | MessageOutput( 70 | id = null, 71 | author = "", 72 | authorType = "SYSTEM", 73 | attachmentName = null, 74 | createdAt = LocalDateTime.of(2023, 8, 13, 18, 16), 75 | content = "As mensagens e as chamadas são protegidas com a criptografia de ponta a ponta e ficam somente entre você e os participantes desta conversa. Nem mesmo o WhatsApp pode ler ou ouvi-las. Toque para saber mais." 76 | ), 77 | MessageOutput( 78 | id = null, 79 | author = "Fulano", 80 | authorType = "USER", 81 | attachmentName = null, 82 | createdAt = LocalDateTime.of(2023, 8, 13, 17, 45), 83 | content = "Que lindooos. Feliz dia dos pais, Beltrano!" 84 | ), 85 | MessageOutput( 86 | id = null, 87 | author = "Beltrano", 88 | authorType = "USER", 89 | attachmentName = null, 90 | createdAt = LocalDateTime.of(2023, 8, 13, 18, 24), 91 | content = "Opa, vlw Fulano !\uD83D\uDE43" 92 | ), 93 | MessageOutput( 94 | id = null, 95 | author = "Fulano", 96 | authorType = "USER", 97 | attachmentName = null, 98 | createdAt = LocalDateTime.of(2023, 9, 19, 23, 3), 99 | content = "Eita\nEssa ficou bonita" 100 | ) 101 | ) 102 | 103 | val inputStream = """ 104 | [13/08/2023 18:16] - As mensagens e as chamadas são protegidas com a criptografia de ponta a ponta e ficam somente entre você e os participantes desta conversa. Nem mesmo o WhatsApp pode ler ou ouvi-las. Toque para saber mais. 105 | [13/08/2023 17:45] - Fulano: Que lindooos. Feliz dia dos pais, Beltrano! 106 | [13/08/2023 18:24] - Beltrano: Opa, vlw Fulano !🙃 107 | [19/09/2023 23:03] - Fulano: Eita 108 | Essa ficou bonita 109 | """.trimIndent().byteInputStream() 110 | 111 | val list = chatMessageParser.parseToList(inputStream) 112 | 113 | 114 | assertEquals(expected, list) 115 | } 116 | 117 | @Test 118 | fun `when receive input stream should build message with attachmentName`() { 119 | 120 | val expected = listOf( 121 | MessageOutput( 122 | id = null, 123 | author = "Fulano", 124 | authorType = "USER", 125 | attachmentName = "IMG-20230922-WA0006.jpg", 126 | createdAt = LocalDateTime.of(2023, 9, 22, 13, 33), 127 | content = "IMG-20230922-WA0006.jpg (arquivo anexado)\nEsse é um teste" 128 | ) 129 | ) 130 | 131 | val inputStream = """ 132 | 22/09/2023 13:33 - Fulano: ‎IMG-20230922-WA0006.jpg (arquivo anexado) 133 | Esse é um teste 134 | """.trimIndent().byteInputStream() 135 | 136 | val list = chatMessageParser.parseToList(inputStream) 137 | 138 | assertEquals(expected, list) 139 | } 140 | 141 | } -------------------------------------------------------------------------------- /backend/usecase/src/test/kotlin/dev/marcal/chatvault/usecase/MessageDeduplicationUseCaseTest.kt: -------------------------------------------------------------------------------- 1 | package dev.marcal.chatvault.usecase 2 | 3 | import dev.marcal.chatvault.in_out_boundary.input.NewMessageInput 4 | import dev.marcal.chatvault.model.* 5 | import dev.marcal.chatvault.repository.ChatRepository 6 | import io.mockk.every 7 | import io.mockk.mockk 8 | import org.junit.jupiter.api.Assertions 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.Test 11 | import java.time.LocalDateTime 12 | 13 | class MessageDeduplicationUseCaseTest { 14 | 15 | private val chatRepository: ChatRepository = mockk() 16 | 17 | private val messageDeduplicationUseCase = MessageDeduplicationUseCase( 18 | chatRepository = chatRepository 19 | ) 20 | private val chatId = 1L 21 | 22 | @BeforeEach 23 | fun setup() { 24 | every { chatRepository.findChatBucketInfoByChatId(chatId) } returns ChatBucketInfo( 25 | chatId = chatId, 26 | bucket = Bucket("/") 27 | ) 28 | } 29 | 30 | @Test 31 | fun `when has no messages stored should return input messages`() { 32 | 33 | val input = listOf( 34 | NewMessageInput( 35 | authorName = "Fulano", 36 | createdAt = LocalDateTime.of(2020, 1, 1, 5, 30), 37 | chatId = chatId, 38 | content = "Bla bla bla" 39 | ), 40 | NewMessageInput( 41 | authorName = "Beltrano", 42 | createdAt = LocalDateTime.of(2020, 1, 1, 5, 32), 43 | chatId = chatId, 44 | content = "Bla bla bla bla" 45 | ) 46 | ) 47 | 48 | 49 | 50 | every { chatRepository.findLastMessageByChatId(chatId) } returns null 51 | 52 | val deduplicatedMessages = messageDeduplicationUseCase.execute( 53 | chatId = chatId, 54 | messages = input 55 | ) 56 | 57 | Assertions.assertArrayEquals(input.toTypedArray(), deduplicatedMessages.toTypedArray()) 58 | } 59 | 60 | @Test 61 | fun `when last message is equal or after stored message should return empty messages`() { 62 | 63 | NewMessageInput( 64 | authorName = "Beltrano", 65 | createdAt = LocalDateTime.of(2020, 1, 1, 5, 31), 66 | chatId = chatId, 67 | content = "Bla bla bla bla" 68 | ).apply { 69 | every { chatRepository.findLastMessageByChatId(chatId) } returns Message( 70 | author = Author(name = "Beltrano", type = AuthorType.USER), 71 | createdAt = LocalDateTime.of(2020, 1, 1, 5, 31), 72 | content = Content(text = "Bla bla bla bla"), 73 | externalId = null 74 | ) 75 | 76 | val deduplicatedMessages = messageDeduplicationUseCase.execute( 77 | chatId = chatId, 78 | messages = listOf(this) 79 | ) 80 | Assertions.assertTrue(deduplicatedMessages.isEmpty()) 81 | } 82 | 83 | NewMessageInput( 84 | authorName = "Beltrano", 85 | createdAt = LocalDateTime.of(2020, 1, 1, 5, 30), 86 | chatId = chatId, 87 | content = "Bla bla bla bla" 88 | ).apply { 89 | every { chatRepository.findLastMessageByChatId(chatId) } returns Message( 90 | author = Author(name = "Beltrano", type = AuthorType.USER), 91 | createdAt = LocalDateTime.of(2020, 1, 1, 5, 31), 92 | content = Content(text = "Bla bla bla bla"), 93 | externalId = null 94 | ) 95 | 96 | val deduplicatedMessages = messageDeduplicationUseCase.execute( 97 | chatId = chatId, 98 | messages = listOf(this) 99 | ) 100 | Assertions.assertTrue(deduplicatedMessages.isEmpty()) 101 | } 102 | 103 | } 104 | 105 | 106 | @Test 107 | fun `should cut off olders messages when found message equal stored message when binary searching`() { 108 | val now = LocalDateTime.of(2023, 1, 1, 1, 1) 109 | val messages = (0..10000).map { 110 | NewMessageInput( 111 | authorName = "Beltrano", 112 | createdAt = now.plusMinutes(it.toLong()), 113 | chatId = chatId, 114 | content = "Bla bla bla bla" 115 | ) 116 | } 117 | 118 | val expected = messages.subList(23, messages.size).toTypedArray() 119 | 120 | every { chatRepository.findLastMessageByChatId(chatId) } returns Message( 121 | author = Author(name = "Beltrano", type = AuthorType.USER), 122 | createdAt = LocalDateTime.of(2023, 1, 1, 1, 23), 123 | content = Content(text = "Bla bla bla bla"), 124 | externalId = null 125 | ) 126 | 127 | val deduplicatedMessages = messageDeduplicationUseCase.execute( 128 | chatId = chatId, 129 | messages = messages 130 | ) 131 | 132 | 133 | 134 | Assertions.assertArrayEquals(expected, deduplicatedMessages.toTypedArray()) 135 | 136 | } 137 | 138 | 139 | @Test 140 | fun `should cut off olders messages when binary searching`() { 141 | val now = LocalDateTime.of(2023, 1, 1, 1, 1) 142 | val messages = (0..10000).map { 143 | NewMessageInput( 144 | authorName = "Beltrano", 145 | createdAt = now.plusMinutes(it.toLong()), 146 | chatId = chatId, 147 | content = "Bla bla bla bla" 148 | ) 149 | } 150 | 151 | val expected = messages.subList(22, messages.size).toTypedArray() 152 | 153 | every { chatRepository.findLastMessageByChatId(chatId) } returns Message( 154 | author = Author(name = "Beltrano", type = AuthorType.USER), 155 | createdAt = LocalDateTime.of(2023, 1, 1, 1, 23), 156 | content = Content(text = "Bla bla bla bla bla"), 157 | externalId = null 158 | ) 159 | 160 | val deduplicatedMessages = messageDeduplicationUseCase.execute( 161 | chatId = chatId, 162 | messages = messages 163 | ) 164 | 165 | Assertions.assertArrayEquals(expected, deduplicatedMessages.toTypedArray()) 166 | 167 | } 168 | 169 | 170 | @Test 171 | fun `when first message is after last message saved should return all messages`() { 172 | val now = LocalDateTime.of(2023, 1, 1, 1, 2) 173 | val messages = (0..10000).map { 174 | NewMessageInput( 175 | authorName = "Beltrano", 176 | createdAt = now.plusMinutes(it.toLong()), 177 | chatId = chatId, 178 | content = "Bla bla bla bla" 179 | ) 180 | } 181 | 182 | 183 | every { chatRepository.findLastMessageByChatId(chatId) } returns Message( 184 | author = Author(name = "Beltrano", type = AuthorType.USER), 185 | createdAt = LocalDateTime.of(2023, 1, 1, 1, 1), 186 | content = Content(text = "Bla bla bla bla bla"), 187 | externalId = null 188 | ) 189 | 190 | val deduplicatedMessages = messageDeduplicationUseCase.execute( 191 | chatId = chatId, 192 | messages = messages 193 | ) 194 | 195 | Assertions.assertArrayEquals(messages.toTypedArray(), deduplicatedMessages.toTypedArray()) 196 | 197 | } 198 | 199 | @Test 200 | fun `when only last messages is new should cut off olders`() { 201 | val now = LocalDateTime.of(2023, 1, 1, 1, 0) 202 | val messages = (0..10000).map { 203 | NewMessageInput( 204 | authorName = "Beltrano", 205 | createdAt = now.plusMinutes(it.toLong()), 206 | chatId = chatId, 207 | content = "Bla bla bla bla" 208 | ) 209 | } 210 | 211 | 212 | every { chatRepository.findLastMessageByChatId(chatId) } returns Message( 213 | author = Author(name = "Beltrano", type = AuthorType.USER), 214 | createdAt = now.plusMinutes(10000 - 10), 215 | content = Content(text = "Bla bla bla bla bla"), 216 | externalId = null 217 | ) 218 | 219 | val expected = messages.subList(10000 - 10, messages.size).toTypedArray() 220 | 221 | val deduplicatedMessages = messageDeduplicationUseCase.execute( 222 | chatId = chatId, 223 | messages = messages 224 | ) 225 | 226 | Assertions.assertArrayEquals(expected, deduplicatedMessages.toTypedArray()) 227 | 228 | } 229 | 230 | 231 | } -------------------------------------------------------------------------------- /backend/usecase/src/test/resources/test_chat.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitormarcal/chatvault/e834d31a778849455749aa47a233f1c2cfdc13e3/backend/usecase/src/test/resources/test_chat.zip -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | id 'java' 5 | id 'org.springframework.boot' version '3.2.1' 6 | id 'io.spring.dependency-management' version '1.1.4' 7 | id 'org.jetbrains.kotlin.jvm' version '1.9.21' 8 | id 'org.jetbrains.kotlin.plugin.spring' version '1.9.21' 9 | id 'org.jetbrains.kotlin.plugin.jpa' version '1.9.21' 10 | } 11 | 12 | java { 13 | sourceCompatibility = '21' 14 | } 15 | 16 | repositories { 17 | mavenCentral() 18 | } 19 | 20 | bootJar { 21 | enabled = false 22 | } 23 | 24 | subprojects { 25 | apply plugin: 'java' 26 | apply plugin: 'kotlin' 27 | apply plugin: 'kotlin-jpa' 28 | apply plugin: 'kotlin-spring' 29 | apply plugin: 'org.springframework.boot' 30 | apply plugin: 'io.spring.dependency-management' 31 | 32 | 33 | group = 'dev.marcal' 34 | version = '1.15.1' 35 | 36 | repositories { 37 | mavenCentral() 38 | } 39 | 40 | springBoot { 41 | mainClass = "dev.marcal.chatvault.Boot" 42 | } 43 | 44 | dependencies { 45 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 46 | implementation 'org.springframework.boot:spring-boot-starter-web' 47 | implementation 'org.springframework.boot:spring-boot-starter-webflux' 48 | implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' 49 | implementation 'org.jetbrains.kotlin:kotlin-reflect' 50 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") 51 | runtimeOnly 'org.postgresql:postgresql' 52 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 53 | testImplementation("io.mockk:mockk:1.13.8") 54 | } 55 | 56 | tasks.withType(KotlinCompile) { 57 | kotlinOptions { 58 | freeCompilerArgs += '-Xjsr305=strict' 59 | jvmTarget = '21' 60 | } 61 | } 62 | 63 | tasks.named('test') { 64 | useJUnitPlatform() 65 | } 66 | } 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /compose-dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | database: 3 | image: 'postgres:latest' 4 | environment: 5 | - 'POSTGRES_DB=chatvault' 6 | - 'POSTGRES_PASSWORD=secret' 7 | - 'POSTGRES_USER=usr_chatvault' 8 | ports: 9 | - '5432:5432' 10 | adminer: 11 | image: adminer 12 | ports: 13 | - 8081:8080 -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | database: 3 | image: 'postgres:latest' 4 | environment: 5 | - 'POSTGRES_DB=chatvault' 6 | - 'POSTGRES_PASSWORD=secret' 7 | - 'POSTGRES_USER=usr_chatvault' 8 | ports: 9 | - '5432:5432' 10 | adminer: 11 | image: adminer 12 | ports: 13 | - 8081:8080 14 | chatvault: 15 | build: ./ 16 | environment: 17 | - SPRING_DATASOURCE_URL=jdbc:postgresql://database:5432/chatvault 18 | - SPRING_DATASOURCE_USERNAME=usr_chatvault 19 | - SPRING_DATASOURCE_PASSWORD=secret 20 | - CHATVAULT_MSGPARSER_DATEFORMAT="dd/MM/yyyy HH:mm" 21 | - CHATVAULT_BUCKET_ROOT=/archive 22 | - CHATVAULT_BUCKET_IMPORT=/import 23 | - CHATVAULT_BUCKET_EXPORT=/export 24 | ports: 25 | - 8080:8080 26 | volumes: 27 | - '/opt/chatvault:/opt/chatvault' 28 | - './config:/config' 29 | depends_on: 30 | - database -------------------------------------------------------------------------------- /doc/chatvault-blur-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitormarcal/chatvault/e834d31a778849455749aa47a233f1c2cfdc13e3/doc/chatvault-blur-enabled.png -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 Minimal Starter 2 | 3 | Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. 4 | 5 | ## Setup 6 | 7 | Make sure to install the dependencies: 8 | 9 | ```bash 10 | # npm 11 | npm install 12 | 13 | # pnpm 14 | pnpm install 15 | 16 | # yarn 17 | yarn install 18 | 19 | # bun 20 | bun install 21 | ``` 22 | 23 | ## Development Server 24 | 25 | Start the development server on `http://localhost:3000`: 26 | 27 | ```bash 28 | # npm 29 | npm run dev 30 | 31 | # pnpm 32 | pnpm run dev 33 | 34 | # yarn 35 | yarn dev 36 | 37 | # bun 38 | bun run dev 39 | ``` 40 | 41 | ## Production 42 | 43 | Build the application for production: 44 | 45 | ```bash 46 | # npm 47 | npm run build 48 | 49 | # pnpm 50 | pnpm run build 51 | 52 | # yarn 53 | yarn build 54 | 55 | # bun 56 | bun run build 57 | ``` 58 | 59 | Locally preview production build: 60 | 61 | ```bash 62 | # npm 63 | npm run preview 64 | 65 | # pnpm 66 | pnpm run preview 67 | 68 | # yarn 69 | yarn preview 70 | 71 | # bun 72 | bun run preview 73 | ``` 74 | 75 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 76 | -------------------------------------------------------------------------------- /frontend/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/components/Attachment.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /frontend/components/ChatConfig.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 126 | 127 | 129 | -------------------------------------------------------------------------------- /frontend/components/ChatDeleter.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 51 | 52 | -------------------------------------------------------------------------------- /frontend/components/ChatExporter.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 50 | 51 | -------------------------------------------------------------------------------- /frontend/components/ChatItem.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 30 | 31 | 51 | -------------------------------------------------------------------------------- /frontend/components/ChatList.vue: -------------------------------------------------------------------------------- 1 | 26 | 68 | 69 | 70 | 75 | -------------------------------------------------------------------------------- /frontend/components/FocusableAttachment.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 30 | 31 | -------------------------------------------------------------------------------- /frontend/components/Gallery.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 61 | 62 | 67 | -------------------------------------------------------------------------------- /frontend/components/IconCheck.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/components/IconPencilSquare.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/components/IconThreeDots.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /frontend/components/IconTrash.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 12 | -------------------------------------------------------------------------------- /frontend/components/ImportExportChat.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 136 | 137 | -------------------------------------------------------------------------------- /frontend/components/MessageArea.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 90 | 91 | -------------------------------------------------------------------------------- /frontend/components/MessageAreaNavBar.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 48 | 49 | 72 | -------------------------------------------------------------------------------- /frontend/components/MessageCreatedAt.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 39 | 40 | 43 | -------------------------------------------------------------------------------- /frontend/components/MessageItem.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 48 | 49 | 103 | -------------------------------------------------------------------------------- /frontend/components/NewChatUploader.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 87 | 88 | -------------------------------------------------------------------------------- /frontend/components/ProfileImage.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 46 | 47 | 50 | -------------------------------------------------------------------------------- /frontend/components/ProfileImageUploader.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 108 | -------------------------------------------------------------------------------- /frontend/components/RotableArrowIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/components/SearchBar.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 31 | 32 | 38 | -------------------------------------------------------------------------------- /frontend/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | 3 | const host = process.env.API_HOST || '/api' 4 | export default defineNuxtConfig({ 5 | devtools: {enabled: true}, 6 | ssr: false, 7 | 8 | runtimeConfig: { 9 | public: { 10 | api: { 11 | listChats: `${host}/chats`, 12 | getMessagesByIdAndPage: `${host}/chats/:chatId?page=:page&size=:size&query=:query`, 13 | getAttachmentByChatIdAndMessageId: `${host}/chats/:chatId/messages/:messageId/attachment`, 14 | getAttachmentsInfoByChatId: `${host}/chats/:chatId/attachments`, 15 | importChatById: `${host}/chats/:chatId/messages/import`, 16 | updateChatNameByChatId: `${host}/chats/:chatId/chatName/:chatName`, 17 | getProfileImage: `${host}/chats/:chatId/profile-image`, 18 | exportChatById: `${host}/chats/:chatId/export`, 19 | deleteChatById: `${host}/chats/:chatId`, 20 | importChatByName: `${host}/chats/import/:chatName`, 21 | importFromDisk: `${host}/chats/disk-import`, 22 | exportAllChats: `${host}/chats/export/all` 23 | } 24 | } 25 | }, 26 | 27 | css: ["bootstrap/dist/css/bootstrap.min.css"], 28 | 29 | modules: [ 30 | '@pinia/nuxt', 31 | ], 32 | 33 | compatibilityDate: '2024-09-19', 34 | }) -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatvault", 3 | "private": true, 4 | "version": "1.15.1", 5 | "type": "module", 6 | "scripts": { 7 | "build": "nuxt build", 8 | "dev": "API_HOST=http://localhost:8080/api nuxt dev ", 9 | "generate": "nuxt generate", 10 | "preview": "nuxt preview", 11 | "upgrade": "nuxt upgrade --force", 12 | "postinstall": "nuxt prepare" 13 | }, 14 | "devDependencies": { 15 | "nuxt": "^3.13.2", 16 | "vue": "^3.3.7", 17 | "vue-router": "^4.2.5" 18 | }, 19 | "dependencies": { 20 | "@pinia/nuxt": "^0.5.1", 21 | "bootstrap": "^5.3.2", 22 | "pinia": "^2.1.7" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/pages/index.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 107 | 108 | 109 | 122 | -------------------------------------------------------------------------------- /frontend/plugins/pinia.ts: -------------------------------------------------------------------------------- 1 | import { useMainStore } from '~/store' 2 | 3 | export default defineNuxtPlugin(({ $pinia }) => { 4 | return { 5 | provide: { 6 | store: useMainStore($pinia) 7 | } 8 | } 9 | }) -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitormarcal/chatvault/e834d31a778849455749aa47a233f1c2cfdc13e3/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/store/index.ts: -------------------------------------------------------------------------------- 1 | import {defineStore} from 'pinia'; 2 | import {type Attachment, AttachmentConstructor, type Chat, ChatMessage} from "~/types"; 3 | 4 | export const useMainStore = defineStore('main', () => { 5 | const state = reactive({ 6 | loading: false, 7 | messages: [] as ChatMessage[], 8 | attachmentsInfo: [] as any[], 9 | chatActive: {} as Chat, 10 | authorActive: localStorage.getItem("authorActive") || '', 11 | chatConfigOpen: false, 12 | nextPage: 0, 13 | pageSize: localStorage.getItem("pageSize") || 20, 14 | searchQuery: undefined, 15 | reloadImageProfile: false, 16 | blurEnabled: localStorage.getItem("blurEnabled") === 'true', 17 | }); 18 | 19 | watch(() => state.authorActive, (newValue) => { 20 | localStorage.setItem("authorActive", newValue || ''); 21 | }); 22 | 23 | watch(() => state.blurEnabled, (newValue) => { 24 | localStorage.setItem("blurEnabled", newValue.toString()); 25 | }); 26 | 27 | const authors = computed(() => { 28 | return [...new Set(state.messages.map(it => it.author))].filter(Boolean); 29 | }); 30 | 31 | const attachments = computed(() => { 32 | return state.attachmentsInfo.map((it: any) => 33 | AttachmentConstructor(it.name, attachmentUrl(state.chatActive.chatId, it.id)) 34 | ); 35 | }); 36 | 37 | const moreMessagesPath = computed(() => { 38 | return useRuntimeConfig().public.api.getMessagesByIdAndPage 39 | .replace(":chatId", state.chatActive.chatId?.toString()) 40 | .replace(":page", state.nextPage.toString()) 41 | .replace(":size", state.pageSize.toString()) 42 | .replace(":query", state.searchQuery || "") 43 | }); 44 | 45 | function toggleBlur() { 46 | state.blurEnabled = !state.blurEnabled; 47 | } 48 | 49 | function updateMessages(items: ChatMessage[]) { 50 | state.messages = items; 51 | } 52 | 53 | function toChatMessage(item: any): ChatMessage { 54 | return new ChatMessage(item, state.chatActive.chatId); 55 | } 56 | 57 | function clearMessages() { 58 | state.messages = []; 59 | state.nextPage = 0; 60 | } 61 | 62 | function chatExited() { 63 | state.chatActive = {} as Chat; 64 | state.chatConfigOpen = false; 65 | state.attachmentsInfo = []; 66 | clearMessages(); 67 | } 68 | 69 | async function openChat(chat: Chat) { 70 | state.chatActive = chat; 71 | await findAttachmentsInfo(); 72 | } 73 | 74 | async function findAttachmentsInfo() { 75 | const url = useRuntimeConfig().public.api.getAttachmentsInfoByChatId 76 | .replace(":chatId", state.chatActive.chatId.toString()); 77 | state.attachmentsInfo = await $fetch(url); 78 | } 79 | 80 | function toNextPage() { 81 | state.nextPage += 1; 82 | } 83 | 84 | function updatePageSize(value: number): boolean { 85 | if (value === state.pageSize) return true; 86 | if (!isNaN(value) && value >= 1 && value <= 2000) { 87 | clearMessages(); 88 | state.pageSize = value; 89 | return true; 90 | } 91 | return false; 92 | } 93 | 94 | return { 95 | ...toRefs(state), 96 | authors, 97 | attachments, 98 | toggleBlur, 99 | moreMessagesPath, 100 | updateMessages, 101 | clearMessages, 102 | toNextPage, 103 | updatePageSize, 104 | chatExited, 105 | openChat, 106 | toChatMessage, 107 | }; 108 | }); 109 | 110 | function attachmentUrl(chatId: number, messageId: number): string { 111 | return useRuntimeConfig().public.api.getAttachmentByChatIdAndMessageId 112 | .replace(':chatId', chatId.toString()) 113 | .replace(':messageId', messageId.toString()); 114 | } 115 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface Chat { 2 | chatId: number 3 | chatName: string 4 | authorName: string 5 | authorType: string, 6 | content: string, 7 | msgCreatedAt: string, 8 | msgCount: number 9 | } 10 | 11 | export interface Attachment { 12 | name: string 13 | type: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'PDF' | 'UNKNOWN' 14 | url: string 15 | } 16 | 17 | export class ChatMessage { 18 | id: number 19 | chatId: number 20 | author: string 21 | authorType: string 22 | content: string 23 | attachment: Attachment | null 24 | createdAt: string 25 | 26 | constructor(data: any, chatId: number) { 27 | this.id = data.id 28 | this.chatId = chatId 29 | this.author = data.author 30 | this.authorType = data.authorType 31 | this.content = data.content 32 | this.createdAt = data.createdAt 33 | const url = useRuntimeConfig().public.api.getAttachmentByChatIdAndMessageId 34 | .replace(':chatId', this.chatId.toString()) 35 | .replace(':messageId', this.id.toString()) 36 | this.attachment = AttachmentConstructor(data.attachmentName, url) 37 | } 38 | } 39 | 40 | export function AttachmentConstructor(attachmentName: string | undefined, url: string): Attachment | null { 41 | if (!attachmentName) { 42 | return null 43 | } 44 | 45 | if (/\.(jpg|jpeg|png|gif|webp)$/i.test(attachmentName)) { 46 | return {name: attachmentName, type: 'IMAGE', url: url} 47 | } else if (/\.(mp4|avi|mov)$/i.test(attachmentName)) { 48 | return {name: attachmentName, type: 'VIDEO', url: url} 49 | } else if (/\.(mp3|wav|opus)$/i.test(attachmentName)) { 50 | return {name: attachmentName, type: 'AUDIO', url: url} 51 | } else if (/\.(pdf)$/i.test(attachmentName)) { 52 | return {name: attachmentName, type: 'PDF', url: url} 53 | } 54 | 55 | return {name: attachmentName, type: 'UNKNOWN', url: url} 56 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitormarcal/chatvault/e834d31a778849455749aa47a233f1c2cfdc13e3/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'chatvault' 2 | 3 | include "backend:application" 4 | include "backend:in-out-boundary" 5 | include "backend:infra:persistence" 6 | include "backend:infra:app-service" 7 | include "backend:infra:web" 8 | include "backend:infra:email" 9 | include "backend:domain" 10 | include "backend:usecase" 11 | include "backend:service" --------------------------------------------------------------------------------