diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d9021c8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.gradle +.gradle-user +.idea +build +out +.git +.github +*.iml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d01573 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + pull_request: + branches: + - master + - develop + push: + branches: + - master + - develop + +permissions: + contents: read + +jobs: + pull-request-rules: + name: Pull Request Rules + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate PR title + env: + BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + if [[ "$BASE_BRANCH" == "develop" && ! "$PR_TITLE" =~ ^DVLP ]]; then + echo "PR title must start with DVLP when targeting develop." + echo "Target branch: $BASE_BRANCH" + echo "Actual title: $PR_TITLE" + exit 1 + fi + + title_length=${#PR_TITLE} + if (( title_length > 120 )); then + echo "PR title must not be longer than 120 characters." + echo "Actual length: $title_length" + exit 1 + fi + + - name: Validate commit titles + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + invalid_commits=$(git log --format='%H%x09%s' "$BASE_SHA..$HEAD_SHA" | awk -F '\t' '$2 !~ /^DVLP/ { print }') + + if [[ -n "$invalid_commits" ]]; then + echo "Every commit title in the PR must start with DVLP." + echo "$invalid_commits" + exit 1 + fi + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Run unit tests + run: ./gradlew test + + checkstyle: + name: Checkstyle + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Run Checkstyle + run: ./gradlew checkstyleMain checkstyleTest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4848709 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM eclipse-temurin:21-jdk AS builder + +WORKDIR /workspace + +COPY gradlew . +COPY gradle gradle +COPY build.gradle . +COPY settings.gradle . +COPY src src +COPY config config + +RUN chmod +x ./gradlew +RUN ./gradlew bootJar --no-daemon + +FROM eclipse-temurin:21-jre + +WORKDIR /app + +COPY --from=builder /workspace/build/libs/*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README.md b/README.md index a6303b2..011ddec 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,440 @@ -# Messaging-System -Messaging System Backend +# Messaging Chat Service + +Backend service for a chat/messaging system. It provides REST APIs for conversations, messages, read/delivery state, and attachment upload flow, plus STOMP over WebSocket for real-time message delivery. + +## Tech Stack + +| Area | Technology | +| --- | --- | +| Language | Java 21 | +| Framework | Spring Boot 4.0.5 | +| Build tool | Gradle | +| REST API | Spring Web / Spring MVC | +| WebSocket | Spring WebSocket + STOMP + SockJS | +| Persistence | Spring Data JPA, Hibernate | +| Database | PostgreSQL 16 | +| Migrations | Liquibase | +| Cache / WebSocket tickets | Redis | +| Messaging | RabbitMQ, Spring AMQP | +| Object storage | AWS S3 SDK v2 | +| API documentation | springdoc-openapi / Swagger UI | +| Mapping | MapStruct | +| Validation | Jakarta Bean Validation | +| Boilerplate reduction | Lombok | +| Testing | Spock, Groovy, Spring Boot Test | +| Containerization | Docker, Docker Compose | +| Code quality | Checkstyle | + +## Main Features + +- Create conversations with multiple participants. +- List conversations for a user with cursor pagination. +- Send text, image, or file messages. +- Fetch messages for a conversation with pagination. +- Track delivered and read message state. +- Send and receive chat events over WebSocket/STOMP. +- Generate S3 presigned upload URLs for attachments. +- Confirm uploaded attachments by checking S3 object metadata. +- Publish message notification events to RabbitMQ after database commit. +- Manage schema changes through Liquibase migrations. + +## Project Structure + +```text +. ++-- src/main/java/com/messaging/chat +| +-- config # WebSocket, RabbitMQ, S3, OpenAPI configuration +| +-- controller # REST and WebSocket controllers +| +-- dao # JPA entities and repositories +| +-- mapper # MapStruct mappers +| +-- model # DTOs, constants, exceptions +| +-- service # Business logic +| +-- util # Utility classes ++-- src/main/resources +| +-- application.yaml +| +-- db/changelog # Liquibase changelogs ++-- build.gradle ++-- Dockerfile ++-- docker-compose.yml +``` + +## Prerequisites + +- Java 21 +- Docker and Docker Compose +- Gradle wrapper included in the project +- Optional: an AWS S3 bucket, LocalStack, or MinIO-compatible setup for attachment upload testing + +## Running With Docker Compose + +Build and start all Compose services: + +```bash +docker compose up --build +``` + +The Compose file starts: + +| Service | URL / Port | +| --- | --- | +| Chat service | `http://localhost:8080` | +| PostgreSQL | `localhost:5432` | +| Redis | `localhost:6379` | +| RabbitMQ AMQP | `localhost:5672` | +| RabbitMQ Management UI | `http://localhost:15672` | + +RabbitMQ default credentials: + +```text +guest / guest +``` + +Stop services: + +```bash +docker compose down +``` + +Stop services and remove volumes: + +```bash +docker compose down -v +``` + +## Running Locally Without Dockerizing the App + +Start infrastructure only: + +```bash +docker compose up postgres redis rabbitmq +``` + +Then run the app from the project root: + +```bash +./gradlew bootRun +``` + +On Windows PowerShell: + +```powershell +.\gradlew.bat bootRun +``` + +By default, `application.yaml` points PostgreSQL to `localhost:5432`. When the app runs inside Docker, `docker-compose.yml` overrides this to use the Compose service name `postgres`. + +## Configuration + +Spring Boot loads configuration from `src/main/resources/application.yaml`. Environment variables from Docker Compose override those values at startup through Spring Boot externalized configuration. + +For example: + +```yaml +SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/chat +``` + +maps to: + +```yaml +spring: + datasource: + url: jdbc:postgresql://postgres:5432/chat +``` + +Important environment variables used by `chat-service`: + +| Environment variable | Spring property | Purpose | +| --- | --- | --- | +| `SPRING_DATASOURCE_URL` | `spring.datasource.url` | PostgreSQL JDBC URL | +| `SPRING_DATASOURCE_USERNAME` | `spring.datasource.username` | PostgreSQL username | +| `SPRING_DATASOURCE_PASSWORD` | `spring.datasource.password` | PostgreSQL password | +| `SPRING_RABBITMQ_HOST` | `spring.rabbitmq.host` | RabbitMQ host | +| `SPRING_RABBITMQ_PORT` | `spring.rabbitmq.port` | RabbitMQ port | +| `SPRING_RABBITMQ_USERNAME` | `spring.rabbitmq.username` | RabbitMQ username | +| `SPRING_RABBITMQ_PASSWORD` | `spring.rabbitmq.password` | RabbitMQ password | +| `SPRING_DATA_REDIS_HOST` | `spring.data.redis.host` | Redis host | +| `SPRING_DATA_REDIS_PORT` | `spring.data.redis.port` | Redis port | +| `STORAGE_S3_BUCKET_NAME` | `storage.s3.bucket-name` | S3 bucket for attachments | +| `STORAGE_S3_REGION` | `storage.s3.region` | S3 region | +| `STORAGE_S3_ACCESS_KEY_ID` | `storage.s3.access-key-id` | S3 access key | +| `STORAGE_S3_SECRET_ACCESS_KEY` | `storage.s3.secret-access-key` | S3 secret key | +| `RABBITMQ_NOTIFICATION_QUEUE` | `rabbitmq.notification-queue` | Notification queue name | +| `RABBITMQ_NOTIFICATION_EXCHANGE` | `rabbitmq.notification-exchange` | Notification exchange name | +| `RABBITMQ_NOTIFICATION_ROUTING_KEY` | `rabbitmq.notification-routing-key` | Notification routing key | +| `RABBITMQ_NOTIFICATION_DLQ` | `rabbitmq.notification-dlq` | Dead-letter queue name | +| `RABBITMQ_NOTIFICATION_DLX` | `rabbitmq.notification-dlx` | Dead-letter exchange name | + +Note: `docker-compose.yml` does not currently start an S3-compatible service. The S3 values in Compose are placeholders unless you connect them to a real S3 bucket or add a local S3-compatible service such as LocalStack or MinIO. + +## API Documentation + +When the app is running, OpenAPI documentation is available at: + +```text +http://localhost:8080/swagger-ui/index.html +``` + +The generated OpenAPI JSON is available at: + +```text +http://localhost:8080/v3/api-docs +``` + +## REST API Overview + +All REST endpoints use the `userId` request header to identify the current user. + +### Conversations + +Create a conversation: + +```http +POST /ms-chat/conversations +userId: 1 +Content-Type: application/json + +{ + "participantUserIds": [2, 3] +} +``` + +List conversations: + +```http +GET /ms-chat/conversations?limit=20&cursor=50 +userId: 1 +``` + +List messages in a conversation: + +```http +GET /ms-chat/conversations/20/messages?limit=30&beforeMessageId=171 +userId: 1 +``` + +Send a message: + +```http +POST /ms-chat/conversations/20/messages +userId: 1 +Content-Type: application/json + +{ + "clientMessageId": "550e8400-e29b-41d4-a716-446655440000", + "type": "TEXT", + "textContent": "Hello", + "attachmentIds": [] +} +``` + +Mark messages delivered: + +```http +POST /ms-chat/conversations/20/delivery +userId: 1 +Content-Type: application/json + +{ + "messageId": 100 +} +``` + +Mark messages read: + +```http +POST /ms-chat/conversations/20/read +userId: 1 +Content-Type: application/json + +{ + "messageId": 100 +} +``` + +Supported message types: + +```text +TEXT, IMAGE, FILE +``` + +### Attachments + +Create a presigned upload URL: + +```http +POST /ms-chat/attachments/presign-upload +userId: 1 +Content-Type: application/json + +{ + "fileName": "photo.png", + "contentType": "image/png", + "fileSize": 1048576, + "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==" +} +``` + +The response contains: + +- `attachmentId` +- `storageKey` +- `uploadUrl` +- HTTP method, currently `PUT` +- expiration time +- required upload headers + +After uploading the file to S3 with the returned URL, confirm the attachment: + +```http +POST /ms-chat/attachments/10/confirm +userId: 1 +``` + +Attachment rules: + +- Maximum file size is less than 5 MB. +- Executable content types are rejected. +- Executable file extensions are rejected. +- Confirmation checks that the uploaded S3 object size and content type match the presigned request. + +## WebSocket / STOMP + +WebSocket endpoint: + +```text +/ws-chat +``` + +SockJS is enabled. + +The connection requires a query parameter: + +```text +/ws-chat?ticket= +``` + +The ticket is resolved from Redis using this key format: + +```text +chat:ws-ticket: +``` + +The Redis value must be the user id. The ticket is consumed with `GETDEL`, so it is single-use. + +Application destination prefix: + +```text +/app +``` + +User destination prefix: + +```text +/user +``` + +Client sends messages to: + +| Action | STOMP destination | +| --- | --- | +| Send message | `/app/conversations/{conversationId}/messages` | +| Mark delivered | `/app/conversations/{conversationId}/delivery` | +| Mark read | `/app/conversations/{conversationId}/read` | + +Client subscribes to user queues: + +| Event | STOMP subscription | +| --- | --- | +| New messages | `/user/queue/messages` | +| Delivery updates | `/user/queue/delivery` | +| Read updates | `/user/queue/read` | + +## RabbitMQ Notifications + +When a message is sent, the service publishes notification events after the database transaction commits. + +Configured RabbitMQ components: + +- Notification queue +- Topic exchange +- Routing key binding +- Dead-letter queue +- Dead-letter exchange + +The queue and exchange names are configured through `rabbitmq.notification-*` properties. + +## Database Migrations + +Liquibase runs automatically on application startup. + +Master changelog: + +```text +src/main/resources/db/changelog/db.changelog-master.yaml +``` + +The application uses: + +```yaml +spring: + jpa: + hibernate: + ddl-auto: none +``` + +That means Hibernate does not create/update schema automatically; schema changes should be added through Liquibase changelogs. + +## Build, Test, and Quality Checks + +Build the project: + +```bash +./gradlew build +``` + +Run tests: + +```bash +./gradlew test +``` + +Run Checkstyle: + +```bash +./gradlew checkstyleMain checkstyleTest +``` + +Create the Spring Boot jar: + +```bash +./gradlew bootJar +``` + +On Windows PowerShell, use `.\gradlew.bat` instead of `./gradlew`. + +## Docker Image + +The `Dockerfile` uses a multi-stage build: + +1. `eclipse-temurin:21-jdk` builds the Spring Boot jar. +2. `eclipse-temurin:21-jre` runs the jar. + +The app listens on port `8080`. + +## Useful Local URLs + +| Tool | URL | +| --- | --- | +| Application | `http://localhost:8080` | +| Swagger UI | `http://localhost:8080/swagger-ui/index.html` | +| OpenAPI JSON | `http://localhost:8080/v3/api-docs` | +| Actuator health | `http://localhost:8080/actuator/health` | +| RabbitMQ UI | `http://localhost:15672` | + +## Development Notes + +- REST requests use the `userId` header instead of a full authentication system. +- WebSocket authentication uses a single-use Redis ticket. +- S3 presigned upload URLs expire after `storage.s3.presign-duration-minutes`, currently configured as `5`. +- Message notification publishing is transactional: RabbitMQ publishing runs after the database commit. +- Docker Compose service names are used for container-to-container networking, for example `postgres`, `redis`, and `rabbitmq`. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..bd152fa --- /dev/null +++ b/build.gradle @@ -0,0 +1,60 @@ +plugins { + id 'java' + id 'groovy' + id 'checkstyle' + id 'org.springframework.boot' version '4.0.5' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.messaging' +version = '0.0.1-SNAPSHOT' +ext['groovy.version'] = '4.0.26' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation platform('software.amazon.awssdk:bom:2.32.12') + implementation 'software.amazon.awssdk:s3' + implementation 'ch.qos.logback:logback-classic' + implementation 'org.mapstruct:mapstruct:1.6.3' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.liquibase:liquibase-core' + implementation 'org.springframework.boot:spring-boot-liquibase' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' + runtimeOnly 'org.postgresql:postgresql' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + annotationProcessor 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' + testCompileOnly 'org.projectlombok:lombok' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testAnnotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + testAnnotationProcessor 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.spockframework:spock-core:2.4-M6-groovy-4.0' + testImplementation 'org.spockframework:spock-spring:2.4-M6-groovy-4.0' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-amqp' +} + +tasks.named('test') { + useJUnitPlatform() +} + +checkstyle { + toolVersion = '10.21.4' + configFile = file('config/checkstyle/checkstyle.xml') +} diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..5f9c8f2 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..696a672 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,86 @@ +services: + postgres: + image: postgres:16-alpine + container_name: chat-postgres + environment: + POSTGRES_DB: chat + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d chat"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: chat-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + rabbitmq: + image: rabbitmq:3-management-alpine + container_name: chat-rabbitmq + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq-data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + chat-service: + build: + context: . + dockerfile: Dockerfile + container_name: chat-service + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/chat + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: postgres + SPRING_RABBITMQ_HOST: rabbitmq + SPRING_RABBITMQ_PORT: 5672 + SPRING_RABBITMQ_USERNAME: guest + SPRING_RABBITMQ_PASSWORD: guest + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 + STORAGE_S3_BUCKET_NAME: local-chat-bucket + STORAGE_S3_REGION: us-east-1 + STORAGE_S3_ACCESS_KEY_ID: local-access-key + STORAGE_S3_SECRET_ACCESS_KEY: local-secret-key + RABBITMQ_NOTIFICATION_QUEUE: notificationQueue + RABBITMQ_NOTIFICATION_EXCHANGE: notificationExchange + RABBITMQ_NOTIFICATION_ROUTING_KEY: notificationRouting + RABBITMQ_NOTIFICATION_DLQ: notificationDlq + RABBITMQ_NOTIFICATION_DLX: notificationDlx + ports: + - "8080:8080" + +volumes: + postgres-data: + redis-data: + rabbitmq-data: diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..9e919f7 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'chat' diff --git a/src/main/java/com/messaging/chat/ChatApplication.java b/src/main/java/com/messaging/chat/ChatApplication.java new file mode 100644 index 0000000..e70db4d --- /dev/null +++ b/src/main/java/com/messaging/chat/ChatApplication.java @@ -0,0 +1,13 @@ +package com.messaging.chat; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ChatApplication { + + public static void main(String[] args) { + SpringApplication.run(ChatApplication.class, args); + } + +} diff --git a/src/main/java/com/messaging/chat/config/OpenApiConfig.java b/src/main/java/com/messaging/chat/config/OpenApiConfig.java new file mode 100644 index 0000000..f34ce56 --- /dev/null +++ b/src/main/java/com/messaging/chat/config/OpenApiConfig.java @@ -0,0 +1,16 @@ +package com.messaging.chat.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "Chat API", + version = "v1", + description = "REST API for chat conversations and attachment uploads." + ) +) +public class OpenApiConfig { +} diff --git a/src/main/java/com/messaging/chat/config/RabbitMQConfig.java b/src/main/java/com/messaging/chat/config/RabbitMQConfig.java new file mode 100644 index 0000000..62dbd6d --- /dev/null +++ b/src/main/java/com/messaging/chat/config/RabbitMQConfig.java @@ -0,0 +1,78 @@ +package com.messaging.chat.config; + +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.DirectExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.QueueBuilder; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.support.converter.JacksonJsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +public class RabbitMQConfig { + + @Value("${rabbitmq.notification-queue}") + private String queueName; + + @Value("${rabbitmq.notification-exchange}") + private String exchangeName; + + @Value("${rabbitmq.notification-routing-key}") + private String routingKey; + + @Value("${rabbitmq.notification-dlq}") + private String deadLetterQueueName; + + @Value("${rabbitmq.notification-dlx}") + private String deadLetterExchangeName; + + private static final String DEAD_LETTER_ROUTING_KEY = "notificationDlqRouting"; + + + @Bean + public Queue queue() { + return QueueBuilder.nonDurable(queueName) + .deadLetterExchange(deadLetterExchangeName) + .deadLetterRoutingKey(DEAD_LETTER_ROUTING_KEY) + .build(); + } + + @Bean + public TopicExchange exchange() { + return new TopicExchange(exchangeName); + } + + @Bean + public Binding binding(Queue queue, TopicExchange exchange) { + return BindingBuilder.bind(queue).to(exchange).with(routingKey); + } + + @Bean + public Queue deadLetterQueue() { + return new Queue(deadLetterQueueName, false); + } + + @Bean + public DirectExchange deadLetterExchange() { + return new DirectExchange(deadLetterExchangeName); + } + + @Bean + public Binding deadLetterBinding( + Queue deadLetterQueue, + DirectExchange deadLetterExchange) { + return BindingBuilder.bind(deadLetterQueue) + .to(deadLetterExchange) + .with(DEAD_LETTER_ROUTING_KEY); + } + + @Bean + public MessageConverter messageConverter() { + return new JacksonJsonMessageConverter(); + } +} diff --git a/src/main/java/com/messaging/chat/config/S3Config.java b/src/main/java/com/messaging/chat/config/S3Config.java new file mode 100644 index 0000000..6a3bfd4 --- /dev/null +++ b/src/main/java/com/messaging/chat/config/S3Config.java @@ -0,0 +1,41 @@ +package com.messaging.chat.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3Config { + + @Value("${storage.s3.region}") + private String region; + + @Value("${storage.s3.access-key-id}") + private String accessKeyId; + + @Value("${storage.s3.secret-access-key}") + private String secretAccessKey; + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKeyId, secretAccessKey))) + .build(); + } + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKeyId, secretAccessKey))) + .build(); + } +} diff --git a/src/main/java/com/messaging/chat/config/WebSocketConfig.java b/src/main/java/com/messaging/chat/config/WebSocketConfig.java new file mode 100644 index 0000000..8caa462 --- /dev/null +++ b/src/main/java/com/messaging/chat/config/WebSocketConfig.java @@ -0,0 +1,33 @@ +package com.messaging.chat.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@RequiredArgsConstructor +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final WebSocketHandshakeInterceptor webSocketHandshakeInterceptor; + private final WebSocketUserHandshakeHandler webSocketUserHandshakeHandler; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws-chat") + .addInterceptors(webSocketHandshakeInterceptor) + .setHandshakeHandler(webSocketUserHandshakeHandler) + .setAllowedOriginPatterns("*") + .withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/app"); + registry.enableSimpleBroker("/topic", "/queue"); + registry.setUserDestinationPrefix("/user"); + } +} diff --git a/src/main/java/com/messaging/chat/config/WebSocketHandshakeInterceptor.java b/src/main/java/com/messaging/chat/config/WebSocketHandshakeInterceptor.java new file mode 100644 index 0000000..f5e2771 --- /dev/null +++ b/src/main/java/com/messaging/chat/config/WebSocketHandshakeInterceptor.java @@ -0,0 +1,54 @@ +package com.messaging.chat.config; + +import com.messaging.chat.service.WebSocketTicketService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class WebSocketHandshakeInterceptor implements HandshakeInterceptor { + + public static final String USER_ID_ATTRIBUTE = "userId"; + private static final String TICKET_PARAM = "ticket"; + + private final WebSocketTicketService webSocketTicketService; + + @Override + public boolean beforeHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Map attributes + ) { + String ticket = UriComponentsBuilder.fromUri(request.getURI()) + .build() + .getQueryParams() + .getFirst(TICKET_PARAM); + + if (!StringUtils.hasText(ticket)) { + return false; + } + + Optional userId = webSocketTicketService.resolveUserId(ticket); + userId.ifPresent(id -> attributes.put(USER_ID_ATTRIBUTE, id.toString())); + return userId.isPresent(); + } + + @Override + public void afterHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Exception exception + ) { + } +} diff --git a/src/main/java/com/messaging/chat/config/WebSocketUserHandshakeHandler.java b/src/main/java/com/messaging/chat/config/WebSocketUserHandshakeHandler.java new file mode 100644 index 0000000..e98d2f3 --- /dev/null +++ b/src/main/java/com/messaging/chat/config/WebSocketUserHandshakeHandler.java @@ -0,0 +1,23 @@ +package com.messaging.chat.config; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +import java.security.Principal; +import java.util.Map; + +@Component +public class WebSocketUserHandshakeHandler extends DefaultHandshakeHandler { + + @Override + protected Principal determineUser( + ServerHttpRequest request, + WebSocketHandler wsHandler, + Map attributes + ) { + String userId = (String) attributes.get(WebSocketHandshakeInterceptor.USER_ID_ATTRIBUTE); + return () -> userId; + } +} diff --git a/src/main/java/com/messaging/chat/controller/AttachmentController.java b/src/main/java/com/messaging/chat/controller/AttachmentController.java new file mode 100644 index 0000000..bd033a9 --- /dev/null +++ b/src/main/java/com/messaging/chat/controller/AttachmentController.java @@ -0,0 +1,87 @@ +package com.messaging.chat.controller; + +import com.messaging.chat.model.dto.request.PresignUploadRequest; +import com.messaging.chat.model.dto.response.AttachmentConfirmResponse; +import com.messaging.chat.model.dto.response.PresignUploadResponse; +import com.messaging.chat.service.AttachmentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.messaging.chat.model.constant.Headers.USER_ID_HEADER; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/ms-chat/attachments") +@Tag(name = "Attachments", description = "Create presigned upload URLs and confirm uploaded files.") +public class AttachmentController { + + private final AttachmentService attachmentService; + + @PostMapping(path = "/presign-upload") + @Operation( + summary = "Create presigned upload URL", + description = "Validates file metadata, saves a pending attachment record, and returns an S3 PUT URL.", + parameters = @Parameter( + name = USER_ID_HEADER, + description = "Current user id", + required = true, + in = ParameterIn.HEADER, + example = "1" + ) + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Presigned upload URL created"), + @ApiResponse(responseCode = "400", description = "Invalid file metadata"), + @ApiResponse(responseCode = "500", description = "Upload URL could not be created") + }) + public PresignUploadResponse presignUpload( + @RequestHeader(USER_ID_HEADER) Long userId, + @Valid @RequestBody PresignUploadRequest presignUploadRequest + ) { + return attachmentService.presignUpload(userId, presignUploadRequest); + } + + @PostMapping(path = "/{attachmentId}/confirm") + @Operation( + summary = "Confirm uploaded attachment", + description = "Checks S3 to confirm the object was uploaded, then changes attachment status to UPLOADED.", + parameters = { + @Parameter( + name = USER_ID_HEADER, + description = "Current user id", + required = true, + in = ParameterIn.HEADER, + example = "1" + ), + @Parameter( + name = "attachmentId", + description = "Attachment id returned by presign-upload", + required = true, + example = "10" + ) + } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Attachment confirmed"), + @ApiResponse(responseCode = "400", description = "File was not uploaded or metadata does not match"), + @ApiResponse(responseCode = "404", description = "Attachment not found") + }) + public AttachmentConfirmResponse confirmUpload( + @RequestHeader(USER_ID_HEADER) Long userId, + @PathVariable Long attachmentId + ) { + return attachmentService.confirmUpload(userId, attachmentId); + } +} diff --git a/src/main/java/com/messaging/chat/controller/ChatWebSocketController.java b/src/main/java/com/messaging/chat/controller/ChatWebSocketController.java new file mode 100644 index 0000000..b36dec3 --- /dev/null +++ b/src/main/java/com/messaging/chat/controller/ChatWebSocketController.java @@ -0,0 +1,89 @@ +package com.messaging.chat.controller; + +import com.messaging.chat.dao.repository.ConversationRepository; +import com.messaging.chat.model.dto.request.MessageStateRequest; +import com.messaging.chat.model.dto.request.SendMessageRequest; +import com.messaging.chat.model.dto.response.MessageResponse; +import com.messaging.chat.model.dto.response.MessageStateResponse; +import com.messaging.chat.service.MessageService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +import java.security.Principal; +import java.util.List; + +@Controller +@RequiredArgsConstructor +public class ChatWebSocketController { + + private static final String MESSAGES_QUEUE = "/queue/messages"; + private static final String DELIVERY_QUEUE = "/queue/delivery"; + private static final String READ_QUEUE = "/queue/read"; + + private final MessageService messageService; + private final ConversationRepository conversationRepository; + private final SimpMessagingTemplate messagingTemplate; + + @MessageMapping("/conversations/{conversationId}/messages") + public void sendMessage( + Principal principal, + @DestinationVariable Long conversationId, + @Valid @Payload SendMessageRequest sendMessageRequest + ) { + Long userId = getUserId(principal); + MessageResponse messageResponse = messageService.sendMessage(userId, conversationId, sendMessageRequest); + + sendToConversationParticipants(conversationId, MESSAGES_QUEUE, messageResponse); + } + + @MessageMapping("/conversations/{conversationId}/delivery") + public void markDelivered( + Principal principal, + @DestinationVariable Long conversationId, + @Valid @Payload MessageStateRequest messageStateRequest + ) { + Long userId = getUserId(principal); + MessageStateResponse messageStateResponse = messageService.markDelivered( + userId, + conversationId, + messageStateRequest.messageId() + ); + + sendToConversationParticipants(conversationId, DELIVERY_QUEUE, messageStateResponse); + } + + @MessageMapping("/conversations/{conversationId}/read") + public void markRead( + Principal principal, + @DestinationVariable Long conversationId, + @Valid @Payload MessageStateRequest messageStateRequest + ) { + Long userId = getUserId(principal); + MessageStateResponse messageStateResponse = messageService.markRead( + userId, + conversationId, + messageStateRequest.messageId() + ); + + sendToConversationParticipants(conversationId, READ_QUEUE, messageStateResponse); + } + + private void sendToConversationParticipants(Long conversationId, String destination, Object payload) { + List participantUserIds = conversationRepository.findParticipantUserIdsByConversationId(conversationId); + + participantUserIds.forEach(participantUserId -> messagingTemplate.convertAndSendToUser( + participantUserId.toString(), + destination, + payload + )); + } + + private Long getUserId(Principal principal) { + return Long.valueOf(principal.getName()); + } +} diff --git a/src/main/java/com/messaging/chat/controller/ConversationController.java b/src/main/java/com/messaging/chat/controller/ConversationController.java new file mode 100644 index 0000000..5fdc2a6 --- /dev/null +++ b/src/main/java/com/messaging/chat/controller/ConversationController.java @@ -0,0 +1,244 @@ +package com.messaging.chat.controller; + + +import com.messaging.chat.model.dto.request.CreateConversationRequest; +import com.messaging.chat.model.dto.request.MessageStateRequest; +import com.messaging.chat.model.dto.request.SendMessageRequest; +import com.messaging.chat.model.dto.response.ConversationListResponse; +import com.messaging.chat.model.dto.response.ConversationResponse; +import com.messaging.chat.model.dto.response.MessageResponse; +import com.messaging.chat.model.dto.response.MessageStateResponse; +import com.messaging.chat.service.ConversationService; +import com.messaging.chat.service.MessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static com.messaging.chat.model.constant.Headers.USER_ID_HEADER; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/ms-chat/conversations") +@Tag(name = "Conversations", description = "Create conversations and fetch conversation lists.") +public class ConversationController { + + private final ConversationService conversationService; + private final MessageService messageService; + + @PostMapping + @Operation( + summary = "Create conversation", + description = "Creates a conversation including the current user and the requested participants.", + parameters = @Parameter( + name = USER_ID_HEADER, + description = "Current user id", + required = true, + in = ParameterIn.HEADER, + example = "1" + ) + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Conversation created"), + @ApiResponse(responseCode = "400", description = "Invalid request body") + }) + public ConversationResponse createConversations( + @RequestHeader(USER_ID_HEADER) Long userId, + @Valid @RequestBody CreateConversationRequest createConversationRequest) { + + return conversationService.createConversations(userId, createConversationRequest); + } + + @GetMapping + @Operation( + summary = "List conversations", + description = "Returns conversations for the current user with cursor pagination.", + parameters = { + @Parameter( + name = USER_ID_HEADER, + description = "Current user id", + required = true, + in = ParameterIn.HEADER, + example = "1" + ), + @Parameter( + name = "limit", + description = "Maximum number of conversations to return", + example = "20" + ), + @Parameter( + name = "cursor", + description = "Last seen conversation id for pagination", + example = "50" + ) + } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200",description = "Conversation list returned") + }) + public List getConversationList( + @RequestHeader(USER_ID_HEADER) Long userId, + @RequestParam(defaultValue = "20") Integer limit, + @RequestParam(required = false) Integer cursor + ) { + + return conversationService.getConversationList(userId, limit, cursor); + } + + @GetMapping(path = "/{conversationId}/messages") + @Operation( + summary = "List conversation messages", + description = "Returns the latest messages or messages before a given message id, ordered oldest to newest.", + parameters = { + @Parameter( + name = USER_ID_HEADER, + description = "Current user id", + required = true, + in = ParameterIn.HEADER, + example = "1" + ), + @Parameter( + name = "conversationId", + description = "Conversation id", + required = true, + example = "20" + ), + @Parameter( + name = "beforeMessageId", + description = "Fetch messages older than this message id", + example = "171" + ), + @Parameter( + name = "limit", + description = "Maximum number of messages to return", + example = "30" + ) + } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Messages returned"), + @ApiResponse(responseCode = "403", description = "User is not a conversation participant") + }) + public List getMessages( + @RequestHeader(USER_ID_HEADER) Long userId, + @PathVariable Long conversationId, + @RequestParam(required = false) Long beforeMessageId, + @RequestParam(defaultValue = "30") Integer limit + ) { + return messageService.getMessages(userId, conversationId, beforeMessageId, limit); + } + + @PostMapping(path = "/{conversationId}/messages") + @Operation( + summary = "Send message", + description = "Creates a message in a conversation and links uploaded attachments if provided.", + parameters = { + @Parameter( + name = USER_ID_HEADER, + description = "Current user id", + required = true, + in = ParameterIn.HEADER, + example = "1" + ), + @Parameter( + name = "conversationId", + description = "Conversation id", + required = true, + example = "20" + ) + } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Message created"), + @ApiResponse(responseCode = "400", description = "Invalid request body or attachment state"), + @ApiResponse(responseCode = "403", description = "User is not a conversation participant"), + @ApiResponse(responseCode = "404", description = "Conversation or attachment not found") + }) + public MessageResponse sendMessage( + @RequestHeader(USER_ID_HEADER) Long userId, + @PathVariable Long conversationId, + @Valid @RequestBody SendMessageRequest sendMessageRequest + ) { + return messageService.sendMessage(userId, conversationId, sendMessageRequest); + } + + @PostMapping(path = "/{conversationId}/delivery") + @Operation( + summary = "Mark messages delivered", + description = "Updates the user's last delivered message for the conversation.", + parameters = { + @Parameter( + name = USER_ID_HEADER, + description = "Current user id", + required = true, + in = ParameterIn.HEADER, + example = "1" + ), + @Parameter( + name = "conversationId", + description = "Conversation id", + required = true, + example = "20" + ) + } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Delivery state updated"), + @ApiResponse(responseCode = "403", description = "User is not a conversation participant"), + @ApiResponse(responseCode = "404", description = "Conversation or message not found") + }) + public MessageStateResponse markDelivered( + @RequestHeader(USER_ID_HEADER) Long userId, + @PathVariable Long conversationId, + @Valid @RequestBody MessageStateRequest messageStateRequest + ) { + return messageService.markDelivered(userId, conversationId, messageStateRequest.messageId()); + } + + @PostMapping(path = "/{conversationId}/read") + @Operation( + summary = "Mark messages read", + description = "Updates the user's last read message for the conversation.", + parameters = { + @Parameter( + name = USER_ID_HEADER, + description = "Current user id", + required = true, + in = ParameterIn.HEADER, + example = "1" + ), + @Parameter( + name = "conversationId", + description = "Conversation id", + required = true, + example = "20" + ) + } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Read state updated"), + @ApiResponse(responseCode = "403", description = "User is not a conversation participant"), + @ApiResponse(responseCode = "404", description = "Conversation or message not found") + }) + public MessageStateResponse markRead( + @RequestHeader(USER_ID_HEADER) Long userId, + @PathVariable Long conversationId, + @Valid @RequestBody MessageStateRequest messageStateRequest + ) { + return messageService.markRead(userId, conversationId, messageStateRequest.messageId()); + } +} diff --git a/src/main/java/com/messaging/chat/controller/ErrorHandler.java b/src/main/java/com/messaging/chat/controller/ErrorHandler.java new file mode 100644 index 0000000..550320b --- /dev/null +++ b/src/main/java/com/messaging/chat/controller/ErrorHandler.java @@ -0,0 +1,56 @@ +package com.messaging.chat.controller; + +import com.messaging.chat.model.exceptions.FileValidationException; +import com.messaging.chat.model.exceptions.ResourceNotFound; +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class ErrorHandler { + + @ExceptionHandler(ResourceNotFound.class) + public ResponseEntity> handleResourceNotFound(ResourceNotFound exception) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("message", exception.getMessage())); + } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatusException(ResponseStatusException exception) { + return ResponseEntity.status(exception.getStatusCode()) + .body(Map.of("message", exception.getReason() != null ? exception.getReason() : "Request failed")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception exception) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", "Internal server error")); + } + + @ExceptionHandler(FileValidationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleFileValidationException(FileValidationException exception) { + return Map.of("message", exception.getMessage()); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public Map handleValidationExceptions( + MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach((error) -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + return errors; + } +} diff --git a/src/main/java/com/messaging/chat/dao/entity/Attachment.java b/src/main/java/com/messaging/chat/dao/entity/Attachment.java new file mode 100644 index 0000000..81ca083 --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/entity/Attachment.java @@ -0,0 +1,54 @@ +package com.messaging.chat.dao.entity; + +import com.messaging.chat.model.constant.FileStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "attachments") +@Getter +@Setter +public class Attachment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "message_id") + private Message message; + + @Column(name = "uploader_id") + private Long uploaderId; + + @Column(name = "storage_key") + private String storageKey; + + @Column(name = "original_file_name") + private String originalFileName; + + @Column(name = "content_type") + private String contentType; + + @Column(name = "file_size") + private Long fileSize; + + @Column(name = "checksum") + private String checksum; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private FileStatus status; + +} diff --git a/src/main/java/com/messaging/chat/dao/entity/BaseEntity.java b/src/main/java/com/messaging/chat/dao/entity/BaseEntity.java new file mode 100644 index 0000000..59f77ff --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/entity/BaseEntity.java @@ -0,0 +1,22 @@ +package com.messaging.chat.dao.entity; + + +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Getter +@Setter +@MappedSuperclass +public class BaseEntity { + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/messaging/chat/dao/entity/Conversation.java b/src/main/java/com/messaging/chat/dao/entity/Conversation.java new file mode 100644 index 0000000..2875257 --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/entity/Conversation.java @@ -0,0 +1,38 @@ +package com.messaging.chat.dao.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "conversations") +@Getter +@Setter +public class Conversation extends BaseEntity{ + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List conversationParticipants = new ArrayList<>(); + + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List messages = new ArrayList<>(); + + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List conversationDeliveryStates = new ArrayList<>(); + + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List readDeliveryStates = new ArrayList<>(); +} diff --git a/src/main/java/com/messaging/chat/dao/entity/ConversationDeliveryState.java b/src/main/java/com/messaging/chat/dao/entity/ConversationDeliveryState.java new file mode 100644 index 0000000..376ac6d --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/entity/ConversationDeliveryState.java @@ -0,0 +1,35 @@ +package com.messaging.chat.dao.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "conversation_delivery_state") +@Getter +@Setter +public class ConversationDeliveryState extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "conversation_id") + private Conversation conversation; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "last_delivered_message_id") + private Message lastMessageId; +} diff --git a/src/main/java/com/messaging/chat/dao/entity/ConversationParticipant.java b/src/main/java/com/messaging/chat/dao/entity/ConversationParticipant.java new file mode 100644 index 0000000..3dd9ff3 --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/entity/ConversationParticipant.java @@ -0,0 +1,32 @@ +package com.messaging.chat.dao.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "conversation_participants", uniqueConstraints = @UniqueConstraint(columnNames = {"conversation_id", "user_id"})) +@Getter +@Setter +public class ConversationParticipant extends BaseEntity{ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "conversation_id") + private Conversation conversation; + + @Column(name = "user_id") + private Long userId; +} diff --git a/src/main/java/com/messaging/chat/dao/entity/Message.java b/src/main/java/com/messaging/chat/dao/entity/Message.java new file mode 100644 index 0000000..bb22cae --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/entity/Message.java @@ -0,0 +1,53 @@ +package com.messaging.chat.dao.entity; + +import com.messaging.chat.model.constant.MessageType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "messages") +@Getter +@Setter +public class Message extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "conversation_id", nullable = false) + private Conversation conversation; + + @Column(name = "sender_id") + private Long senderId; + + @Column(name = "client_message_id") + private String clientMessageId; + + @Column(name = "type") + @Enumerated(EnumType.STRING) + private MessageType type; + + @Column(name = "text_content") + private String textContent; + + @OneToMany(mappedBy = "message", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List attachments = new ArrayList<>(); + +} diff --git a/src/main/java/com/messaging/chat/dao/entity/ReadDeliveryState.java b/src/main/java/com/messaging/chat/dao/entity/ReadDeliveryState.java new file mode 100644 index 0000000..e523476 --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/entity/ReadDeliveryState.java @@ -0,0 +1,35 @@ +package com.messaging.chat.dao.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "read_delivery_state") +@Getter +@Setter +public class ReadDeliveryState extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "conversation_id") + private Conversation conversation; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "last_read_message_id") + private Message lastMessageId; +} diff --git a/src/main/java/com/messaging/chat/dao/repository/AttachmentRepository.java b/src/main/java/com/messaging/chat/dao/repository/AttachmentRepository.java new file mode 100644 index 0000000..6c60489 --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/repository/AttachmentRepository.java @@ -0,0 +1,14 @@ +package com.messaging.chat.dao.repository; + +import com.messaging.chat.dao.entity.Attachment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +@Repository +public interface AttachmentRepository extends JpaRepository { + + List findAllByIdIn(Collection attachmentIds); +} diff --git a/src/main/java/com/messaging/chat/dao/repository/ConversationDeliveryStateRepository.java b/src/main/java/com/messaging/chat/dao/repository/ConversationDeliveryStateRepository.java new file mode 100644 index 0000000..c7d1e08 --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/repository/ConversationDeliveryStateRepository.java @@ -0,0 +1,13 @@ +package com.messaging.chat.dao.repository; + +import com.messaging.chat.dao.entity.ConversationDeliveryState; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ConversationDeliveryStateRepository extends JpaRepository { + + Optional findByConversationIdAndUserId(Long conversationId, Long userId); +} diff --git a/src/main/java/com/messaging/chat/dao/repository/ConversationRepository.java b/src/main/java/com/messaging/chat/dao/repository/ConversationRepository.java new file mode 100644 index 0000000..191329c --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/repository/ConversationRepository.java @@ -0,0 +1,45 @@ +package com.messaging.chat.dao.repository; + +import com.messaging.chat.dao.entity.Conversation; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ConversationRepository extends JpaRepository { + + boolean existsByIdAndConversationParticipantsUserId(Long conversationId, Long userId); + + @Query(""" + select cp.userId + from Conversation c + join c.conversationParticipants cp + where c.id = :conversationId + """) + List findParticipantUserIdsByConversationId(@Param("conversationId") Long conversationId); + + @Query(""" + select distinct c + from Conversation c + join c.conversationParticipants cp + where cp.userId = :userId + order by c.id desc + """) + List findConversationListByUserId(@Param("userId") Long userId, Pageable pageable); + + @Query(""" + select distinct c + from Conversation c + join c.conversationParticipants cp + where cp.userId = :userId + and c.id < :cursor + order by c.id desc + """) + List findConversationListByUserIdAndCursor(@Param("userId") Long userId, + @Param("cursor") Long cursor, + Pageable pageable); +} diff --git a/src/main/java/com/messaging/chat/dao/repository/MessageRepository.java b/src/main/java/com/messaging/chat/dao/repository/MessageRepository.java new file mode 100644 index 0000000..5643c3c --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/repository/MessageRepository.java @@ -0,0 +1,29 @@ +package com.messaging.chat.dao.repository; + +import com.messaging.chat.dao.entity.Message; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface MessageRepository extends JpaRepository { + + boolean existsByIdAndConversationId(Long messageId, Long conversationId); + + List findByConversationIdOrderByIdDesc(Long conversationId, Pageable pageable); + + List findByConversationIdAndIdLessThanOrderByIdDesc( + Long conversationId, + Long beforeMessageId, + Pageable pageable + ); + + Optional findByConversationIdAndSenderIdAndClientMessageId( + Long conversationId, + Long senderId, + String clientMessageId + ); +} diff --git a/src/main/java/com/messaging/chat/dao/repository/ReadDeliveryStateRepository.java b/src/main/java/com/messaging/chat/dao/repository/ReadDeliveryStateRepository.java new file mode 100644 index 0000000..14895c8 --- /dev/null +++ b/src/main/java/com/messaging/chat/dao/repository/ReadDeliveryStateRepository.java @@ -0,0 +1,13 @@ +package com.messaging.chat.dao.repository; + +import com.messaging.chat.dao.entity.ReadDeliveryState; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ReadDeliveryStateRepository extends JpaRepository { + + Optional findByConversationIdAndUserId(Long conversationId, Long userId); +} diff --git a/src/main/java/com/messaging/chat/logging/DPLogger.java b/src/main/java/com/messaging/chat/logging/DPLogger.java new file mode 100644 index 0000000..2e71aa4 --- /dev/null +++ b/src/main/java/com/messaging/chat/logging/DPLogger.java @@ -0,0 +1,80 @@ +package com.messaging.chat.logging; + +import java.util.Arrays; +import java.util.regex.MatchResult; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DPLogger { + + private static final Pattern PHONE_PATTERN = Pattern.compile("(? clazz) { + this.logger = LoggerFactory.getLogger(clazz); + } + + public static DPLogger getLogger(Class clazz) { + return new DPLogger(clazz); + } + + public void info(String msg, Object... args) { + logger.info(mask(msg), maskArgs(args)); + } + + public void debug(String msg, Object... args) { + logger.debug(mask(msg), maskArgs(args)); + } + + public void warn(String msg, Object... args) { + logger.warn(mask(msg), maskArgs(args)); + } + + public void error(String msg, Object... args) { + logger.error(mask(msg), maskArgs(args)); + } + + private String mask(String message) { + if (message == null) { + return null; + } + return PHONE_PATTERN.matcher(message).replaceAll(this::maskPhoneNumber); + } + + private Object[] maskArgs(Object[] args) { + if (args == null) { + return null; + } + + return Arrays.stream(args) + .map(arg -> arg instanceof String text ? mask(text) : arg) + .toArray(); + } + + private String maskPhoneNumber(MatchResult matchResult) { + String original = matchResult.group(1); + String digitsOnly = original.replaceAll("\\D", ""); + + if (digitsOnly.length() <= 4) { + return original; + } + + int digitsToMask = digitsOnly.length() - 4; + int maskedDigits = 0; + StringBuilder result = new StringBuilder(original.length()); + + for (char currentChar : original.toCharArray()) { + if (Character.isDigit(currentChar) && maskedDigits < digitsToMask) { + result.append('*'); + maskedDigits++; + } else { + result.append(currentChar); + } + } + + return result.toString(); + } +} diff --git a/src/main/java/com/messaging/chat/mapper/AttachmentMapper.java b/src/main/java/com/messaging/chat/mapper/AttachmentMapper.java new file mode 100644 index 0000000..9c33b3a --- /dev/null +++ b/src/main/java/com/messaging/chat/mapper/AttachmentMapper.java @@ -0,0 +1,28 @@ +package com.messaging.chat.mapper; + +import com.messaging.chat.dao.entity.Attachment; +import com.messaging.chat.model.constant.FileStatus; +import com.messaging.chat.model.dto.request.PresignUploadRequest; +import com.messaging.chat.model.dto.response.AttachmentConfirmResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring", imports = FileStatus.class) +public interface AttachmentMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "message", ignore = true) + @Mapping(target = "uploaderId", source = "userId") + @Mapping(target = "storageKey", source = "storageKey") + @Mapping(target = "originalFileName", source = "presignUploadRequest.fileName") + @Mapping(target = "contentType", source = "presignUploadRequest.contentType") + @Mapping(target = "fileSize", source = "presignUploadRequest.fileSize") + @Mapping(target = "checksum", source = "presignUploadRequest.checksum") + @Mapping(target = "status", expression = "java(FileStatus.PENDING)") + Attachment toPendingEntity(Long userId, String storageKey, PresignUploadRequest presignUploadRequest); + + + AttachmentConfirmResponse toAttachmentConfirmResponse(Attachment attachment); +} diff --git a/src/main/java/com/messaging/chat/mapper/ConversationMapper.java b/src/main/java/com/messaging/chat/mapper/ConversationMapper.java new file mode 100644 index 0000000..8e412e8 --- /dev/null +++ b/src/main/java/com/messaging/chat/mapper/ConversationMapper.java @@ -0,0 +1,35 @@ +package com.messaging.chat.mapper; + +import com.messaging.chat.dao.entity.Conversation; +import com.messaging.chat.dao.entity.ConversationParticipant; +import com.messaging.chat.model.dto.response.ConversationListResponse; +import com.messaging.chat.model.dto.response.ConversationResponse; +import com.messaging.chat.model.dto.response.MessagePreview; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface ConversationMapper { + + @Mapping(target = "participantUserIds", source = "conversationParticipants", + qualifiedByName = "participantsToUserIds") + ConversationResponse toResponse(Conversation conversation); + + @Mapping(target = "participantUserIds", source = "conversation.conversationParticipants", + qualifiedByName = "participantsToUserIds") + @Mapping(target = "id", source = "conversation.id") + @Mapping(target = "messagePreview", source = "messagePreview") + @Mapping(target = "createdAt", source = "conversation.createdAt") + @Mapping(target = "updatedAt", source = "conversation.updatedAt") + ConversationListResponse toListResponse(Conversation conversation, MessagePreview messagePreview); + + @Named("participantsToUserIds") + default List mapParticipantsToUserIds(List participants) { + return participants.stream() + .map(ConversationParticipant::getUserId) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/messaging/chat/mapper/ConversationParticipantMapper.java b/src/main/java/com/messaging/chat/mapper/ConversationParticipantMapper.java new file mode 100644 index 0000000..79f573d --- /dev/null +++ b/src/main/java/com/messaging/chat/mapper/ConversationParticipantMapper.java @@ -0,0 +1,16 @@ +package com.messaging.chat.mapper; + +import com.messaging.chat.dao.entity.Conversation; +import com.messaging.chat.dao.entity.ConversationParticipant; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface ConversationParticipantMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "conversation", source = "conversation") + @Mapping(target = "updatedAt", ignore = true) + ConversationParticipant toEntity(Long userId, Conversation conversation); +} diff --git a/src/main/java/com/messaging/chat/mapper/MessageMapper.java b/src/main/java/com/messaging/chat/mapper/MessageMapper.java new file mode 100644 index 0000000..7b6db3f --- /dev/null +++ b/src/main/java/com/messaging/chat/mapper/MessageMapper.java @@ -0,0 +1,66 @@ +package com.messaging.chat.mapper; + +import com.messaging.chat.dao.entity.Attachment; +import com.messaging.chat.dao.entity.Conversation; +import com.messaging.chat.dao.entity.Message; +import com.messaging.chat.model.dto.event.MessageNotificationEvent; +import com.messaging.chat.model.dto.request.SendMessageRequest; +import com.messaging.chat.model.dto.response.AttachmentResponse; +import com.messaging.chat.model.dto.response.MessagePreview; +import com.messaging.chat.model.dto.response.MessageResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface MessageMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "conversation", source = "conversation") + @Mapping(target = "senderId", source = "userId") + @Mapping(target = "clientMessageId", source = "sendMessageRequest.clientMessageId") + @Mapping(target = "type", source = "sendMessageRequest.type") + @Mapping(target = "textContent", source = "sendMessageRequest.textContent") + @Mapping(target = "attachments", ignore = true) + Message toEntity(Long userId, Conversation conversation, SendMessageRequest sendMessageRequest); + + @Mapping(target = "userId", source = "senderId") + @Mapping(target = "preview", source = "textContent") + MessagePreview toMessagePreview(Message message); + + @Mapping(target = "conversationId", source = "conversation.id") + MessageResponse toResponse(Message message); + + @Mapping(target = "fileName", source = "originalFileName") + @Mapping(target = "downloadUrl", ignore = true) + AttachmentResponse toAttachmentResponse(Attachment attachment); + + @Mapping(target = "id", source = "messageResponse.id") + @Mapping(target = "conversationId", source = "messageResponse.conversationId") + @Mapping(target = "senderId", source = "messageResponse.senderId") + @Mapping(target = "clientMessageId", source = "messageResponse.clientMessageId") + @Mapping(target = "type", source = "messageResponse.type") + @Mapping(target = "textContent", source = "messageResponse.textContent") + @Mapping(target = "attachments", source = "attachments") + @Mapping(target = "createdAt", source = "messageResponse.createdAt") + MessageResponse toResponseWithAttachments(MessageResponse messageResponse, List attachments); + + @Mapping(target = "id", source = "attachmentResponse.id") + @Mapping(target = "fileName", source = "attachmentResponse.fileName") + @Mapping(target = "contentType", source = "attachmentResponse.contentType") + @Mapping(target = "fileSize", source = "attachmentResponse.fileSize") + @Mapping(target = "status", source = "attachmentResponse.status") + @Mapping(target = "downloadUrl", source = "downloadUrl") + AttachmentResponse toAttachmentResponseWithDownloadUrl(AttachmentResponse attachmentResponse, String downloadUrl); + + @Mapping(target = "recipientUserId", source = "recipientUserId") + @Mapping(target = "senderId", source = "message.senderId") + @Mapping(target = "conversationId", source = "message.conversation.id") + @Mapping(target = "messageId", source = "message.id") + @Mapping(target = "messageType", source = "message.type") + @Mapping(target = "textContent", source = "message.textContent") + MessageNotificationEvent toMessageNotificationEvent(Message message, Long recipientUserId); +} diff --git a/src/main/java/com/messaging/chat/mapper/MessageStateMapper.java b/src/main/java/com/messaging/chat/mapper/MessageStateMapper.java new file mode 100644 index 0000000..3a1e6af --- /dev/null +++ b/src/main/java/com/messaging/chat/mapper/MessageStateMapper.java @@ -0,0 +1,25 @@ +package com.messaging.chat.mapper; + +import com.messaging.chat.dao.entity.Conversation; +import com.messaging.chat.dao.entity.ConversationDeliveryState; +import com.messaging.chat.dao.entity.ReadDeliveryState; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface MessageStateMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "lastMessageId", ignore = true) + @Mapping(target = "conversation", source = "conversation") + ConversationDeliveryState toDeliveryState(Long userId, Conversation conversation); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "lastMessageId", ignore = true) + @Mapping(target = "conversation", source = "conversation") + ReadDeliveryState toReadState(Long userId, Conversation conversation); +} diff --git a/src/main/java/com/messaging/chat/model/constant/FileConstants.java b/src/main/java/com/messaging/chat/model/constant/FileConstants.java new file mode 100644 index 0000000..7d1f4f4 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/constant/FileConstants.java @@ -0,0 +1,52 @@ +package com.messaging.chat.model.constant; + + +import java.util.Set; + +public class FileConstants { + + private FileConstants() { + } + + public static final Set EXECUTABLE_CONTENT_TYPES = Set.of( + "application/java-archive", + "application/octet-stream", + "application/vnd.android.package-archive", + "application/vnd.microsoft.portable-executable", + "application/x-bat", + "application/x-csh", + "application/x-dosexec", + "application/x-executable", + "application/x-ms-installer", + "application/x-msdos-program", + "application/x-msdownload", + "application/x-sh", + "application/x-shellscript", + "application/x-shockwave-flash", + "application/x-windows-executable", + "text/javascript" + ); + + public static final Set EXECUTABLE_FILE_EXTENSIONS = Set.of( + "apk", + "appimage", + "bat", + "bin", + "cmd", + "com", + "csh", + "dll", + "dmg", + "exe", + "jar", + "js", + "msi", + "ps1", + "run", + "scr", + "sh", + "vb", + "vbs", + "wsf" + ); +} diff --git a/src/main/java/com/messaging/chat/model/constant/FileStatus.java b/src/main/java/com/messaging/chat/model/constant/FileStatus.java new file mode 100644 index 0000000..a2f2356 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/constant/FileStatus.java @@ -0,0 +1,7 @@ +package com.messaging.chat.model.constant; + +public enum FileStatus { + PENDING, + UPLOADED, + FAILED +} diff --git a/src/main/java/com/messaging/chat/model/constant/Headers.java b/src/main/java/com/messaging/chat/model/constant/Headers.java new file mode 100644 index 0000000..610f7d1 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/constant/Headers.java @@ -0,0 +1,8 @@ +package com.messaging.chat.model.constant; + +public class Headers { + + + private Headers() {} + public static final String USER_ID_HEADER = "userId"; +} diff --git a/src/main/java/com/messaging/chat/model/constant/MessageType.java b/src/main/java/com/messaging/chat/model/constant/MessageType.java new file mode 100644 index 0000000..dbefc64 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/constant/MessageType.java @@ -0,0 +1,7 @@ +package com.messaging.chat.model.constant; + +public enum MessageType { + TEXT, + IMAGE, + FILE +} diff --git a/src/main/java/com/messaging/chat/model/dto/event/MessageNotificationEvent.java b/src/main/java/com/messaging/chat/model/dto/event/MessageNotificationEvent.java new file mode 100644 index 0000000..2428712 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/event/MessageNotificationEvent.java @@ -0,0 +1,13 @@ +package com.messaging.chat.model.dto.event; + +import com.messaging.chat.model.constant.MessageType; + +public record MessageNotificationEvent( + Long recipientUserId, + Long senderId, + Long conversationId, + Long messageId, + MessageType messageType, + String textContent +) { +} diff --git a/src/main/java/com/messaging/chat/model/dto/request/CreateConversationRequest.java b/src/main/java/com/messaging/chat/model/dto/request/CreateConversationRequest.java new file mode 100644 index 0000000..2febb1c --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/request/CreateConversationRequest.java @@ -0,0 +1,18 @@ +package com.messaging.chat.model.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +@Schema(description = "Request body for creating a conversation.") +public record CreateConversationRequest( + + @Schema( + description = "Participant user ids to add to the conversation. The current user is added automatically.", + example = "[2, 3]" + ) + @NotEmpty + List participantUserIds) +{ +} diff --git a/src/main/java/com/messaging/chat/model/dto/request/MessageStateRequest.java b/src/main/java/com/messaging/chat/model/dto/request/MessageStateRequest.java new file mode 100644 index 0000000..c047a75 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/request/MessageStateRequest.java @@ -0,0 +1,14 @@ +package com.messaging.chat.model.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +@Schema(description = "Request body for updating delivered/read message state.") +public record MessageStateRequest( + @Schema(description = "Latest message id delivered or read by the user.", example = "100") + @NotNull + @Positive + Long messageId +) { +} diff --git a/src/main/java/com/messaging/chat/model/dto/request/PresignUploadRequest.java b/src/main/java/com/messaging/chat/model/dto/request/PresignUploadRequest.java new file mode 100644 index 0000000..7a4e365 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/request/PresignUploadRequest.java @@ -0,0 +1,23 @@ +package com.messaging.chat.model.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +@Schema(description = "Metadata required to create a presigned S3 upload URL.") +public record PresignUploadRequest( + @Schema(description = "Original file name.", example = "photo.png") + @NotBlank + String fileName, + @Schema(description = "MIME type sent when uploading to S3.", example = "image/png") + @NotBlank + String contentType, + @Schema(description = "File size in bytes. Must be less than 5 MB.", example = "1048576", minimum = "1") + @NotNull + @Positive + Long fileSize, + @Schema(description = "Optional base64 Content-MD5 checksum.", example = "1B2M2Y8AsgTpgAmY7PhCfg==") + String checksum +) { +} diff --git a/src/main/java/com/messaging/chat/model/dto/request/SendMessageRequest.java b/src/main/java/com/messaging/chat/model/dto/request/SendMessageRequest.java new file mode 100644 index 0000000..eabb336 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/request/SendMessageRequest.java @@ -0,0 +1,21 @@ +package com.messaging.chat.model.dto.request; + +import com.messaging.chat.model.constant.MessageType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +@Schema(description = "Request body for sending a message.") +public record SendMessageRequest( + @Schema(description = "Frontend-generated idempotency key for this message.", example = "550e8400-e29b-41d4-a716-446655440000") + String clientMessageId, + @Schema(description = "Message type.", example = "TEXT") + @NotNull + MessageType type, + @Schema(description = "Text content for text messages.", example = "Hello") + String textContent, + @Schema(description = "Uploaded attachment ids to link to this message.", example = "[10, 11]") + List attachmentIds +) { +} diff --git a/src/main/java/com/messaging/chat/model/dto/response/AttachmentConfirmResponse.java b/src/main/java/com/messaging/chat/model/dto/response/AttachmentConfirmResponse.java new file mode 100644 index 0000000..cf2ae08 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/response/AttachmentConfirmResponse.java @@ -0,0 +1,15 @@ +package com.messaging.chat.model.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import com.messaging.chat.model.constant.FileStatus; + +@Schema(description = "Response returned after confirming an attachment upload.") +public record AttachmentConfirmResponse( + @Schema(description = "Attachment id.", example = "10") + Long id, + @Schema(description = "S3 object key for the uploaded file.", example = "attachments/1/uuid-photo.png") + String storageKey, + @Schema(description = "Current attachment status.", example = "UPLOADED") + FileStatus status +) { +} diff --git a/src/main/java/com/messaging/chat/model/dto/response/AttachmentResponse.java b/src/main/java/com/messaging/chat/model/dto/response/AttachmentResponse.java new file mode 100644 index 0000000..1c6733c --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/response/AttachmentResponse.java @@ -0,0 +1,21 @@ +package com.messaging.chat.model.dto.response; + +import com.messaging.chat.model.constant.FileStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Attachment metadata linked to a message.") +public record AttachmentResponse( + @Schema(description = "Attachment id.", example = "10") + Long id, + @Schema(description = "Original file name.", example = "photo.png") + String fileName, + @Schema(description = "File MIME type.", example = "image/png") + String contentType, + @Schema(description = "File size in bytes.", example = "1048576") + Long fileSize, + @Schema(description = "Current upload status.", example = "UPLOADED") + FileStatus status, + @Schema(description = "Temporary URL for viewing or downloading the attachment.") + String downloadUrl +) { +} diff --git a/src/main/java/com/messaging/chat/model/dto/response/ConversationListResponse.java b/src/main/java/com/messaging/chat/model/dto/response/ConversationListResponse.java new file mode 100644 index 0000000..29ff238 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/response/ConversationListResponse.java @@ -0,0 +1,21 @@ +package com.messaging.chat.model.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "Conversation summary returned in conversation list responses.") +public record ConversationListResponse( + @Schema(description = "Conversation id.", example = "20") + Long id, + @Schema(description = "User ids participating in the conversation.", example = "[1, 2, 3]") + List participantUserIds, + @Schema(description = "Latest message preview for the conversation.") + MessagePreview messagePreview, + @Schema(description = "Conversation creation time.", example = "2026-04-19T10:00:00") + LocalDateTime createdAt, + @Schema(description = "Conversation last update time.", example = "2026-04-19T10:05:00") + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/messaging/chat/model/dto/response/ConversationResponse.java b/src/main/java/com/messaging/chat/model/dto/response/ConversationResponse.java new file mode 100644 index 0000000..42b4ccd --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/response/ConversationResponse.java @@ -0,0 +1,14 @@ +package com.messaging.chat.model.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "Response returned after creating a conversation.") +public record ConversationResponse( + @Schema(description = "Conversation id.", example = "20") + Long id, + @Schema(description = "User ids participating in the conversation.", example = "[1, 2, 3]") + List participantUserIds +) { +} diff --git a/src/main/java/com/messaging/chat/model/dto/response/MessagePreview.java b/src/main/java/com/messaging/chat/model/dto/response/MessagePreview.java new file mode 100644 index 0000000..dac6da8 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/response/MessagePreview.java @@ -0,0 +1,21 @@ +package com.messaging.chat.model.dto.response; + +import com.messaging.chat.model.constant.MessageType; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "Preview of the latest message in a conversation.") +public record MessagePreview( + @Schema(description = "Message id.", example = "100") + Long id, + @Schema(description = "Message type.", example = "TEXT") + MessageType type, + @Schema(description = "Sender user id.", example = "1") + Long userId, + @Schema(description = "Short preview content.", example = "Hello") + String preview, + @Schema(description = "Message creation time.", example = "2026-04-19T10:05:00") + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/messaging/chat/model/dto/response/MessageResponse.java b/src/main/java/com/messaging/chat/model/dto/response/MessageResponse.java new file mode 100644 index 0000000..4d62ede --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/response/MessageResponse.java @@ -0,0 +1,28 @@ +package com.messaging.chat.model.dto.response; + +import com.messaging.chat.model.constant.MessageType; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "Message response.") +public record MessageResponse( + @Schema(description = "Message id.", example = "100") + Long id, + @Schema(description = "Conversation id.", example = "20") + Long conversationId, + @Schema(description = "Sender user id.", example = "1") + Long senderId, + @Schema(description = "Frontend-generated idempotency key.", example = "550e8400-e29b-41d4-a716-446655440000") + String clientMessageId, + @Schema(description = "Message type.", example = "TEXT") + MessageType type, + @Schema(description = "Text content.", example = "Hello") + String textContent, + @Schema(description = "Attachments linked to this message.") + List attachments, + @Schema(description = "Message creation time.", example = "2026-04-19T10:05:00") + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/messaging/chat/model/dto/response/MessageStateResponse.java b/src/main/java/com/messaging/chat/model/dto/response/MessageStateResponse.java new file mode 100644 index 0000000..6878e2c --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/response/MessageStateResponse.java @@ -0,0 +1,14 @@ +package com.messaging.chat.model.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Response returned after updating delivered/read message state.") +public record MessageStateResponse( + @Schema(description = "Conversation id.", example = "20") + Long conversationId, + @Schema(description = "User id that acknowledged the message.", example = "1") + Long userId, + @Schema(description = "Latest delivered/read message id stored for the user.", example = "100") + Long messageId +) { +} diff --git a/src/main/java/com/messaging/chat/model/dto/response/PresignUploadResponse.java b/src/main/java/com/messaging/chat/model/dto/response/PresignUploadResponse.java new file mode 100644 index 0000000..61a5d2e --- /dev/null +++ b/src/main/java/com/messaging/chat/model/dto/response/PresignUploadResponse.java @@ -0,0 +1,23 @@ +package com.messaging.chat.model.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; +import java.util.Map; + +@Schema(description = "Response containing the S3 presigned URL and required upload headers.") +public record PresignUploadResponse( + @Schema(description = "Attachment id saved with PENDING status.", example = "10") + Long attachmentId, + @Schema(description = "S3 object key.", example = "attachments/1/uuid-photo.png") + String storageKey, + @Schema(description = "Temporary S3 URL used by the client to upload the file.") + String uploadUrl, + @Schema(description = "HTTP method to use against uploadUrl.", example = "PUT") + String httpMethod, + @Schema(description = "Expiration time for the presigned upload URL.", example = "2026-04-19T02:17:36Z") + Instant expiresAt, + @Schema(description = "Headers that must be sent when uploading the file to S3.") + Map uploadHeaders +) { +} diff --git a/src/main/java/com/messaging/chat/model/exceptions/FileValidationException.java b/src/main/java/com/messaging/chat/model/exceptions/FileValidationException.java new file mode 100644 index 0000000..22f81e0 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/exceptions/FileValidationException.java @@ -0,0 +1,7 @@ +package com.messaging.chat.model.exceptions; + +public class FileValidationException extends RuntimeException { + public FileValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/messaging/chat/model/exceptions/ResourceNotFound.java b/src/main/java/com/messaging/chat/model/exceptions/ResourceNotFound.java new file mode 100644 index 0000000..3687dd7 --- /dev/null +++ b/src/main/java/com/messaging/chat/model/exceptions/ResourceNotFound.java @@ -0,0 +1,8 @@ +package com.messaging.chat.model.exceptions; + +public class ResourceNotFound extends RuntimeException { + + public ResourceNotFound(String message) { + super(message); + } +} diff --git a/src/main/java/com/messaging/chat/service/AttachmentService.java b/src/main/java/com/messaging/chat/service/AttachmentService.java new file mode 100644 index 0000000..241b86c --- /dev/null +++ b/src/main/java/com/messaging/chat/service/AttachmentService.java @@ -0,0 +1,193 @@ +package com.messaging.chat.service; + +import com.messaging.chat.dao.entity.Attachment; +import com.messaging.chat.dao.repository.AttachmentRepository; +import com.messaging.chat.logging.DPLogger; +import com.messaging.chat.mapper.AttachmentMapper; +import com.messaging.chat.model.dto.request.PresignUploadRequest; +import com.messaging.chat.model.dto.response.AttachmentConfirmResponse; +import com.messaging.chat.model.dto.response.PresignUploadResponse; +import com.messaging.chat.model.exceptions.FileValidationException; +import com.messaging.chat.model.exceptions.ResourceNotFound; +import com.messaging.chat.util.FileOperationsUtil; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import static com.messaging.chat.model.constant.FileConstants.EXECUTABLE_CONTENT_TYPES; +import static com.messaging.chat.model.constant.FileConstants.EXECUTABLE_FILE_EXTENSIONS; +import static com.messaging.chat.model.constant.FileStatus.FAILED; +import static com.messaging.chat.model.constant.FileStatus.UPLOADED; + +@Service +@RequiredArgsConstructor +public class AttachmentService { + + private static final DPLogger logger = DPLogger.getLogger(AttachmentService.class); + private static final long MAX_UPLOAD_SIZE_BYTES = 5L * 1024L * 1024L; + private static final int ZERO = 0; + + @Value("${storage.s3.bucket-name}") + private String bucketName; + + @Value("${storage.s3.presign-duration-minutes}") + private Long presignDurationMinutes; + + private final S3Presigner s3Presigner; + private final S3Client s3Client; + private final AttachmentRepository attachmentRepository; + private final AttachmentMapper attachmentMapper; + + @Transactional + public PresignUploadResponse presignUpload(Long userId, PresignUploadRequest presignUploadRequest) { + logger.info("Action.log.start presignUpload userId: {}, fileName: {}", userId, presignUploadRequest.fileName()); + + validateUploadedFile(presignUploadRequest); + + String storageKey = FileOperationsUtil.buildStorageKey(userId, presignUploadRequest.fileName()); + Attachment attachment = attachmentRepository.save(attachmentMapper.toPendingEntity( + userId, + storageKey, + presignUploadRequest + )); + PresignedPutObjectRequest presignedPutObjectRequest = createPresignedPutObjectRequest( + storageKey, + presignUploadRequest + ); + Instant expiresAt = Instant.now().plus(Duration.ofMinutes(presignDurationMinutes)); + + logger.info("Action.log.end presignUpload userId: {}, storageKey: {}", userId, storageKey); + return new PresignUploadResponse( + attachment.getId(), + storageKey, + presignedPutObjectRequest.url().toString(), + "PUT", + expiresAt, + extractUploadHeaders(presignedPutObjectRequest) + ); + } + + @Transactional + public AttachmentConfirmResponse confirmUpload(Long userId, Long attachmentId) { + logger.info("Action.log.start confirmUpload userId: {}, attachmentId: {}", userId, attachmentId); + + Attachment attachment = attachmentRepository.findById(attachmentId) + .orElseThrow(() -> new ResourceNotFound("Attachment not found")); + + HeadObjectResponse headObjectResponse = getUploadedObjectMetadata(attachment.getStorageKey()); + validateUploadedObjectMetadata(attachment, headObjectResponse); + + attachment.setStatus(UPLOADED); + Attachment savedAttachment = attachmentRepository.save(attachment); + + logger.info("Action.log.end confirmUpload userId: {}, attachmentId: {}", userId, attachmentId); + return attachmentMapper.toAttachmentConfirmResponse(savedAttachment); + } + + public String createDownloadUrl(String storageKey) { + PresignedGetObjectRequest presignedGetObjectRequest = s3Presigner.presignGetObject(GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(presignDurationMinutes)) + .getObjectRequest(GetObjectRequest.builder() + .bucket(bucketName) + .key(storageKey) + .build()) + .build()); + return presignedGetObjectRequest.url().toString(); + } + + private void validateUploadedFile(PresignUploadRequest presignUploadRequest) { + if (presignUploadRequest.fileSize() >= MAX_UPLOAD_SIZE_BYTES) { + throw new FileValidationException("File size must be less than 5 MB"); + } + + String contentType = presignUploadRequest.contentType().split(";")[ZERO].trim().toLowerCase(); + if (EXECUTABLE_CONTENT_TYPES.contains(contentType)) { + throw new FileValidationException("Executable files are not allowed"); + } + + String fileExtension = FileOperationsUtil.getFileExtension(presignUploadRequest.fileName()); + if (EXECUTABLE_FILE_EXTENSIONS.contains(fileExtension)) { + throw new FileValidationException("Executable files are not allowed"); + } + } + + private PresignedPutObjectRequest createPresignedPutObjectRequest( + String storageKey, + PresignUploadRequest presignUploadRequest + ) { + PutObjectRequest.Builder putObjectRequestBuilder = PutObjectRequest.builder() + .bucket(bucketName) + .key(storageKey) + .contentType(presignUploadRequest.contentType()) + .contentLength(presignUploadRequest.fileSize()); + + if (presignUploadRequest.checksum() != null && !presignUploadRequest.checksum().isBlank()) { + putObjectRequestBuilder.contentMD5(presignUploadRequest.checksum()); + } + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(presignDurationMinutes)) + .putObjectRequest(putObjectRequestBuilder.build()) + .build(); + + return s3Presigner.presignPutObject(presignRequest); + } + + private HeadObjectResponse getUploadedObjectMetadata(String storageKey) { + try { + return s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(storageKey) + .build()); + } catch (NoSuchKeyException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File has not been uploaded yet", exception); + } catch (S3Exception exception) { + if (exception.statusCode() == HttpStatus.NOT_FOUND.value()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File has not been uploaded yet", exception); + } + throw exception; + } + } + + private void validateUploadedObjectMetadata(Attachment attachment, HeadObjectResponse headObjectResponse) { + if (!attachment.getFileSize().equals(headObjectResponse.contentLength())) { + attachment.setStatus(FAILED); + attachmentRepository.save(attachment); + throw new FileValidationException("Uploaded file size does not match presigned request"); + } + + if (!attachment.getContentType().equalsIgnoreCase(headObjectResponse.contentType())) { + attachment.setStatus(FAILED); + attachmentRepository.save(attachment); + throw new FileValidationException("Uploaded file content type does not match presigned request"); + } + } + + private Map extractUploadHeaders(PresignedPutObjectRequest presignedPutObjectRequest) { + Map headers = new LinkedHashMap<>(); + presignedPutObjectRequest.httpRequest().headers() + .forEach((headerName, headerValues) -> headers.put(headerName, + String.join(",", headerValues))); + return headers; + } +} diff --git a/src/main/java/com/messaging/chat/service/ConversationService.java b/src/main/java/com/messaging/chat/service/ConversationService.java new file mode 100644 index 0000000..585ea2f --- /dev/null +++ b/src/main/java/com/messaging/chat/service/ConversationService.java @@ -0,0 +1,87 @@ +package com.messaging.chat.service; + +import com.messaging.chat.dao.entity.Conversation; +import com.messaging.chat.dao.entity.Message; +import com.messaging.chat.dao.repository.ConversationRepository; +import com.messaging.chat.logging.DPLogger; +import com.messaging.chat.mapper.ConversationMapper; +import com.messaging.chat.mapper.ConversationParticipantMapper; +import com.messaging.chat.mapper.MessageMapper; +import com.messaging.chat.model.dto.request.CreateConversationRequest; +import com.messaging.chat.model.dto.response.ConversationListResponse; +import com.messaging.chat.model.dto.response.ConversationResponse; +import com.messaging.chat.model.dto.response.MessagePreview; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class ConversationService { + + private static final DPLogger logger = DPLogger.getLogger(ConversationService.class); + + private static final int ZERO = 0; + + private final ConversationRepository conversationRepository; + private final ConversationMapper conversationMapper; + private final ConversationParticipantMapper conversationParticipantMapper; + private final MessageMapper messageMapper; + + @Transactional + public ConversationResponse createConversations(Long userId, CreateConversationRequest createConversationRequest) { + logger.info("Action.log.start createConversations userId: {}", userId); + + LinkedHashSet participantIds = new LinkedHashSet<>(); + participantIds.add(userId); + createConversationRequest.participantUserIds().stream() + .filter(Objects::nonNull) + .forEach(participantIds::add); + + Conversation conversation = new Conversation(); + conversation.setConversationParticipants( + participantIds.stream() + .map(participantId -> conversationParticipantMapper.toEntity(participantId, conversation)) + .toList() + ); + + Conversation savedConversation = conversationRepository.save(conversation); + logger.info("Action.log.end createConversations userId: {}", userId); + + return conversationMapper.toResponse(savedConversation); + } + + @Transactional(readOnly = true) + public List getConversationList(Long userId, Integer limit, Integer cursor) { + logger.info("Action.log.start getConversationList userId: {}, cursor: {}", userId, cursor); + + List conversations = cursor == null || cursor <= ZERO + ? conversationRepository.findConversationListByUserId(userId, PageRequest.of(ZERO, limit)) + : conversationRepository.findConversationListByUserIdAndCursor( + userId, + cursor.longValue(), + PageRequest.of(ZERO, limit) + ); + + List conversationList = conversations.stream() + .map(conversation -> conversationMapper.toListResponse(conversation, + buildMessagePreview(conversation))) + .toList(); + + logger.info("Action.log.end getConversationList userId: {} cursor: {}", userId, cursor); + return conversationList; + } + + private MessagePreview buildMessagePreview(Conversation conversation) { + return conversation.getMessages().stream() + .max(Comparator.comparing(Message::getCreatedAt)) + .map(messageMapper::toMessagePreview) + .orElse(null); + } +} diff --git a/src/main/java/com/messaging/chat/service/MessageNotificationPublisher.java b/src/main/java/com/messaging/chat/service/MessageNotificationPublisher.java new file mode 100644 index 0000000..91a0685 --- /dev/null +++ b/src/main/java/com/messaging/chat/service/MessageNotificationPublisher.java @@ -0,0 +1,42 @@ +package com.messaging.chat.service; + +import com.messaging.chat.logging.DPLogger; +import com.messaging.chat.model.dto.event.MessageNotificationEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.AmqpException; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class MessageNotificationPublisher { + + private static final DPLogger logger = DPLogger.getLogger(MessageNotificationPublisher.class); + + private final ApplicationEventPublisher applicationEventPublisher; + private final RabbitTemplate rabbitTemplate; + + @Value("${rabbitmq.notification-exchange}") + private String notificationExchangeName; + + @Value("${rabbitmq.notification-routing-key}") + private String notificationRoutingKey; + + public void publish(MessageNotificationEvent event) { + applicationEventPublisher.publishEvent(event); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void sendToQueue(MessageNotificationEvent event) { + try { + rabbitTemplate.convertAndSend(notificationExchangeName, notificationRoutingKey, event); + } catch (AmqpException ex) { + logger.warn("Action.log.failed sendToQueue recipientUserId: {}, conversationId: {}, messageId: {}, error: {}", + event.recipientUserId(), event.conversationId(), event.messageId(), ex.getMessage()); + } + } +} diff --git a/src/main/java/com/messaging/chat/service/MessageService.java b/src/main/java/com/messaging/chat/service/MessageService.java new file mode 100644 index 0000000..e7fdd14 --- /dev/null +++ b/src/main/java/com/messaging/chat/service/MessageService.java @@ -0,0 +1,246 @@ +package com.messaging.chat.service; + +import com.messaging.chat.dao.entity.Attachment; +import com.messaging.chat.dao.entity.Conversation; +import com.messaging.chat.dao.entity.ConversationDeliveryState; +import com.messaging.chat.dao.entity.ConversationParticipant; +import com.messaging.chat.dao.entity.Message; +import com.messaging.chat.dao.entity.ReadDeliveryState; +import com.messaging.chat.dao.repository.AttachmentRepository; +import com.messaging.chat.dao.repository.ConversationDeliveryStateRepository; +import com.messaging.chat.dao.repository.ConversationRepository; +import com.messaging.chat.dao.repository.MessageRepository; +import com.messaging.chat.dao.repository.ReadDeliveryStateRepository; +import com.messaging.chat.logging.DPLogger; +import com.messaging.chat.mapper.MessageMapper; +import com.messaging.chat.mapper.MessageStateMapper; +import com.messaging.chat.model.constant.FileStatus; +import com.messaging.chat.model.dto.event.MessageNotificationEvent; +import com.messaging.chat.model.dto.request.SendMessageRequest; +import com.messaging.chat.model.dto.response.AttachmentResponse; +import com.messaging.chat.model.dto.response.MessageResponse; +import com.messaging.chat.model.dto.response.MessageStateResponse; +import com.messaging.chat.model.exceptions.FileValidationException; +import com.messaging.chat.model.exceptions.ResourceNotFound; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class MessageService { + + private static final DPLogger logger = DPLogger.getLogger(MessageService.class); + + private static final int ZERO = 0; + + private final ConversationRepository conversationRepository; + private final MessageRepository messageRepository; + private final AttachmentRepository attachmentRepository; + private final ConversationDeliveryStateRepository conversationDeliveryStateRepository; + private final ReadDeliveryStateRepository readDeliveryStateRepository; + private final MessageMapper messageMapper; + private final MessageStateMapper messageStateMapper; + private final AttachmentService attachmentService; + private final MessageNotificationPublisher messageNotificationPublisher; + + @Transactional(readOnly = true) + public List getMessages(Long userId, Long conversationId, Long beforeMessageId, Integer limit) { + logger.info("Action.log.start getMessages userId: {}, conversationId: {}, beforeMessageId: {}", + userId, conversationId, beforeMessageId); + + validateConversationParticipant(userId, conversationId); + + PageRequest pageRequest = PageRequest.of(ZERO, limit); + List messages = beforeMessageId == null + ? messageRepository.findByConversationIdOrderByIdDesc(conversationId, pageRequest) + : messageRepository.findByConversationIdAndIdLessThanOrderByIdDesc( + conversationId, + beforeMessageId, + pageRequest + ); + + List messageResponses = messages.stream() + .map(this::toResponseWithDownloadUrls) + .toList(); + + logger.info("Action.log.end getMessages userId: {}, conversationId: {}, count: {}", + userId, conversationId, messageResponses.size()); + return messageResponses; + } + + @Transactional + public MessageResponse sendMessage(Long userId, Long conversationId, SendMessageRequest sendMessageRequest) { + logger.info("Action.log.start sendMessage userId: {}, conversationId: {}", userId, conversationId); + + Conversation conversation = conversationRepository.findById(conversationId) + .orElseThrow(() -> new ResourceNotFound("Conversation not found")); + + validateConversationParticipant(userId, conversationId); + + Message existingMessage = findExistingMessage(userId, conversationId, sendMessageRequest.clientMessageId()); + if (existingMessage != null) { + logger.info("Action.log.end sendMessage duplicate userId: {}, conversationId: {}, messageId: {}", + userId, conversationId, existingMessage.getId()); + return toResponseWithDownloadUrls(existingMessage); + } + + Message message = messageMapper.toEntity(userId, conversation, sendMessageRequest); + List attachments = getValidAttachments(sendMessageRequest.attachmentIds()); + attachments.forEach(attachment -> attachment.setMessage(message)); + message.setAttachments(attachments); + + Message savedMessage = messageRepository.save(message); + publishMessageNotificationEvents(savedMessage); + + logger.info("Action.log.end sendMessage userId: {}, conversationId: {}, messageId: {}", + userId, conversationId, savedMessage.getId()); + return toResponseWithDownloadUrls(savedMessage); + } + + @Transactional + public MessageStateResponse markDelivered(Long userId, Long conversationId, Long messageId) { + logger.info("Action.log.start markDelivered userId: {}, conversationId: {}, messageId: {}", + userId, conversationId, messageId); + + Conversation conversation = validateStateUpdate(userId, conversationId, messageId); + Message message = messageRepository.getReferenceById(messageId); + ConversationDeliveryState state = conversationDeliveryStateRepository.findByConversationIdAndUserId( + conversationId, userId) + .orElseGet(() -> messageStateMapper.toDeliveryState(userId, conversation)); + + if (shouldMoveForward(state.getLastMessageId(), messageId)) { + state.setLastMessageId(message); + state = conversationDeliveryStateRepository.save(state); + } + + Long storedMessageId = state.getLastMessageId().getId(); + logger.info("Action.log.end markDelivered userId: {}, conversationId: {}, messageId: {}", + userId, conversationId, storedMessageId); + return new MessageStateResponse(conversationId, userId, storedMessageId); + } + + @Transactional + public MessageStateResponse markRead(Long userId, Long conversationId, Long messageId) { + logger.info("Action.log.start markRead userId: {}, conversationId: {}, messageId: {}", + userId, conversationId, messageId); + + Conversation conversation = validateStateUpdate(userId, conversationId, messageId); + Message message = messageRepository.getReferenceById(messageId); + ReadDeliveryState state = readDeliveryStateRepository.findByConversationIdAndUserId(conversationId, userId) + .orElseGet(() -> messageStateMapper.toReadState(userId, conversation)); + + if (shouldMoveForward(state.getLastMessageId(), messageId)) { + state.setLastMessageId(message); + state = readDeliveryStateRepository.save(state); + } + + Long storedMessageId = state.getLastMessageId().getId(); + logger.info("Action.log.end markRead userId: {}, conversationId: {}, messageId: {}", + userId, conversationId, storedMessageId); + return new MessageStateResponse(conversationId, userId, storedMessageId); + } + + private Conversation validateStateUpdate(Long userId, Long conversationId, Long messageId) { + Conversation conversation = conversationRepository.findById(conversationId) + .orElseThrow(() -> new ResourceNotFound("Conversation not found")); + + if (!conversationRepository.existsByIdAndConversationParticipantsUserId(conversationId, userId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User is not participant of conversation"); + } + + if (!messageRepository.existsByIdAndConversationId(messageId, conversationId)) { + throw new ResourceNotFound("Message not found in conversation"); + } + + return conversation; + } + + private void validateConversationParticipant(Long userId, Long conversationId) { + if (!conversationRepository.existsByIdAndConversationParticipantsUserId(conversationId, userId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User is not participant of conversation"); + } + } + + private boolean shouldMoveForward(Message currentMessage, Long newMessageId) { + return currentMessage == null || currentMessage.getId() < newMessageId; + } + + private Message findExistingMessage(Long userId, Long conversationId, String clientMessageId) { + if (clientMessageId == null || clientMessageId.isBlank()) { + return null; + } + + return messageRepository.findByConversationIdAndSenderIdAndClientMessageId( + conversationId, + userId, + clientMessageId + ).orElse(null); + } + + private MessageResponse toResponseWithDownloadUrls(Message message) { + MessageResponse messageResponse = messageMapper.toResponse(message); + List attachments = message.getAttachments().stream() + .map(this::toAttachmentResponseWithDownloadUrl) + .toList(); + + return messageMapper.toResponseWithAttachments(messageResponse, attachments); + } + + private void publishMessageNotificationEvents(Message message) { + List events = message.getConversation().getConversationParticipants().stream() + .map(ConversationParticipant::getUserId) + .filter(participantUserId -> !participantUserId.equals(message.getSenderId())) + .map(recipientUserId -> messageMapper.toMessageNotificationEvent(message, recipientUserId)) + .toList(); + + if (events.isEmpty()) { + return; + } + + events.forEach(messageNotificationPublisher::publish); + } + + private AttachmentResponse toAttachmentResponseWithDownloadUrl(Attachment attachment) { + AttachmentResponse attachmentResponse = messageMapper.toAttachmentResponse(attachment); + String downloadUrl = attachment.getStatus() == FileStatus.UPLOADED + ? attachmentService.createDownloadUrl(attachment.getStorageKey()) + : null; + + return messageMapper.toAttachmentResponseWithDownloadUrl(attachmentResponse, downloadUrl); + } + + private List getValidAttachments(List attachmentIds) { + if (CollectionUtils.isEmpty(attachmentIds)) { + return Collections.emptyList(); + } + + Set uniqueAttachmentIds = new LinkedHashSet<>(attachmentIds); + List attachments = attachmentRepository.findAllByIdIn(uniqueAttachmentIds); + if (attachments.size() != uniqueAttachmentIds.size()) { + throw new ResourceNotFound("One or more attachments were not found"); + } + + attachments.forEach(this::validateAttachment); + return attachments; + } + + private void validateAttachment(Attachment attachment) { + if (attachment.getMessage() != null) { + throw new FileValidationException("Attachment is already linked to a message"); + } + + if (attachment.getStatus() != FileStatus.UPLOADED) { + throw new FileValidationException("Attachment must be uploaded before sending message"); + } + } +} diff --git a/src/main/java/com/messaging/chat/service/WebSocketTicketService.java b/src/main/java/com/messaging/chat/service/WebSocketTicketService.java new file mode 100644 index 0000000..e567d14 --- /dev/null +++ b/src/main/java/com/messaging/chat/service/WebSocketTicketService.java @@ -0,0 +1,32 @@ +package com.messaging.chat.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class WebSocketTicketService { + + private final StringRedisTemplate redisTemplate; + + + private static final String TICKET_KEY_PREFIX = "chat:ws-ticket:"; + + + public Optional resolveUserId(String ticket) { + String userId = redisTemplate.opsForValue().getAndDelete(TICKET_KEY_PREFIX + ticket); + if (!StringUtils.hasText(userId)) { + return Optional.empty(); + } + + try { + return Optional.of(Long.valueOf(userId)); + } catch (NumberFormatException ex) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/messaging/chat/util/FileOperationsUtil.java b/src/main/java/com/messaging/chat/util/FileOperationsUtil.java new file mode 100644 index 0000000..67a2abc --- /dev/null +++ b/src/main/java/com/messaging/chat/util/FileOperationsUtil.java @@ -0,0 +1,24 @@ +package com.messaging.chat.util; + +import lombok.experimental.UtilityClass; + +import java.util.UUID; + +@UtilityClass +public class FileOperationsUtil { + + + public String buildStorageKey(Long userId, String fileName) { + String sanitizedFileName = fileName.replaceAll("[^a-zA-Z0-9._-]", "_"); + return "attachments/%d/%s-%s".formatted(userId, UUID.randomUUID(), sanitizedFileName); + } + + public String getFileExtension(String fileName) { + int extensionSeparatorIndex = fileName.lastIndexOf('.'); + if (extensionSeparatorIndex == -1 || extensionSeparatorIndex == fileName.length() - 1) { + return ""; + } + + return fileName.substring(extensionSeparatorIndex + 1).toLowerCase(); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..c03202e --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,58 @@ +spring: + application: + name: chat + datasource: + url: jdbc:postgresql://localhost:5432/chat + username: postgres + password: postgres + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: none + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.PostgreSQLDialect + liquibase: + change-log: classpath:/db/changelog/db.changelog-master.yaml + rabbitmq: + host: + port: + username: + password: + data: + redis: + host: + port: + username: + password: + +logging: + level: + root: INFO + com.messaging.chat: DEBUG + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: when_authorized + +storage: + s3: + bucket-name: + region: + access-key-id: + secret-access-key: + presign-duration-minutes: 5 + +rabbitmq: + notification-queue: + notification-exchange: + notification-routing-key: + notification-dlq: + notification-dlx: diff --git a/src/main/resources/db/changelog/changes/1.10/1.10-change.yaml b/src/main/resources/db/changelog/changes/1.10/1.10-change.yaml new file mode 100644 index 0000000..f4625a5 --- /dev/null +++ b/src/main/resources/db/changelog/changes/1.10/1.10-change.yaml @@ -0,0 +1,153 @@ +databaseChangeLog: + - changeSet: + id: creating_initial_tables + author: Farid Gurbanov + changes: + - createTable: + tableName: conversations + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + primaryKeyName: pk_conversations + nullable: false + - column: + name: created_at + type: TIMESTAMP + - column: + name: updated_at + type: TIMESTAMP + - createTable: + tableName: messages + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + primaryKeyName: pk_messages + nullable: false + - column: + name: conversation_id + type: BIGINT + constraints: + nullable: false + - column: + name: sender_id + type: BIGINT + - column: + name: client_message_id + type: VARCHAR(255) + - column: + name: type + type: VARCHAR(255) + - column: + name: text_content + type: VARCHAR(255) + - column: + name: created_at + type: TIMESTAMP + - column: + name: updated_at + type: TIMESTAMP + - createTable: + tableName: attachments + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + primaryKeyName: pk_attachments + nullable: false + - column: + name: message_id + type: BIGINT + constraints: + nullable: false + - column: + name: uploader_id + type: BIGINT + - column: + name: storage_key + type: VARCHAR(255) + - column: + name: original_file_name + type: VARCHAR(255) + - column: + name: content_type + type: VARCHAR(255) + - column: + name: file_size + type: BIGINT + - column: + name: checksum + type: VARCHAR(255) + - column: + name: thumbnail_storage_key + type: VARCHAR(255) + - column: + name: public_url + type: VARCHAR(255) + - column: + name: status + type: VARCHAR(255) + - column: + name: created_at + type: TIMESTAMP + - column: + name: updated_at + type: TIMESTAMP + - createTable: + tableName: conversation_participants + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + primaryKeyName: pk_conversation_participants + nullable: false + - column: + name: conversation_id + type: BIGINT + - column: + name: user_id + type: BIGINT + - column: + name: created_at + type: TIMESTAMP + - column: + name: updated_at + type: TIMESTAMP + - addForeignKeyConstraint: + constraintName: fk_messages_conversation + baseTableName: messages + baseColumnNames: conversation_id + referencedTableName: conversations + referencedColumnNames: id + onDelete: CASCADE + - addForeignKeyConstraint: + constraintName: fk_attachments_message + baseTableName: attachments + baseColumnNames: message_id + referencedTableName: messages + referencedColumnNames: id + onDelete: CASCADE + - addForeignKeyConstraint: + constraintName: fk_conversation_participants_conversation + baseTableName: conversation_participants + baseColumnNames: conversation_id + referencedTableName: conversations + referencedColumnNames: id + onDelete: CASCADE + - addUniqueConstraint: + tableName: conversation_participants + columnNames: conversation_id, user_id + constraintName: uk_conversation_participants_conversation_user diff --git a/src/main/resources/db/changelog/changes/1.10/1.11-change.yaml b/src/main/resources/db/changelog/changes/1.10/1.11-change.yaml new file mode 100644 index 0000000..258c822 --- /dev/null +++ b/src/main/resources/db/changelog/changes/1.10/1.11-change.yaml @@ -0,0 +1,15 @@ +databaseChangeLog: + - changeSet: + id: make_attachment_message_optional_for_pending_uploads + author: Farid Gurbanov + changes: + - dropNotNullConstraint: + tableName: attachments + columnName: message_id + columnDataType: BIGINT + - dropColumn: + tableName: attachments + columnName: thumbnail_storage_key + - dropColumn: + tableName: attachments + columnName: public_url diff --git a/src/main/resources/db/changelog/changes/1.10/1.12-change.yaml b/src/main/resources/db/changelog/changes/1.10/1.12-change.yaml new file mode 100644 index 0000000..1ead08f --- /dev/null +++ b/src/main/resources/db/changelog/changes/1.10/1.12-change.yaml @@ -0,0 +1,101 @@ +databaseChangeLog: + - changeSet: + id: create_conversation_delivery_and_read_state_tables + author: Farid Gurbanov + changes: + - createTable: + tableName: conversation_delivery_state + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + primaryKeyName: pk_conversation_delivery_state + nullable: false + - column: + name: user_id + type: BIGINT + constraints: + nullable: false + - column: + name: conversation_id + type: BIGINT + constraints: + nullable: false + - column: + name: last_delivered_message_id + type: BIGINT + - column: + name: created_at + type: TIMESTAMP + - column: + name: updated_at + type: TIMESTAMP + - createTable: + tableName: read_delivery_state + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + primaryKeyName: pk_read_delivery_state + nullable: false + - column: + name: user_id + type: BIGINT + constraints: + nullable: false + - column: + name: conversation_id + type: BIGINT + constraints: + nullable: false + - column: + name: last_read_message_id + type: BIGINT + - column: + name: created_at + type: TIMESTAMP + - column: + name: updated_at + type: TIMESTAMP + - addForeignKeyConstraint: + constraintName: fk_conversation_delivery_state_conversation + baseTableName: conversation_delivery_state + baseColumnNames: conversation_id + referencedTableName: conversations + referencedColumnNames: id + onDelete: CASCADE + - addForeignKeyConstraint: + constraintName: fk_conversation_delivery_state_message + baseTableName: conversation_delivery_state + baseColumnNames: last_delivered_message_id + referencedTableName: messages + referencedColumnNames: id + onDelete: SET NULL + - addForeignKeyConstraint: + constraintName: fk_read_delivery_state_conversation + baseTableName: read_delivery_state + baseColumnNames: conversation_id + referencedTableName: conversations + referencedColumnNames: id + onDelete: CASCADE + - addForeignKeyConstraint: + constraintName: fk_read_delivery_state_message + baseTableName: read_delivery_state + baseColumnNames: last_read_message_id + referencedTableName: messages + referencedColumnNames: id + onDelete: SET NULL + - addUniqueConstraint: + tableName: conversation_delivery_state + columnNames: conversation_id, user_id + constraintName: uk_conversation_delivery_state_conversation_user + - addUniqueConstraint: + tableName: read_delivery_state + columnNames: conversation_id, user_id + constraintName: uk_read_delivery_state_conversation_user \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/1.10/1.13-change.yaml b/src/main/resources/db/changelog/changes/1.10/1.13-change.yaml new file mode 100644 index 0000000..026a36e --- /dev/null +++ b/src/main/resources/db/changelog/changes/1.10/1.13-change.yaml @@ -0,0 +1,9 @@ +databaseChangeLog: + - changeSet: + id: add_message_client_id_idempotency_constraint + author: Farid Gurbanov + changes: + - addUniqueConstraint: + tableName: messages + columnNames: conversation_id, sender_id, client_message_id + constraintName: uk_messages_conversation_sender_client_message diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 0000000..9e8c0d1 --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,3 @@ +databaseChangeLog: + - includeAll: + path: db/changelog/changes/1.10 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..8b10dc1 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,15 @@ + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/src/test/groovy/com/messaging/chat/controller/AttachmentControllerSpec.groovy b/src/test/groovy/com/messaging/chat/controller/AttachmentControllerSpec.groovy new file mode 100644 index 0000000..b4482c7 --- /dev/null +++ b/src/test/groovy/com/messaging/chat/controller/AttachmentControllerSpec.groovy @@ -0,0 +1,153 @@ +package com.messaging.chat.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.messaging.chat.model.constant.FileStatus +import com.messaging.chat.model.dto.request.PresignUploadRequest +import com.messaging.chat.model.dto.response.AttachmentConfirmResponse +import com.messaging.chat.model.dto.response.PresignUploadResponse +import com.messaging.chat.service.AttachmentService +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean +import spock.lang.Specification + +import java.time.Instant + +import static com.messaging.chat.model.constant.Headers.USER_ID_HEADER +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +class AttachmentControllerSpec extends Specification { + + private AttachmentService attachmentService + private MockMvc mockMvc + private ObjectMapper objectMapper + + def setup() { + attachmentService = Mock() + objectMapper = new ObjectMapper().findAndRegisterModules() + + def validator = new LocalValidatorFactoryBean() + validator.afterPropertiesSet() + + mockMvc = MockMvcBuilders + .standaloneSetup(new AttachmentController(attachmentService)) + .setValidator(validator) + .build() + } + + def "presign upload delegates to service and returns response"() { + given: + def request = new PresignUploadRequest( + "photo.png", + "image/png", + 1024L, + "1B2M2Y8AsgTpgAmY7PhCfg==" + ) + def response = new PresignUploadResponse( + 10L, + "attachments/1/photo.png", + "https://s3.example/upload", + "PUT", + Instant.parse("2026-04-19T02:17:36Z"), + ["content-type": "image/png"] + ) + + when: + def result = mockMvc.perform(post("/ms-chat/attachments/presign-upload") + .header(USER_ID_HEADER, "1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + + then: + 1 * attachmentService.presignUpload(1L, { + it.fileName() == "photo.png" && + it.contentType() == "image/png" && + it.fileSize() == 1024L && + it.checksum() == "1B2M2Y8AsgTpgAmY7PhCfg==" + }) >> response + 0 * _ + + and: + result.andExpect(status().isOk()) + .andExpect(jsonPath('$.attachmentId').value(10)) + .andExpect(jsonPath('$.storageKey').value("attachments/1/photo.png")) + .andExpect(jsonPath('$.uploadUrl').value("https://s3.example/upload")) + .andExpect(jsonPath('$.httpMethod').value("PUT")) + .andExpect(jsonPath('$.expiresAt').value("2026-04-19T02:17:36Z")) + .andExpect(jsonPath('$.uploadHeaders.content-type').value("image/png")) + } + + def "presign upload returns bad request when body is invalid"() { + given: + def invalidBody = [ + fileName : "", + contentType: "image/png", + fileSize : 1024L + ] + + when: + def result = mockMvc.perform(post("/ms-chat/attachments/presign-upload") + .header(USER_ID_HEADER, "1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidBody))) + + then: + 0 * attachmentService._ + + and: + result.andExpect(status().isBadRequest()) + } + + def "presign upload returns bad request when user id header is missing"() { + given: + def request = new PresignUploadRequest("photo.png", "image/png", 1024L, null) + + when: + def result = mockMvc.perform(post("/ms-chat/attachments/presign-upload") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + + then: + 0 * attachmentService._ + + and: + result.andExpect(status().isBadRequest()) + } + + def "confirm upload delegates to service and returns response"() { + given: + def response = new AttachmentConfirmResponse( + 10L, + "attachments/1/photo.png", + FileStatus.UPLOADED + ) + + when: + def result = mockMvc.perform(post("/ms-chat/attachments/10/confirm") + .header(USER_ID_HEADER, "1")) + + then: + 1 * attachmentService.confirmUpload(1L, 10L) >> response + 0 * _ + + and: + result.andExpect(status().isOk()) + .andExpect(jsonPath('$.id').value(10)) + .andExpect(jsonPath('$.storageKey').value("attachments/1/photo.png")) + .andExpect(jsonPath('$.status').value("UPLOADED")) + } + + def "confirm upload returns bad request when user id header is missing"() { + when: + def result = mockMvc.perform(post("/ms-chat/attachments/10/confirm")) + + then: + 0 * attachmentService._ + + and: + result.andExpect(status().isBadRequest()) + } +} diff --git a/src/test/groovy/com/messaging/chat/controller/ChatWebSocketControllerSpec.groovy b/src/test/groovy/com/messaging/chat/controller/ChatWebSocketControllerSpec.groovy new file mode 100644 index 0000000..1020ccb --- /dev/null +++ b/src/test/groovy/com/messaging/chat/controller/ChatWebSocketControllerSpec.groovy @@ -0,0 +1,79 @@ +package com.messaging.chat.controller + +import com.messaging.chat.dao.repository.ConversationRepository +import com.messaging.chat.model.constant.MessageType +import com.messaging.chat.model.dto.request.MessageStateRequest +import com.messaging.chat.model.dto.request.SendMessageRequest +import com.messaging.chat.model.dto.response.MessageResponse +import com.messaging.chat.model.dto.response.MessageStateResponse +import com.messaging.chat.service.MessageService +import org.springframework.messaging.simp.SimpMessagingTemplate +import spock.lang.Specification + +import java.security.Principal +import java.time.LocalDateTime + +class ChatWebSocketControllerSpec extends Specification { + + private MessageService messageService + private ConversationRepository conversationRepository + private SimpMessagingTemplate messagingTemplate + private ChatWebSocketController controller + private Principal principal + + def setup() { + messageService = Mock() + conversationRepository = Mock() + messagingTemplate = Mock() + controller = new ChatWebSocketController(messageService, conversationRepository, messagingTemplate) + principal = Stub(Principal) { + getName() >> "1" + } + } + + def "send message saves message and publishes to all participants private queues"() { + given: + def request = new SendMessageRequest("client-1", MessageType.TEXT, "hello", []) + def response = new MessageResponse(100L, 20L, 1L, "client-1", MessageType.TEXT, "hello", [], LocalDateTime.now()) + + when: + controller.sendMessage(principal, 20L, request) + + then: + 1 * messageService.sendMessage(1L, 20L, request) >> response + 1 * conversationRepository.findParticipantUserIdsByConversationId(20L) >> [1L, 2L] + 1 * messagingTemplate.convertAndSendToUser("1", "/queue/messages", response) + 1 * messagingTemplate.convertAndSendToUser("2", "/queue/messages", response) + 0 * _ + } + + def "mark delivered publishes delivery state to participants"() { + given: + def response = new MessageStateResponse(20L, 2L, 100L) + + when: + controller.markDelivered(principal, 20L, new MessageStateRequest(100L)) + + then: + 1 * messageService.markDelivered(1L, 20L, 100L) >> response + 1 * conversationRepository.findParticipantUserIdsByConversationId(20L) >> [1L, 2L] + 1 * messagingTemplate.convertAndSendToUser("1", "/queue/delivery", response) + 1 * messagingTemplate.convertAndSendToUser("2", "/queue/delivery", response) + 0 * _ + } + + def "mark read publishes read state to participants"() { + given: + def response = new MessageStateResponse(20L, 2L, 100L) + + when: + controller.markRead(principal, 20L, new MessageStateRequest(100L)) + + then: + 1 * messageService.markRead(1L, 20L, 100L) >> response + 1 * conversationRepository.findParticipantUserIdsByConversationId(20L) >> [1L, 2L] + 1 * messagingTemplate.convertAndSendToUser("1", "/queue/read", response) + 1 * messagingTemplate.convertAndSendToUser("2", "/queue/read", response) + 0 * _ + } +} diff --git a/src/test/groovy/com/messaging/chat/controller/ConversationControllerSpec.groovy b/src/test/groovy/com/messaging/chat/controller/ConversationControllerSpec.groovy new file mode 100644 index 0000000..add8ae9 --- /dev/null +++ b/src/test/groovy/com/messaging/chat/controller/ConversationControllerSpec.groovy @@ -0,0 +1,190 @@ +package com.messaging.chat.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.messaging.chat.model.constant.MessageType +import com.messaging.chat.model.dto.request.CreateConversationRequest +import com.messaging.chat.model.dto.request.MessageStateRequest +import com.messaging.chat.model.dto.request.SendMessageRequest +import com.messaging.chat.model.dto.response.ConversationListResponse +import com.messaging.chat.model.dto.response.ConversationResponse +import com.messaging.chat.model.dto.response.MessageResponse +import com.messaging.chat.model.dto.response.MessageStateResponse +import com.messaging.chat.service.ConversationService +import com.messaging.chat.service.MessageService +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean +import spock.lang.Specification + +import java.time.LocalDateTime + +import static com.messaging.chat.model.constant.Headers.USER_ID_HEADER +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +class ConversationControllerSpec extends Specification { + + private ConversationService conversationService + private MessageService messageService + private MockMvc mockMvc + private ObjectMapper objectMapper + + def setup() { + conversationService = Mock() + messageService = Mock() + objectMapper = new ObjectMapper().findAndRegisterModules() + + def validator = new LocalValidatorFactoryBean() + validator.afterPropertiesSet() + + mockMvc = MockMvcBuilders + .standaloneSetup(new ConversationController(conversationService, messageService)) + .setValidator(validator) + .build() + } + + def "create conversation delegates to service"() { + given: + def request = new CreateConversationRequest([2L, 3L]) + def response = new ConversationResponse(11L, [1L, 2L, 3L]) + + when: + def result = mockMvc.perform(post("/ms-chat/conversations") + .header(USER_ID_HEADER, "1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + + then: + 1 * conversationService.createConversations(1L, { it.participantUserIds() == [2L, 3L] }) >> response + 0 * _ + + and: + result.andExpect(status().isOk()) + .andExpect(jsonPath('$.id').value(11)) + .andExpect(jsonPath('$.participantUserIds[0]').value(1)) + .andExpect(jsonPath('$.participantUserIds[2]').value(3)) + } + + def "list conversations passes pagination parameters"() { + given: + def response = [new ConversationListResponse(11L, [1L, 2L], null, null, null)] + + when: + def result = mockMvc.perform(get("/ms-chat/conversations") + .header(USER_ID_HEADER, "1") + .param("limit", "15") + .param("cursor", "50")) + + then: + 1 * conversationService.getConversationList(1L, 15, 50) >> response + 0 * _ + + and: + result.andExpect(status().isOk()) + .andExpect(jsonPath('$[0].id').value(11)) + .andExpect(jsonPath('$[0].participantUserIds[1]').value(2)) + } + + def "get messages delegates to message service"() { + given: + def response = [ + new MessageResponse(100L, 20L, 1L, "client-1", MessageType.TEXT, "hello", [], LocalDateTime.now()) + ] + + when: + def result = mockMvc.perform(get("/ms-chat/conversations/20/messages") + .header(USER_ID_HEADER, "1") + .param("beforeMessageId", "90") + .param("limit", "10")) + + then: + 1 * messageService.getMessages(1L, 20L, 90L, 10) >> response + 0 * _ + + and: + result.andExpect(status().isOk()) + .andExpect(jsonPath('$[0].id').value(100)) + .andExpect(jsonPath('$[0].textContent').value("hello")) + } + + def "send message delegates to message service"() { + given: + def request = new SendMessageRequest("client-1", MessageType.TEXT, "hello", []) + def response = new MessageResponse(100L, 20L, 1L, "client-1", MessageType.TEXT, "hello", [], LocalDateTime.now()) + + when: + def result = mockMvc.perform(post("/ms-chat/conversations/20/messages") + .header(USER_ID_HEADER, "1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + + then: + 1 * messageService.sendMessage(1L, 20L, { it.clientMessageId() == "client-1" }) >> response + 0 * _ + + and: + result.andExpect(status().isOk()) + .andExpect(jsonPath('$.id').value(100)) + .andExpect(jsonPath('$.conversationId').value(20)) + } + + def "mark delivered delegates to message service"() { + given: + def request = new MessageStateRequest(100L) + def response = new MessageStateResponse(20L, 1L, 100L) + + when: + def result = mockMvc.perform(post("/ms-chat/conversations/20/delivery") + .header(USER_ID_HEADER, "1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + + then: + 1 * messageService.markDelivered(1L, 20L, 100L) >> response + 0 * _ + + and: + result.andExpect(status().isOk()) + .andExpect(jsonPath('$.conversationId').value(20)) + .andExpect(jsonPath('$.messageId').value(100)) + } + + def "mark read delegates to message service"() { + given: + def request = new MessageStateRequest(100L) + def response = new MessageStateResponse(20L, 1L, 100L) + + when: + def result = mockMvc.perform(post("/ms-chat/conversations/20/read") + .header(USER_ID_HEADER, "1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + + then: + 1 * messageService.markRead(1L, 20L, 100L) >> response + 0 * _ + + and: + result.andExpect(status().isOk()) + .andExpect(jsonPath('$.conversationId').value(20)) + .andExpect(jsonPath('$.messageId').value(100)) + } + + def "invalid request body returns bad request before service call"() { + when: + def result = mockMvc.perform(post("/ms-chat/conversations") + .header(USER_ID_HEADER, "1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString([participantUserIds: []]))) + + then: + 0 * conversationService._ + 0 * messageService._ + + and: + result.andExpect(status().isBadRequest()) + } +} diff --git a/src/test/groovy/com/messaging/chat/controller/ErrorHandlerSpec.groovy b/src/test/groovy/com/messaging/chat/controller/ErrorHandlerSpec.groovy new file mode 100644 index 0000000..998989f --- /dev/null +++ b/src/test/groovy/com/messaging/chat/controller/ErrorHandlerSpec.groovy @@ -0,0 +1,46 @@ +package com.messaging.chat.controller + +import com.messaging.chat.model.exceptions.FileValidationException +import com.messaging.chat.model.exceptions.ResourceNotFound +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import spock.lang.Specification + +class ErrorHandlerSpec extends Specification { + + private ErrorHandler errorHandler = new ErrorHandler() + + def "resource not found maps to 404 response"() { + when: + def response = errorHandler.handleResourceNotFound(new ResourceNotFound("missing")) + + then: + response.statusCode == HttpStatus.NOT_FOUND + response.body == [message: "missing"] + } + + def "response status exception keeps status and reason"() { + when: + def response = errorHandler.handleResponseStatusException( + new ResponseStatusException(HttpStatus.FORBIDDEN, "denied") + ) + + then: + response.statusCode == HttpStatus.FORBIDDEN + response.body == [message: "denied"] + } + + def "generic exception maps to 500 without leaking details"() { + when: + def response = errorHandler.handleException(new RuntimeException("secret")) + + then: + response.statusCode == HttpStatus.INTERNAL_SERVER_ERROR + response.body == [message: "Internal server error"] + } + + def "file validation exception maps to message body"() { + expect: + errorHandler.handleFileValidationException(new FileValidationException("bad file")) == [message: "bad file"] + } +} diff --git a/src/test/groovy/com/messaging/chat/logging/DPLoggerSpec.groovy b/src/test/groovy/com/messaging/chat/logging/DPLoggerSpec.groovy new file mode 100644 index 0000000..589fe41 --- /dev/null +++ b/src/test/groovy/com/messaging/chat/logging/DPLoggerSpec.groovy @@ -0,0 +1,85 @@ +package com.messaging.chat.logging + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import org.slf4j.LoggerFactory +import spock.lang.Specification + +class DPLoggerSpec extends Specification { + + private Logger delegateLogger + private ListAppender appender + private DPLogger logger + + def setup() { + delegateLogger = (Logger) LoggerFactory.getLogger(DPLoggerSpec) + delegateLogger.level = Level.DEBUG + delegateLogger.additive = false + + appender = new ListAppender<>() + appender.start() + delegateLogger.addAppender(appender) + + logger = DPLogger.getLogger(DPLoggerSpec) + } + + def cleanup() { + delegateLogger.detachAppender(appender) + appender.stop() + } + + def "masks phone number written in log message"() { + when: + logger.info("User phone is 0501234567") + + then: + appender.list.size() == 1 + appender.list[0].formattedMessage == "User phone is ******4567" + } + + def "masks phone number passed as string argument"() { + when: + logger.info("User phone is {}", "0501234567") + + then: + appender.list.size() == 1 + appender.list[0].formattedMessage == "User phone is ******4567" + } + + def "preserves formatting while masking phone digits"() { + when: + logger.info("International phone {}", "+994 50 123 45 67") + + then: + appender.list.size() == 1 + appender.list[0].formattedMessage == "International phone +*** ** *** 45 67" + } + + def "does not mask non string arguments or short numeric values"() { + when: + logger.info("User id {} code {}", 123456789L, "1234") + + then: + appender.list.size() == 1 + appender.list[0].formattedMessage == "User id 123456789 code 1234" + } + + def "masks phone numbers for every supported level"() { + when: + logAction(logger) + + then: + appender.list.size() == 1 + appender.list[0].level == expectedLevel + appender.list[0].formattedMessage == "Phone ******4567" + + where: + expectedLevel | logAction + Level.INFO | { DPLogger logger -> logger.info("Phone 0501234567") } + Level.DEBUG | { DPLogger logger -> logger.debug("Phone 0501234567") } + Level.WARN | { DPLogger logger -> logger.warn("Phone 0501234567") } + Level.ERROR | { DPLogger logger -> logger.error("Phone 0501234567") } + } +} diff --git a/src/test/groovy/com/messaging/chat/mapper/AttachmentMapperSpec.groovy b/src/test/groovy/com/messaging/chat/mapper/AttachmentMapperSpec.groovy new file mode 100644 index 0000000..3c226a7 --- /dev/null +++ b/src/test/groovy/com/messaging/chat/mapper/AttachmentMapperSpec.groovy @@ -0,0 +1,48 @@ +package com.messaging.chat.mapper + +import com.messaging.chat.dao.entity.Attachment +import com.messaging.chat.model.constant.FileStatus +import com.messaging.chat.model.dto.request.PresignUploadRequest +import org.mapstruct.factory.Mappers +import spock.lang.Specification + +class AttachmentMapperSpec extends Specification { + + private AttachmentMapper mapper = Mappers.getMapper(AttachmentMapper) + + def "maps presign request to pending attachment entity"() { + given: + def request = new PresignUploadRequest("photo.png", "image/png", 1024L, "checksum") + + when: + def entity = mapper.toPendingEntity(1L, "attachments/1/photo.png", request) + + then: + entity.id == null + entity.uploaderId == 1L + entity.storageKey == "attachments/1/photo.png" + entity.originalFileName == "photo.png" + entity.contentType == "image/png" + entity.fileSize == 1024L + entity.checksum == "checksum" + entity.status == FileStatus.PENDING + entity.message == null + } + + def "maps attachment to confirm response"() { + given: + def attachment = new Attachment( + id: 10L, + storageKey: "attachments/1/photo.png", + status: FileStatus.UPLOADED + ) + + when: + def response = mapper.toAttachmentConfirmResponse(attachment) + + then: + response.id() == 10L + response.storageKey() == "attachments/1/photo.png" + response.status() == FileStatus.UPLOADED + } +} diff --git a/src/test/groovy/com/messaging/chat/mapper/ConversationMapperSpec.groovy b/src/test/groovy/com/messaging/chat/mapper/ConversationMapperSpec.groovy new file mode 100644 index 0000000..e2c8f10 --- /dev/null +++ b/src/test/groovy/com/messaging/chat/mapper/ConversationMapperSpec.groovy @@ -0,0 +1,55 @@ +package com.messaging.chat.mapper + +import com.messaging.chat.dao.entity.Conversation +import com.messaging.chat.dao.entity.ConversationParticipant +import com.messaging.chat.model.constant.MessageType +import com.messaging.chat.model.dto.response.MessagePreview +import org.mapstruct.factory.Mappers +import spock.lang.Specification + +import java.time.LocalDateTime + +class ConversationMapperSpec extends Specification { + + private ConversationMapper mapper = Mappers.getMapper(ConversationMapper) + + def "maps conversation participants to response user ids"() { + given: + def conversation = conversation(20L, [1L, 2L]) + + when: + def response = mapper.toResponse(conversation) + + then: + response.id() == 20L + response.participantUserIds() == [1L, 2L] + } + + def "maps conversation list response with message preview and timestamps"() { + given: + def createdAt = LocalDateTime.of(2026, 4, 19, 10, 0) + def updatedAt = LocalDateTime.of(2026, 4, 19, 10, 5) + def conversation = conversation(20L, [1L, 2L]) + conversation.createdAt = createdAt + conversation.updatedAt = updatedAt + def preview = new MessagePreview(100L, MessageType.TEXT, 1L, "hello", updatedAt) + + when: + def response = mapper.toListResponse(conversation, preview) + + then: + response.id() == 20L + response.participantUserIds() == [1L, 2L] + response.messagePreview() == preview + response.createdAt() == createdAt + response.updatedAt() == updatedAt + } + + private static Conversation conversation(Long id, List userIds) { + def conversation = new Conversation(id: id) + conversation.conversationParticipants = userIds.collect { + new ConversationParticipant(userId: it, conversation: conversation) + } + conversation + } +} diff --git a/src/test/groovy/com/messaging/chat/mapper/ConversationParticipantMapperSpec.groovy b/src/test/groovy/com/messaging/chat/mapper/ConversationParticipantMapperSpec.groovy new file mode 100644 index 0000000..0707d28 --- /dev/null +++ b/src/test/groovy/com/messaging/chat/mapper/ConversationParticipantMapperSpec.groovy @@ -0,0 +1,25 @@ +package com.messaging.chat.mapper + +import com.messaging.chat.dao.entity.Conversation +import org.mapstruct.factory.Mappers +import spock.lang.Specification + +class ConversationParticipantMapperSpec extends Specification { + + private ConversationParticipantMapper mapper = Mappers.getMapper(ConversationParticipantMapper) + + def "maps user id and conversation to participant entity"() { + given: + def conversation = new Conversation(id: 20L) + + when: + def participant = mapper.toEntity(1L, conversation) + + then: + participant.id == null + participant.userId == 1L + participant.conversation.is(conversation) + participant.createdAt == null + participant.updatedAt == null + } +} diff --git a/src/test/groovy/com/messaging/chat/mapper/MessageMapperSpec.groovy b/src/test/groovy/com/messaging/chat/mapper/MessageMapperSpec.groovy new file mode 100644 index 0000000..d396a71 --- /dev/null +++ b/src/test/groovy/com/messaging/chat/mapper/MessageMapperSpec.groovy @@ -0,0 +1,122 @@ +package com.messaging.chat.mapper + +import com.messaging.chat.dao.entity.Attachment +import com.messaging.chat.dao.entity.Conversation +import com.messaging.chat.dao.entity.Message +import com.messaging.chat.model.constant.FileStatus +import com.messaging.chat.model.constant.MessageType +import com.messaging.chat.model.dto.request.SendMessageRequest +import com.messaging.chat.model.dto.response.AttachmentResponse +import com.messaging.chat.model.dto.response.MessageResponse +import org.mapstruct.factory.Mappers +import spock.lang.Specification + +import java.time.LocalDateTime + +class MessageMapperSpec extends Specification { + + private MessageMapper mapper = Mappers.getMapper(MessageMapper) + + def "maps send message request to message entity"() { + given: + def conversation = new Conversation(id: 20L) + def request = new SendMessageRequest("client-1", MessageType.TEXT, "hello", []) + + when: + def message = mapper.toEntity(1L, conversation, request) + + then: + message.id == null + message.conversation.is(conversation) + message.senderId == 1L + message.clientMessageId == "client-1" + message.type == MessageType.TEXT + message.textContent == "hello" + message.attachments == [] + } + + def "maps message to response and preview"() { + given: + def createdAt = LocalDateTime.of(2026, 4, 19, 10, 5) + def message = new Message( + id: 100L, + conversation: new Conversation(id: 20L), + senderId: 1L, + clientMessageId: "client-1", + type: MessageType.TEXT, + textContent: "hello", + createdAt: createdAt + ) + + when: + def response = mapper.toResponse(message) + def preview = mapper.toMessagePreview(message) + + then: + response.id() == 100L + response.conversationId() == 20L + response.senderId() == 1L + response.clientMessageId() == "client-1" + response.type() == MessageType.TEXT + response.textContent() == "hello" + response.createdAt() == createdAt + + and: + preview.id() == 100L + preview.type() == MessageType.TEXT + preview.userId() == 1L + preview.preview() == "hello" + preview.createdAt() == createdAt + } + + def "maps attachment response and download url"() { + given: + def attachment = new Attachment( + id: 10L, + originalFileName: "photo.png", + contentType: "image/png", + fileSize: 1024L, + status: FileStatus.UPLOADED + ) + + when: + def attachmentResponse = mapper.toAttachmentResponse(attachment) + def withDownload = mapper.toAttachmentResponseWithDownloadUrl(attachmentResponse, "https://download") + + then: + attachmentResponse.id() == 10L + attachmentResponse.fileName() == "photo.png" + attachmentResponse.downloadUrl() == null + + and: + withDownload.downloadUrl() == "https://download" + withDownload.fileName() == "photo.png" + } + + def "maps response with replacement attachments and notification event"() { + given: + def createdAt = LocalDateTime.of(2026, 4, 19, 10, 5) + def response = new MessageResponse(100L, 20L, 1L, "client-1", MessageType.TEXT, "hello", [], createdAt) + def attachments = [new AttachmentResponse(10L, "photo.png", "image/png", 1024L, FileStatus.UPLOADED, "url")] + def message = new Message( + id: 100L, + conversation: new Conversation(id: 20L), + senderId: 1L, + type: MessageType.TEXT, + textContent: "hello" + ) + + expect: + mapper.toResponseWithAttachments(response, attachments).attachments() == attachments + + and: + with(mapper.toMessageNotificationEvent(message, 2L)) { + recipientUserId() == 2L + senderId() == 1L + conversationId() == 20L + messageId() == 100L + messageType() == MessageType.TEXT + textContent() == "hello" + } + } +} diff --git a/src/test/groovy/com/messaging/chat/mapper/MessageStateMapperSpec.groovy b/src/test/groovy/com/messaging/chat/mapper/MessageStateMapperSpec.groovy new file mode 100644 index 0000000..2b0bb0d --- /dev/null +++ b/src/test/groovy/com/messaging/chat/mapper/MessageStateMapperSpec.groovy @@ -0,0 +1,31 @@ +package com.messaging.chat.mapper + +import com.messaging.chat.dao.entity.Conversation +import org.mapstruct.factory.Mappers +import spock.lang.Specification + +class MessageStateMapperSpec extends Specification { + + private MessageStateMapper mapper = Mappers.getMapper(MessageStateMapper) + + def "maps delivery and read state shell entities"() { + given: + def conversation = new Conversation(id: 20L) + + when: + def delivery = mapper.toDeliveryState(1L, conversation) + def read = mapper.toReadState(2L, conversation) + + then: + delivery.id == null + delivery.userId == 1L + delivery.conversation.is(conversation) + delivery.lastMessageId == null + + and: + read.id == null + read.userId == 2L + read.conversation.is(conversation) + read.lastMessageId == null + } +} diff --git a/src/test/groovy/com/messaging/chat/service/AttachmentServiceSpec.groovy b/src/test/groovy/com/messaging/chat/service/AttachmentServiceSpec.groovy new file mode 100644 index 0000000..6cec05a --- /dev/null +++ b/src/test/groovy/com/messaging/chat/service/AttachmentServiceSpec.groovy @@ -0,0 +1,117 @@ +package com.messaging.chat.service + +import com.messaging.chat.dao.entity.Attachment +import com.messaging.chat.dao.repository.AttachmentRepository +import com.messaging.chat.mapper.AttachmentMapper +import com.messaging.chat.model.constant.FileStatus +import com.messaging.chat.model.dto.request.PresignUploadRequest +import com.messaging.chat.model.exceptions.FileValidationException +import com.messaging.chat.model.exceptions.ResourceNotFound +import org.mapstruct.factory.Mappers +import org.springframework.test.util.ReflectionTestUtils +import spock.lang.Specification +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.HeadObjectResponse +import software.amazon.awssdk.services.s3.presigner.S3Presigner + +class AttachmentServiceSpec extends Specification { + + private S3Presigner s3Presigner + private S3Client s3Client + private AttachmentRepository attachmentRepository + private AttachmentService service + + def setup() { + s3Presigner = Mock() + s3Client = Mock() + attachmentRepository = Mock() + service = new AttachmentService( + s3Presigner, + s3Client, + attachmentRepository, + Mappers.getMapper(AttachmentMapper) + ) + ReflectionTestUtils.setField(service, "bucketName", "bucket") + ReflectionTestUtils.setField(service, "presignDurationMinutes", 5L) + } + + def "presign upload rejects invalid file before saving"() { + when: + service.presignUpload(1L, request) + + then: + thrown(FileValidationException) + 0 * attachmentRepository._ + 0 * s3Presigner._ + + where: + request << [ + new PresignUploadRequest("big.png", "image/png", 5L * 1024L * 1024L, null), + new PresignUploadRequest("script.sh", "text/plain", 1024L, null), + new PresignUploadRequest("photo.png", "application/x-msdownload", 1024L, null) + ] + } + + def "confirm upload marks attachment uploaded when object metadata matches"() { + given: + def attachment = attachment(FileStatus.PENDING) + def headObject = HeadObjectResponse.builder() + .contentLength(1024L) + .contentType("image/png") + .build() + + when: + def response = service.confirmUpload(1L, 10L) + + then: + 1 * attachmentRepository.findById(10L) >> Optional.of(attachment) + 1 * s3Client.headObject({ it.bucket() == "bucket" && it.key() == "attachments/1/photo.png" }) >> headObject + 1 * attachmentRepository.save({ it.status == FileStatus.UPLOADED }) >> { Attachment saved -> saved } + 0 * _ + + and: + response.id() == 10L + response.storageKey() == "attachments/1/photo.png" + response.status() == FileStatus.UPLOADED + } + + def "confirm upload throws not found when attachment does not exist"() { + when: + service.confirmUpload(1L, 10L) + + then: + 1 * attachmentRepository.findById(10L) >> Optional.empty() + 0 * _ + thrown(ResourceNotFound) + } + + def "confirm upload marks failed when uploaded metadata does not match"() { + given: + def attachment = attachment(FileStatus.PENDING) + def headObject = HeadObjectResponse.builder() + .contentLength(99L) + .contentType("image/png") + .build() + + when: + service.confirmUpload(1L, 10L) + + then: + 1 * attachmentRepository.findById(10L) >> Optional.of(attachment) + 1 * s3Client.headObject(_) >> headObject + 1 * attachmentRepository.save({ it.status == FileStatus.FAILED }) >> { Attachment saved -> saved } + 0 * _ + thrown(FileValidationException) + } + + private static Attachment attachment(FileStatus status) { + new Attachment( + id: 10L, + storageKey: "attachments/1/photo.png", + originalFileName: "photo.png", + contentType: "image/png", + fileSize: 1024L, + status: status + ) + } +} diff --git a/src/test/groovy/com/messaging/chat/service/ConversationServiceSpec.groovy b/src/test/groovy/com/messaging/chat/service/ConversationServiceSpec.groovy new file mode 100644 index 0000000..122a925 --- /dev/null +++ b/src/test/groovy/com/messaging/chat/service/ConversationServiceSpec.groovy @@ -0,0 +1,100 @@ +package com.messaging.chat.service + +import com.messaging.chat.dao.entity.Conversation +import com.messaging.chat.dao.entity.ConversationParticipant +import com.messaging.chat.dao.entity.Message +import com.messaging.chat.dao.repository.ConversationRepository +import com.messaging.chat.mapper.ConversationMapper +import com.messaging.chat.mapper.ConversationParticipantMapper +import com.messaging.chat.mapper.MessageMapper +import com.messaging.chat.model.constant.MessageType +import com.messaging.chat.model.dto.request.CreateConversationRequest +import org.mapstruct.factory.Mappers +import org.springframework.data.domain.PageRequest +import spock.lang.Specification + +import java.time.LocalDateTime + +class ConversationServiceSpec extends Specification { + + private ConversationRepository conversationRepository + private ConversationService service + + def setup() { + conversationRepository = Mock() + service = new ConversationService( + conversationRepository, + Mappers.getMapper(ConversationMapper), + Mappers.getMapper(ConversationParticipantMapper), + Mappers.getMapper(MessageMapper) + ) + } + + def "create conversation includes current user and deduplicates participant ids"() { + given: + def request = new CreateConversationRequest([2L, 1L, null, 2L, 3L]) + + when: + def response = service.createConversations(1L, request) + + then: + 1 * conversationRepository.save({ + it.conversationParticipants*.userId == [1L, 2L, 3L] && + it.conversationParticipants.every { participant -> participant.conversation.is(it) } + } as Conversation) >> { Conversation conversation -> + conversation.id = 20L + conversation + } + 0 * _ + + and: + response.id() == 20L + response.participantUserIds() == [1L, 2L, 3L] + } + + def "get conversation list uses first page when cursor is null and maps latest message preview"() { + given: + def conversation = conversationWithMessages(20L) + + when: + def response = service.getConversationList(1L, 10, null) + + then: + 1 * conversationRepository.findConversationListByUserId(1L, PageRequest.of(0, 10)) >> [conversation] + 0 * _ + + and: + response.size() == 1 + response[0].id() == 20L + response[0].participantUserIds() == [1L, 2L] + response[0].messagePreview().id() == 101L + response[0].messagePreview().preview() == "new" + } + + def "get conversation list uses cursor query when cursor is positive"() { + when: + def response = service.getConversationList(1L, 10, 50) + + then: + 1 * conversationRepository.findConversationListByUserIdAndCursor(1L, 50L, PageRequest.of(0, 10)) >> [] + 0 * _ + + and: + response == [] + } + + private static Conversation conversationWithMessages(Long id) { + def conversation = new Conversation(id: id) + conversation.conversationParticipants = [ + new ConversationParticipant(userId: 1L, conversation: conversation), + new ConversationParticipant(userId: 2L, conversation: conversation) + ] + conversation.messages = [ + new Message(id: 100L, senderId: 1L, type: MessageType.TEXT, textContent: "old", + createdAt: LocalDateTime.of(2026, 4, 19, 10, 0)), + new Message(id: 101L, senderId: 2L, type: MessageType.TEXT, textContent: "new", + createdAt: LocalDateTime.of(2026, 4, 19, 10, 5)) + ] + conversation + } +} diff --git a/src/test/groovy/com/messaging/chat/service/MessageNotificationPublisherSpec.groovy b/src/test/groovy/com/messaging/chat/service/MessageNotificationPublisherSpec.groovy new file mode 100644 index 0000000..f241c55 --- /dev/null +++ b/src/test/groovy/com/messaging/chat/service/MessageNotificationPublisherSpec.groovy @@ -0,0 +1,61 @@ +package com.messaging.chat.service + +import com.messaging.chat.model.constant.MessageType +import com.messaging.chat.model.dto.event.MessageNotificationEvent +import org.springframework.amqp.AmqpException +import org.springframework.amqp.rabbit.core.RabbitTemplate +import org.springframework.context.ApplicationEventPublisher +import org.springframework.test.util.ReflectionTestUtils +import spock.lang.Specification + +class MessageNotificationPublisherSpec extends Specification { + + private ApplicationEventPublisher applicationEventPublisher + private RabbitTemplate rabbitTemplate + private MessageNotificationPublisher publisher + + def setup() { + applicationEventPublisher = Mock() + rabbitTemplate = Mock() + publisher = new MessageNotificationPublisher(applicationEventPublisher, rabbitTemplate) + ReflectionTestUtils.setField(publisher, "notificationExchangeName", "notificationExchange") + ReflectionTestUtils.setField(publisher, "notificationRoutingKey", "notificationRouting") + } + + def "publish delegates to application event publisher"() { + given: + def event = event() + + when: + publisher.publish(event) + + then: + 1 * applicationEventPublisher.publishEvent(event) + 0 * _ + } + + def "send to queue publishes rabbit message"() { + given: + def event = event() + + when: + publisher.sendToQueue(event) + + then: + 1 * rabbitTemplate.convertAndSend("notificationExchange", "notificationRouting", event) + 0 * _ + } + + def "send to queue swallows amqp exception"() { + when: + publisher.sendToQueue(event()) + + then: + 1 * rabbitTemplate.convertAndSend(_, _, _) >> { throw new AmqpException("down") } + noExceptionThrown() + } + + private static MessageNotificationEvent event() { + new MessageNotificationEvent(2L, 1L, 20L, 100L, MessageType.TEXT, "hello") + } +} diff --git a/src/test/groovy/com/messaging/chat/service/MessageServiceSpec.groovy b/src/test/groovy/com/messaging/chat/service/MessageServiceSpec.groovy new file mode 100644 index 0000000..13d8137 --- /dev/null +++ b/src/test/groovy/com/messaging/chat/service/MessageServiceSpec.groovy @@ -0,0 +1,269 @@ +package com.messaging.chat.service + +import com.messaging.chat.dao.entity.Attachment +import com.messaging.chat.dao.entity.Conversation +import com.messaging.chat.dao.entity.ConversationDeliveryState +import com.messaging.chat.dao.entity.ConversationParticipant +import com.messaging.chat.dao.entity.Message +import com.messaging.chat.dao.entity.ReadDeliveryState +import com.messaging.chat.dao.repository.AttachmentRepository +import com.messaging.chat.dao.repository.ConversationDeliveryStateRepository +import com.messaging.chat.dao.repository.ConversationRepository +import com.messaging.chat.dao.repository.MessageRepository +import com.messaging.chat.dao.repository.ReadDeliveryStateRepository +import com.messaging.chat.mapper.MessageMapper +import com.messaging.chat.mapper.MessageStateMapper +import com.messaging.chat.model.constant.FileStatus +import com.messaging.chat.model.constant.MessageType +import com.messaging.chat.model.dto.request.SendMessageRequest +import com.messaging.chat.model.exceptions.FileValidationException +import com.messaging.chat.model.exceptions.ResourceNotFound +import org.mapstruct.factory.Mappers +import org.springframework.data.domain.PageRequest +import org.springframework.web.server.ResponseStatusException +import spock.lang.Specification + +import java.time.LocalDateTime + +class MessageServiceSpec extends Specification { + + private ConversationRepository conversationRepository + private MessageRepository messageRepository + private AttachmentRepository attachmentRepository + private ConversationDeliveryStateRepository deliveryStateRepository + private ReadDeliveryStateRepository readStateRepository + private AttachmentService attachmentService + private MessageNotificationPublisher notificationPublisher + private MessageService service + + def setup() { + conversationRepository = Mock() + messageRepository = Mock() + attachmentRepository = Mock() + deliveryStateRepository = Mock() + readStateRepository = Mock() + attachmentService = Mock() + notificationPublisher = Mock() + service = new MessageService( + conversationRepository, + messageRepository, + attachmentRepository, + deliveryStateRepository, + readStateRepository, + Mappers.getMapper(MessageMapper), + Mappers.getMapper(MessageStateMapper), + attachmentService, + notificationPublisher + ) + } + + def "get messages validates participant and adds download urls for uploaded attachments"() { + given: + def message = message(100L, conversation([1L, 2L]), 1L) + message.attachments = [ + attachment(10L, FileStatus.UPLOADED, null), + attachment(11L, FileStatus.PENDING, null) + ] + + when: + def response = service.getMessages(1L, 20L, null, 30) + + then: + 1 * conversationRepository.existsByIdAndConversationParticipantsUserId(20L, 1L) >> true + 1 * messageRepository.findByConversationIdOrderByIdDesc(20L, PageRequest.of(0, 30)) >> [message] + 1 * attachmentService.createDownloadUrl("attachments/10") >> "https://download/10" + 0 * _ + + and: + response[0].id() == 100L + response[0].attachments()[0].downloadUrl() == "https://download/10" + response[0].attachments()[1].downloadUrl() == null + } + + def "get messages uses before message cursor query"() { + when: + def response = service.getMessages(1L, 20L, 90L, 10) + + then: + 1 * conversationRepository.existsByIdAndConversationParticipantsUserId(20L, 1L) >> true + 1 * messageRepository.findByConversationIdAndIdLessThanOrderByIdDesc(20L, 90L, PageRequest.of(0, 10)) >> [] + 0 * _ + + and: + response == [] + } + + def "get messages rejects non participant"() { + when: + service.getMessages(1L, 20L, null, 30) + + then: + 1 * conversationRepository.existsByIdAndConversationParticipantsUserId(20L, 1L) >> false + 0 * _ + thrown(ResponseStatusException) + } + + def "send message returns existing message for duplicate client id"() { + given: + def conversation = conversation([1L, 2L]) + def existing = message(100L, conversation, 1L) + existing.clientMessageId = "client-1" + + when: + def response = service.sendMessage(1L, 20L, + new SendMessageRequest("client-1", MessageType.TEXT, "ignored", [])) + + then: + 1 * conversationRepository.findById(20L) >> Optional.of(conversation) + 1 * conversationRepository.existsByIdAndConversationParticipantsUserId(20L, 1L) >> true + 1 * messageRepository.findByConversationIdAndSenderIdAndClientMessageId(20L, 1L, "client-1") >> Optional.of(existing) + 0 * messageRepository.save(_) + 0 * notificationPublisher._ + + and: + response.id() == 100L + response.textContent() == "hello" + } + + def "send message saves message links attachments and publishes notification to other participants"() { + given: + def conversation = conversation([1L, 2L, 3L]) + def uploadedAttachment = attachment(10L, FileStatus.UPLOADED, null) + + when: + def response = service.sendMessage(1L, 20L, + new SendMessageRequest("client-1", MessageType.TEXT, "hello", [10L])) + + then: + 1 * conversationRepository.findById(20L) >> Optional.of(conversation) + 1 * conversationRepository.existsByIdAndConversationParticipantsUserId(20L, 1L) >> true + 1 * messageRepository.findByConversationIdAndSenderIdAndClientMessageId(20L, 1L, "client-1") >> Optional.empty() + 1 * attachmentRepository.findAllByIdIn({ it as List == [10L] }) >> [uploadedAttachment] + 1 * messageRepository.save({ + it.senderId == 1L && + it.conversation.is(conversation) && + it.attachments == [uploadedAttachment] && + uploadedAttachment.message.is(it) + } as Message) >> { Message saved -> + saved.id = 100L + saved.createdAt = LocalDateTime.of(2026, 4, 19, 10, 5) + saved + } + 1 * notificationPublisher.publish({ it.recipientUserId() == 2L && it.messageId() == 100L }) + 1 * notificationPublisher.publish({ it.recipientUserId() == 3L && it.messageId() == 100L }) + 1 * attachmentService.createDownloadUrl("attachments/10") >> "https://download/10" + 0 * _ + + and: + response.id() == 100L + response.attachments()[0].downloadUrl() == "https://download/10" + } + + def "send message rejects invalid attachment state"() { + given: + def conversation = conversation([1L, 2L]) + + when: + service.sendMessage(1L, 20L, new SendMessageRequest("client-1", MessageType.TEXT, "hello", [10L])) + + then: + 1 * conversationRepository.findById(20L) >> Optional.of(conversation) + 1 * conversationRepository.existsByIdAndConversationParticipantsUserId(20L, 1L) >> true + 1 * messageRepository.findByConversationIdAndSenderIdAndClientMessageId(20L, 1L, "client-1") >> Optional.empty() + 1 * attachmentRepository.findAllByIdIn(_) >> [attachment(10L, FileStatus.PENDING, null)] + 0 * messageRepository.save(_) + thrown(FileValidationException) + } + + def "send message throws when not all attachments exist"() { + given: + def conversation = conversation([1L, 2L]) + + when: + service.sendMessage(1L, 20L, new SendMessageRequest("client-1", MessageType.TEXT, "hello", [10L, 11L])) + + then: + 1 * conversationRepository.findById(20L) >> Optional.of(conversation) + 1 * conversationRepository.existsByIdAndConversationParticipantsUserId(20L, 1L) >> true + 1 * messageRepository.findByConversationIdAndSenderIdAndClientMessageId(20L, 1L, "client-1") >> Optional.empty() + 1 * attachmentRepository.findAllByIdIn(_) >> [attachment(10L, FileStatus.UPLOADED, null)] + 0 * messageRepository.save(_) + thrown(ResourceNotFound) + } + + def "mark delivered creates state and moves it forward"() { + given: + def conversation = conversation([1L, 2L]) + def message = message(100L, conversation, 2L) + + when: + def response = service.markDelivered(1L, 20L, 100L) + + then: + 1 * conversationRepository.findById(20L) >> Optional.of(conversation) + 1 * conversationRepository.existsByIdAndConversationParticipantsUserId(20L, 1L) >> true + 1 * messageRepository.existsByIdAndConversationId(100L, 20L) >> true + 1 * messageRepository.getReferenceById(100L) >> message + 1 * deliveryStateRepository.findByConversationIdAndUserId(20L, 1L) >> Optional.empty() + 1 * deliveryStateRepository.save({ it.userId == 1L && it.lastMessageId.is(message) }) >> { ConversationDeliveryState state -> state } + 0 * _ + + and: + response == new com.messaging.chat.model.dto.response.MessageStateResponse(20L, 1L, 100L) + } + + def "mark read does not move state backward"() { + given: + def conversation = conversation([1L, 2L]) + def currentMessage = message(120L, conversation, 2L) + def existingState = new ReadDeliveryState(userId: 1L, conversation: conversation, lastMessageId: currentMessage) + + when: + def response = service.markRead(1L, 20L, 100L) + + then: + 1 * conversationRepository.findById(20L) >> Optional.of(conversation) + 1 * conversationRepository.existsByIdAndConversationParticipantsUserId(20L, 1L) >> true + 1 * messageRepository.existsByIdAndConversationId(100L, 20L) >> true + 1 * messageRepository.getReferenceById(100L) >> message(100L, conversation, 2L) + 1 * readStateRepository.findByConversationIdAndUserId(20L, 1L) >> Optional.of(existingState) + 0 * readStateRepository.save(_) + 0 * _ + + and: + response == new com.messaging.chat.model.dto.response.MessageStateResponse(20L, 1L, 120L) + } + + private static Conversation conversation(List participantIds) { + def conversation = new Conversation(id: 20L) + conversation.conversationParticipants = participantIds.collect { + new ConversationParticipant(userId: it, conversation: conversation) + } + conversation + } + + private static Message message(Long id, Conversation conversation, Long senderId) { + new Message( + id: id, + conversation: conversation, + senderId: senderId, + clientMessageId: "client-${id}", + type: MessageType.TEXT, + textContent: "hello", + createdAt: LocalDateTime.of(2026, 4, 19, 10, 5), + attachments: [] + ) + } + + private static Attachment attachment(Long id, FileStatus status, Message linkedMessage) { + new Attachment( + id: id, + storageKey: "attachments/${id}", + originalFileName: "file-${id}.png", + contentType: "image/png", + fileSize: 1024L, + status: status, + message: linkedMessage + ) + } +} diff --git a/src/test/groovy/com/messaging/chat/service/WebSocketTicketServiceSpec.groovy b/src/test/groovy/com/messaging/chat/service/WebSocketTicketServiceSpec.groovy new file mode 100644 index 0000000..c2d0a62 --- /dev/null +++ b/src/test/groovy/com/messaging/chat/service/WebSocketTicketServiceSpec.groovy @@ -0,0 +1,48 @@ +package com.messaging.chat.service + +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.ValueOperations +import spock.lang.Specification + +class WebSocketTicketServiceSpec extends Specification { + + private StringRedisTemplate redisTemplate + private ValueOperations valueOperations + private WebSocketTicketService service + + def setup() { + redisTemplate = Mock() + valueOperations = Mock() + service = new WebSocketTicketService(redisTemplate) + } + + def "resolves user id from one-time redis ticket"() { + when: + def result = service.resolveUserId("abc") + + then: + 1 * redisTemplate.opsForValue() >> valueOperations + 1 * valueOperations.getAndDelete("chat:ws-ticket:abc") >> "123" + 0 * _ + + and: + result.present + result.get() == 123L + } + + def "returns empty when ticket is missing blank or invalid"() { + when: + def result = service.resolveUserId("abc") + + then: + 1 * redisTemplate.opsForValue() >> valueOperations + 1 * valueOperations.getAndDelete("chat:ws-ticket:abc") >> redisValue + 0 * _ + + and: + result.empty + + where: + redisValue << [null, "", "not-number"] + } +}